mx_remote/runtime/control.rs
1// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
2// Copyright (c) 2026 Op den Kamp IT Solutions
3
4//! The control surface: what a caller can ask a device to do.
5//!
6//! Every method here has the same shape. It reads the registry to decide what
7//! to send, releases that lock, transmits, and only then writes back what the
8//! device will have done. The order is what makes a handler woken by the
9//! write-back free to call in again, and it keeps the receive thread from
10//! waiting on a socket write for the lock it needs to decode.
11//!
12//! Nothing here reaches the wire on its own: a payload is bytes until the
13//! single transmit path stamps and writes it, which is where the addressee's
14//! protocol version is checked.
15//!
16//! The multiviewer and audio-endpoint methods are served by loadable modules
17//! rather than by the device firmware, and a model may not load modules at
18//! all, may not ship that one, or may not support it. Those modules answer
19//! nothing either way, so an `Ok` from one of those methods says a frame left
20//! the socket and no more: "the device did it" and "nothing on the device
21//! handles this" are the same observation from here. Read the state back to
22//! tell them apart. A multiviewer broadcasts its whole status shortly after a
23//! setting it accepted, which serves as that read for every one of its methods
24//! but [`Remote::set_multiviewer_remote_control`] and
25//! [`Remote::set_multiviewer_input_source`], which broadcast nothing.
26
27use std::fmt;
28use std::net::Ipv4Addr;
29use std::time::{SystemTime, UNIX_EPOCH};
30
31use crate::event::Event;
32use crate::state::{Bay, Device, State};
33use crate::types::{
34 AmpZoneSettings, AudioFeatures, HiddenStatus, MultiviewerStatus, PowerStatus, UnitNetConfig,
35 V2ipAudioFormat, V2ipDeviceSettings, V2ipOutputMode, V2ipPowerSaveSchedule, V2ipPtpMessages,
36 V2ipRoute, V2ipRouteTarget, V2ipScalingSettings, V2ipStreamSources, V2ipTestSync, V2ipTestTone,
37 V2ipTestcard, V2ipVlan, VideoWallOp, VideoWallWindow, VolumeMuteStatus, MULTIVIEWER_INPUTS,
38 SCALING_FLAG_AUTO_SCALING, SCALING_FLAG_MODE_VALID, SCALING_FLAG_OPTIONS_VALID,
39 V2IP_PTP_DOMAIN_MAX, V2IP_SDP_MAX, V2IP_VLAN_PORT_SFP, VIDEO_WALL_CLEARED,
40};
41use crate::wire::{
42 audio_cmd_header, audio_param, audio_sub, build_amp_zone_settings, build_audio_select_input,
43 build_bay_hide, build_edid_profile, build_edid_request, build_mesh_operation,
44 build_privacy_request, build_rc_action, build_rc_key, build_set_bay_name, build_set_volume,
45 build_sink_sdp_command, build_sink_sdp_pages, build_stats_request, build_target_only,
46 build_time_zone, build_unit_command, build_v2ip_device_settings,
47 build_v2ip_manual_source_switch, build_v2ip_scaling, build_v2ip_settings_all,
48 build_v2ip_source_switch, build_v2ip_testcard, build_v2ip_vlan, build_video_wall, mesh_op,
49 mv_cmd_payload, mv_sub, op, sdp_type, testcard_part, testcard_type, Addressee, BayUid,
50 DeviceFeature, DeviceUid, EdidProfile, MultiviewerAspectRatio, MultiviewerEdidTemplate,
51 MultiviewerHdcpMode, MultiviewerItcMode, MultiviewerOutputMode, MultiviewerPipPosition,
52 MultiviewerPipSize, MultiviewerSource, MultiviewerViewMode, MxrSignalType, Opcode, RcAction,
53 RcKey, SendError, StreamAddr, UnitCommandFlag, UnitCommandKind, V2ipDeviceSetting,
54 V2ipFpgaFeature, V2ipPowerSaveLevel, V2ipPtpMode, V2ipStreams, V2ipTestPattern, V2ipToneMode,
55 V2ipVlanFlag, DEVICE_NAME_LEN, TIME_ZONE_NAME_LEN, TIME_ZONE_RULE_LEN, V2IP_DSCP_MAX,
56 V2IP_IR_PROFILE_MAX, V2IP_IR_PROFILE_NOT_SET, V2IP_PORT_ANC, V2IP_PORT_AUDIO, V2IP_PORT_VIDEO,
57};
58
59use super::{Remote, Shared};
60
61/// Why a control method did nothing.
62#[derive(Debug)]
63#[non_exhaustive]
64pub enum ControlError {
65 /// No device with this identifier has been heard from.
66 UnknownDevice(DeviceUid),
67 /// The device has reported no bay on this port.
68 UnknownBay(BayUid),
69 /// No input bay on the device carries this user-assigned name.
70 UnknownSource(String),
71 /// The addressee does not do what was asked of it.
72 Unsupported(&'static str),
73 /// The request breaks a rule the device is not guaranteed to check.
74 ///
75 /// Nothing was sent. This is the caller's to fix, and it is separate from
76 /// [`ControlError::Unsupported`] because the device would have taken the
77 /// frame: refusing here is this library declining to let a bad value
78 /// reach hardware that may store it rather than reject it.
79 InvalidRequest(&'static str),
80 /// The device has not reported something the request is assembled from.
81 ///
82 /// Unlike [`ControlError::Unsupported`], the same call may succeed once it
83 /// has: this says the value is missing, not that it cannot exist.
84 NotReported(&'static str),
85 /// The frame could not be sent.
86 Send(SendError),
87}
88
89impl fmt::Display for ControlError {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 match self {
92 Self::UnknownDevice(uid) => write!(f, "no device {uid}"),
93 Self::UnknownBay(uid) => write!(f, "no bay {uid}"),
94 Self::UnknownSource(name) => write!(f, "no source named {name:?}"),
95 Self::Unsupported(what) => f.write_str(what),
96 Self::InvalidRequest(what) => f.write_str(what),
97 Self::NotReported(what) => write!(f, "{what} has not been reported"),
98 Self::Send(e) => write!(f, "{e}"),
99 }
100 }
101}
102
103impl std::error::Error for ControlError {
104 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
105 match self {
106 Self::Send(e) => Some(e),
107 _ => None,
108 }
109 }
110}
111
112impl From<SendError> for ControlError {
113 fn from(e: SendError) -> Self {
114 Self::Send(e)
115 }
116}
117
118/// What a command does to this client's copy of the registry once its frame is
119/// away.
120///
121/// A device does not acknowledge a command, so without this a caller that read
122/// back what it just wrote would see the old value until some unrelated report
123/// happened to carry the new one.
124type WriteBack = Box<dyn FnOnce(&mut State, &mut Vec<Event>) + Send>;
125
126/// One command: the frame to send, and what the addressee will do with it.
127struct Command {
128 to: Addressee,
129 opcode: Opcode,
130 payload: Vec<u8>,
131 write_back: Option<WriteBack>,
132}
133
134impl Command {
135 fn new(to: Addressee, opcode: Opcode, payload: Vec<u8>) -> Self {
136 Self {
137 to,
138 opcode,
139 payload,
140 write_back: None,
141 }
142 }
143
144 /// Records what to apply locally once the frame is away.
145 fn then(mut self, f: impl FnOnce(&mut State, &mut Vec<Event>) + Send + 'static) -> Self {
146 self.write_back = Some(Box::new(f));
147 self
148 }
149}
150
151impl Shared {
152 /// Runs one command: prepare under the registry lock, send without it,
153 /// then write back.
154 fn command(
155 &self,
156 prepare: impl FnOnce(&State) -> Result<Command, ControlError>,
157 ) -> Result<(), ControlError> {
158 let command = self.read(prepare)?;
159 self.send(&command.to, command.opcode, &command.payload)?;
160 if let Some(write_back) = command.write_back {
161 self.mutate(|state, ev| write_back(state, ev));
162 }
163 Ok(())
164 }
165}
166
167/// The first protocol version whose devices can act on the automatic address
168/// operation.
169///
170/// Not the stamp: that is the mesh operation opcode's, and a device between
171/// the two takes the frame and ignores an operation it does not know. Not
172/// every device on this version has it either, but none below it does.
173const AUTO_ADDRESSES_PROTOCOL: u16 = 0x2B;
174
175fn device_of(state: &State, uid: DeviceUid) -> Result<&Device, ControlError> {
176 state.device(uid).ok_or(ControlError::UnknownDevice(uid))
177}
178
179/// The device behind `uid`, once it is known to be a V2IP output.
180fn sdp_sink_of(state: &State, uid: DeviceUid) -> Result<&Device, ControlError> {
181 let device = device_of(state, uid)?;
182 if !device.is_v2ip_sink() {
183 return Err(ControlError::Unsupported("the device is not a V2IP output"));
184 }
185 Ok(device)
186}
187
188/// The device behind `uid`, once it is known to be a multiviewer.
189fn multiviewer_of(state: &State, uid: DeviceUid) -> Result<&Device, ControlError> {
190 let device = device_of(state, uid)?;
191 if !device.is_multiviewer() {
192 return Err(ControlError::Unsupported("the device is not a multiviewer"));
193 }
194 Ok(device)
195}
196
197/// Wraps one multiviewer sub-command in the envelope every one of them shares.
198fn mv_command(device: &Device, sub: u8, args: &[u8]) -> Command {
199 Command::new(
200 Addressee::device(device),
201 op::V2IP_MULTIVIEWER,
202 mv_cmd_payload(device.uid, sub, args),
203 )
204}
205
206/// The zero-based input a source names, refused when it names none.
207///
208/// A multiviewer reads zero as its first input, so there is no value that says
209/// "no input": a source that names none would arrive as a request to switch to
210/// input 1.
211fn source_index(source: MultiviewerSource, what: &'static str) -> Result<u8, ControlError> {
212 source
213 .to_zero_based()
214 .ok_or(ControlError::InvalidRequest(what))
215}
216
217/// A multiviewer setting within the range its firmware accepts.
218///
219/// Every one of these settings is numbered from one, with zero reserved for
220/// "the device has reported nothing". The device drops a value it does not
221/// know without answering, so a caller sending one would see a send succeed
222/// and the setting stay as it was; this is what turns that into an error.
223fn mv_setting(value: u8, highest: u8, what: &'static str) -> Result<u8, ControlError> {
224 if (1..=highest).contains(&value) {
225 Ok(value)
226 } else {
227 Err(ControlError::InvalidRequest(what))
228 }
229}
230
231fn bay_of(state: &State, uid: BayUid) -> Result<(&Device, &Bay), ControlError> {
232 let device = device_of(state, uid.device)?;
233 let bay = device.bay(uid.port).ok_or(ControlError::UnknownBay(uid))?;
234 Ok((device, bay))
235}
236
237/// The bay a command about `uid` acts on, and its device.
238///
239/// A mesh bay stands for bay 0 of another unit, which holds everything the
240/// mesh bay reports, so a command goes to that unit about that bay. Until
241/// the unit has reported the bay there is nowhere to send it, and it is
242/// refused.
243fn acted_on(state: &State, uid: BayUid) -> Result<(&Device, &Bay), ControlError> {
244 let (device, bay) = bay_of(state, uid)?;
245 if !bay.is_v2ip_remote() {
246 return Ok((device, bay));
247 }
248 let owner = state
249 .mesh_owner(bay)
250 .ok_or(ControlError::NotReported("the bay the mesh bay stands for"))?;
251 Ok((device_of(state, owner.uid().device)?, owner))
252}
253
254/// The streams the source bay on `port` advertises.
255fn source_streams(device: &Device, port: u16) -> Result<&V2ipStreamSources, ControlError> {
256 let source = device
257 .bay(port)
258 .ok_or(ControlError::UnknownBay(BayUid::new(device.uid, port)))?;
259 device
260 .v2ip_source_for(source)
261 .ok_or(ControlError::NotReported("the source's stream addresses"))
262}
263
264/// A sink bay, or the reason it cannot be routed.
265///
266/// Returns the device listing the sink, whose numbering the source ports of
267/// a route are in, then the unit and bay that play the route.
268fn v2ip_sink(state: &State, uid: BayUid) -> Result<(&Device, &Device, &Bay), ControlError> {
269 let (_, bay) = bay_of(state, uid)?;
270 if !bay.is_v2ip_sink() {
271 return Err(ControlError::Unsupported("routing needs a V2IP sink"));
272 }
273 let (unit, sink) = acted_on(state, uid)?;
274 Ok((device_of(state, uid.device)?, unit, sink))
275}
276
277/// One route slot as the wire carries it, substituting the stream's standard
278/// port for an unset one.
279///
280/// An unset address sends the slot zeroed, port included: the firmware reads
281/// the pair together, and a port beside 0.0.0.0 describes nothing.
282fn stream_addr(target: V2ipRouteTarget, standard_port: u16) -> StreamAddr {
283 if target.ip.is_unspecified() {
284 return StreamAddr::default();
285 }
286 StreamAddr {
287 ip: target.ip,
288 port: target.port_or(standard_port),
289 }
290}
291
292/// The protocol from which every unit's audio follows its video for a source
293/// switch naming no audio. Some units below it route no audio instead.
294const AUDIO_FOLLOWS_SINCE: u16 = 0x27;
295
296/// The protocol of the earliest firmware known to carry the current audio
297/// module layout; a unit below it that has not reported its endpoints is
298/// taken to carry the older one.
299const AUDIO_LAYOUT_SINCE: u16 = 0x27;
300
301/// The protocol from which a unit applies a scaling write from management.
302const SCALING_WRITE_PROTOCOL: u16 = 0x2F;
303
304/// The name as the device will store it. A unit keeps 15 bytes of the
305/// [`DEVICE_NAME_LEN`]-byte field, the last being its terminator, so a longer
306/// name is cut there, before any character that would not fit whole.
307fn stored_name(name: &str) -> String {
308 let mut end = name.len().min(DEVICE_NAME_LEN - 1);
309 while !name.is_char_boundary(end) {
310 end -= 1;
311 }
312 name[..end].to_owned()
313}
314
315impl Remote {
316 // ---- routing ----
317
318 /// Routes this V2IP sink's video to the stream a source port advertises.
319 ///
320 /// The sink's audio then follows the video: a separate audio route is
321 /// dropped, so route audio after video to keep them apart. A unit below
322 /// protocol 0x27 is routed the source's audio stream instead, as some of
323 /// those route no audio for "follow the video".
324 pub fn select_video_source(&self, sink: BayUid, source_port: u16) -> Result<(), ControlError> {
325 self.shared.command(|state| {
326 let (listing, device, bay) = v2ip_sink(state, sink)?;
327 if !bay.is_output() {
328 return Err(ControlError::Unsupported("not an output bay"));
329 }
330 let streams = source_streams(listing, source_port)?;
331 // Older firmware routes no audio for an empty audio address rather
332 // than following the video, so it is told the source's audio
333 // stream instead, which every version plays.
334 let audio = if device.hello.supported_protocol < AUDIO_FOLLOWS_SINCE {
335 streams.audio.ip
336 } else {
337 Ipv4Addr::UNSPECIFIED
338 };
339 Ok(Command::new(
340 Addressee::device(device),
341 op::V2IP_SOURCE_SWITCH,
342 build_v2ip_source_switch(device.uid, streams.video.ip, audio),
343 ))
344 })
345 }
346
347 /// Routes this V2IP sink's audio to the stream a source port advertises.
348 pub fn select_audio_source(&self, sink: BayUid, source_port: u16) -> Result<(), ControlError> {
349 self.shared.command(|state| {
350 let (listing, device, _) = v2ip_sink(state, sink)?;
351 let streams = source_streams(listing, source_port)?;
352 Ok(Command::new(
353 Addressee::device(device),
354 op::V2IP_SOURCE_SWITCH,
355 build_v2ip_source_switch(device.uid, Ipv4Addr::UNSPECIFIED, streams.audio.ip),
356 ))
357 })
358 }
359
360 /// Routes this V2IP sink's video to the input bay with the given
361 /// user-assigned name.
362 pub fn select_video_source_by_name(
363 &self,
364 sink: BayUid,
365 name: &str,
366 ) -> Result<(), ControlError> {
367 self.select_video_source(sink, self.source_port(sink, name)?)
368 }
369
370 /// Routes this V2IP sink's audio to a multicast address directly, leaving
371 /// its video and ancillary streams alone.
372 ///
373 /// An unset port is the standard V2IP audio port. A format overrides the
374 /// sample rate and channel count the receiver would otherwise assume.
375 pub fn select_audio_source_addr(
376 &self,
377 sink: BayUid,
378 audio_ip: Ipv4Addr,
379 audio_port: Option<u16>,
380 format: Option<V2ipAudioFormat>,
381 ) -> Result<(), ControlError> {
382 self.shared.command(move |state| {
383 let (_, device, _) = v2ip_sink(state, sink)?;
384 let streams = V2ipStreams {
385 audio: StreamAddr {
386 ip: audio_ip,
387 port: audio_port.unwrap_or(V2IP_PORT_AUDIO),
388 },
389 ..V2ipStreams::default()
390 };
391 Ok(Command::new(
392 Addressee::device(device),
393 op::V2IP_MANUAL_SRC_SWITCH,
394 build_v2ip_manual_source_switch(device.uid, streams, format),
395 ))
396 })
397 }
398
399 /// Routes this V2IP sink's video, audio and ancillary streams to
400 /// multicast groups the caller names.
401 ///
402 /// This is the only way to reach a stream no device on the mesh
403 /// advertises, such as one the host is transmitting itself; a route by
404 /// source port can only name a stream some bay has announced.
405 ///
406 /// Set all three groups. The firmware decides whether a sink has a manual
407 /// route by looking at the video and ancillary groups, so a route that
408 /// leaves either unset does not register as one and the sink falls back to
409 /// the audio source its mesh picks.
410 ///
411 /// An unset `format` sends [`V2ipAudioFormat::STANDARD`] rather than
412 /// omitting the trailer. The firmware stores whatever this frame carries
413 /// and hands it to the FPGA unexamined, so a frame without one leaves a
414 /// zero rate and zero channel count there, which the FPGA rejects and
415 /// which takes the switch down with it.
416 pub fn select_source_addr(
417 &self,
418 sink: BayUid,
419 route: V2ipRoute,
420 format: Option<V2ipAudioFormat>,
421 ) -> Result<(), ControlError> {
422 let streams = V2ipStreams {
423 video: stream_addr(route.video, V2IP_PORT_VIDEO),
424 audio: stream_addr(route.audio, V2IP_PORT_AUDIO),
425 anc: stream_addr(route.anc, V2IP_PORT_ANC),
426 };
427 let format = format.unwrap_or(V2ipAudioFormat::STANDARD);
428 self.shared.command(move |state| {
429 let (_, device, _) = v2ip_sink(state, sink)?;
430 Ok(Command::new(
431 Addressee::device(device),
432 op::V2IP_MANUAL_SRC_SWITCH,
433 build_v2ip_manual_source_switch(device.uid, streams, Some(format)),
434 ))
435 })
436 }
437
438 /// Routes this V2IP sink's audio from the input bay with the given
439 /// user-assigned name.
440 ///
441 /// A format is carried on the manual switch frame, which is the only form
442 /// that can override the receiver's sample rate and channel count.
443 pub fn select_audio_source_by_name(
444 &self,
445 sink: BayUid,
446 name: &str,
447 format: Option<V2ipAudioFormat>,
448 ) -> Result<(), ControlError> {
449 let port = self.source_port(sink, name)?;
450 let Some(format) = format else {
451 return self.select_audio_source(sink, port);
452 };
453 self.shared.command(move |state| {
454 let (listing, device, _) = v2ip_sink(state, sink)?;
455 let audio = source_streams(listing, port)?.audio;
456 let streams = V2ipStreams {
457 audio: StreamAddr {
458 ip: audio.ip,
459 port: audio.port,
460 },
461 ..V2ipStreams::default()
462 };
463 Ok(Command::new(
464 Addressee::device(device),
465 op::V2IP_MANUAL_SRC_SWITCH,
466 build_v2ip_manual_source_switch(device.uid, streams, Some(format)),
467 ))
468 })
469 }
470
471 /// The port of the input bay on `sink`'s device carrying `name`.
472 fn source_port(&self, sink: BayUid, name: &str) -> Result<u16, ControlError> {
473 self.shared.read(|state| {
474 let (device, _) = bay_of(state, sink)?;
475 state
476 .input_by_user_name(device, name)
477 .map(|b| b.port)
478 .ok_or_else(|| ControlError::UnknownSource(name.to_owned()))
479 })
480 }
481
482 // ---- bay settings ----
483
484 /// Renames a bay.
485 pub fn set_bay_name(&self, bay: BayUid, name: &str) -> Result<(), ControlError> {
486 let name = stored_name(name);
487 self.shared.command(move |state| {
488 let (device, b) = acted_on(state, bay)?;
489 let bay = b.uid();
490 let payload = build_set_bay_name(device.uid, bay.port, &name);
491 Ok(
492 Command::new(Addressee::device(device), op::CHANGE_BAY_NAME, payload).then(
493 move |state, ev| {
494 if let Some(b) = state.bay_mut(bay) {
495 b.set_user_name(name, ev);
496 }
497 },
498 ),
499 )
500 })
501 }
502
503 /// Hides a bay from the pickers that list it, or shows it again.
504 pub fn set_bay_hidden(&self, bay: BayUid, hidden: bool) -> Result<(), ControlError> {
505 self.shared.command(move |state| {
506 let (device, b) = acted_on(state, bay)?;
507 let bay = b.uid();
508 Ok(Command::new(
509 Addressee::device(device),
510 op::BAY_HIDE,
511 build_bay_hide(device.uid, bay.port, hidden),
512 )
513 .then(move |state, ev| {
514 if let Some(b) = state.bay_mut(bay) {
515 let status = if hidden {
516 HiddenStatus::Hidden
517 } else {
518 HiddenStatus::Visible
519 };
520 b.apply_hidden(status, ev);
521 }
522 }))
523 })
524 }
525
526 /// Sets the EDID profile an input presents to the source attached to it.
527 pub fn select_edid_profile(
528 &self,
529 bay: BayUid,
530 profile: EdidProfile,
531 ) -> Result<(), ControlError> {
532 self.shared.command(move |state| {
533 let (device, b) = acted_on(state, bay)?;
534 let bay = b.uid();
535 Ok(Command::new(
536 Addressee::device(device),
537 op::BAY_EDID_PROFILE,
538 build_edid_profile(device.uid, profile),
539 )
540 .then(move |state, ev| {
541 if let Some(b) = state.bay_mut(bay) {
542 b.set_edid_profile(profile, ev);
543 }
544 }))
545 })
546 }
547
548 /// Sends a remote-control action to whatever is attached to a bay.
549 pub fn send_action(&self, bay: BayUid, action: RcAction) -> Result<(), ControlError> {
550 self.shared.command(move |state| {
551 let (device, b) = acted_on(state, bay)?;
552 Ok(Command::new(
553 Addressee::device(device),
554 op::RC_TX_ACTION,
555 build_rc_action(device.uid, b.port, action),
556 ))
557 })
558 }
559
560 /// Sends a remote-control key press to whatever is attached to a bay.
561 ///
562 /// The device forwards it over CEC, infrared or IP, whichever that bay is
563 /// configured for; the caller does not choose. An action from
564 /// [`Remote::send_action`] names an outcome instead, and the device
565 /// decides which keys reach it.
566 pub fn send_key(&self, bay: BayUid, key: RcKey) -> Result<(), ControlError> {
567 self.shared.command(move |state| {
568 let (device, b) = acted_on(state, bay)?;
569 Ok(Command::new(
570 Addressee::device(device),
571 op::RC_TX_KEY,
572 build_rc_key(device.uid, b.port, key),
573 ))
574 })
575 }
576
577 /// Powers on the device attached to a bay.
578 pub fn power_on(&self, bay: BayUid) -> Result<(), ControlError> {
579 self.set_power(bay, RcAction::POWER_ON, PowerStatus::On)
580 }
581
582 /// Powers off the device attached to a bay.
583 pub fn power_off(&self, bay: BayUid) -> Result<(), ControlError> {
584 self.set_power(bay, RcAction::POWER_OFF, PowerStatus::Off)
585 }
586
587 fn set_power(
588 &self,
589 bay: BayUid,
590 action: RcAction,
591 power: PowerStatus,
592 ) -> Result<(), ControlError> {
593 self.shared.command(move |state| {
594 let (device, b) = acted_on(state, bay)?;
595 let bay = b.uid();
596 Ok(Command::new(
597 Addressee::device(device),
598 op::RC_TX_ACTION,
599 build_rc_action(device.uid, bay.port, action),
600 )
601 .then(move |state, ev| {
602 if let Some(b) = state.bay_mut(bay) {
603 b.set_power_status(power, ev);
604 }
605 }))
606 })
607 }
608
609 /// Sets a bay's volume, as a percentage, and optionally its mute state.
610 ///
611 /// Both channels are set together: the wire carries them separately, but
612 /// nothing on this surface splits them.
613 ///
614 /// A bay with no volume control of its own is set through its
615 /// [`linked_bay`](crate::BayInfo::linked_bay), so an output wired to an
616 /// amplifier zone reaches that zone. [`volume_up`](Self::volume_up),
617 /// [`volume_down`](Self::volume_down) and [`set_muted`](Self::set_muted)
618 /// follow the same link, and read the volume they step from through it.
619 pub fn set_volume(
620 &self,
621 bay: BayUid,
622 volume: u8,
623 muted: Option<bool>,
624 ) -> Result<(), ControlError> {
625 let volume = volume.min(100);
626 let wanted = VolumeMuteStatus {
627 volume_left: Some(volume),
628 volume_right: Some(volume),
629 muted_left: muted,
630 muted_right: muted,
631 };
632 self.shared.command(move |state| {
633 // The mesh may put this bay's volume control on another device, and
634 // the command belongs where the volume lives, not where it was
635 // addressed.
636 let target = state.volume_bay(acted_on(state, bay)?.1.uid());
637 let (device, b) = bay_of(state, target)?;
638 if !b.has_volume_control() {
639 return Err(ControlError::Unsupported("the bay has no volume control"));
640 }
641 Ok(Command::new(
642 Addressee::device(device),
643 op::AUDIO_SET_VOLUME,
644 build_set_volume(device.uid, target.port, wanted),
645 )
646 .then(move |state, ev| {
647 if let Some(device) = state.device_mut(target.device) {
648 device.apply_bay_volume(target.port, wanted, ev);
649 }
650 }))
651 })
652 }
653
654 /// Raises a bay's volume by one percent.
655 ///
656 /// The step carries the mute state the bay last reported rather than
657 /// "unchanged", which an older matrix reads as mute.
658 pub fn volume_up(&self, bay: BayUid) -> Result<(), ControlError> {
659 let (volume, muted) = self.current_volume(bay)?;
660 self.set_volume(bay, volume.saturating_add(1), muted)
661 }
662
663 /// Lowers a bay's volume by one percent, as [`Remote::volume_up`] does.
664 pub fn volume_down(&self, bay: BayUid) -> Result<(), ControlError> {
665 let (volume, muted) = self.current_volume(bay)?;
666 self.set_volume(bay, volume.saturating_sub(1), muted)
667 }
668
669 /// Mutes or unmutes a bay, keeping the volume it is set to.
670 pub fn set_muted(&self, bay: BayUid, muted: bool) -> Result<(), ControlError> {
671 self.set_volume(bay, self.current_volume(bay)?.0, Some(muted))
672 }
673
674 /// The volume a step or a mute is relative to, and the mute state it
675 /// keeps.
676 fn current_volume(&self, bay: BayUid) -> Result<(u8, Option<bool>), ControlError> {
677 self.shared.read(|state| {
678 let (_, b) = bay_of(state, state.volume_bay(acted_on(state, bay)?.1.uid()))?;
679 b.audio_volume
680 .map(|v| (v.volume(), v.muted()))
681 .ok_or(ControlError::NotReported("the bay's volume"))
682 })
683 }
684
685 /// Applies amplifier settings to a zone.
686 pub fn set_amp_zone_settings(
687 &self,
688 bay: BayUid,
689 settings: AmpZoneSettings,
690 ) -> Result<(), ControlError> {
691 self.shared.command(move |state| {
692 let (device, b) = acted_on(state, bay)?;
693 let bay = b.uid();
694 Ok(Command::new(
695 Addressee::device(device),
696 op::AMP_ZONE_SETTINGS,
697 build_amp_zone_settings(device.uid, bay.port, &settings),
698 )
699 .then(move |state, ev| {
700 if let Some(b) = state.bay_mut(bay) {
701 b.set_amp_settings(settings, ev);
702 }
703 }))
704 })
705 }
706
707 // ---- audio endpoints ----
708
709 /// Mutes or unmutes an audio endpoint.
710 ///
711 /// Refused unless the endpoint reports [`AudioFeatures::MUTE`]: a unit
712 /// changes nothing for one that does not.
713 pub fn set_audio_endpoint_muted(
714 &self,
715 device: DeviceUid,
716 endpoint: u16,
717 muted: bool,
718 ) -> Result<(), ControlError> {
719 self.featured_audio_endpoint(
720 device,
721 audio_sub::MUTE,
722 endpoint,
723 muted,
724 AudioFeatures::MUTE,
725 )
726 }
727
728 /// Sets an audio endpoint's trigger output.
729 ///
730 /// Refused unless the endpoint reports [`AudioFeatures::TRIGGER`]: a unit
731 /// changes nothing for one that does not.
732 pub fn set_audio_endpoint_trigger(
733 &self,
734 device: DeviceUid,
735 endpoint: u16,
736 active: bool,
737 ) -> Result<(), ControlError> {
738 self.featured_audio_endpoint(
739 device,
740 audio_sub::TRIGGER,
741 endpoint,
742 active,
743 AudioFeatures::TRIGGER,
744 )
745 }
746
747 /// Locks or unlocks the audio source of an audio endpoint: while it is
748 /// locked, a video route change leaves the endpoint's audio source alone.
749 ///
750 /// Refused unless the endpoint reports
751 /// [`AudioFeatures::AUDIO_LOCK`], which
752 /// is the only one the device acts on. The device reports its endpoints
753 /// again once the lock has changed;
754 /// [`AudioEndpoints::status`](crate::AudioEndpoints::status) reads it.
755 pub fn set_audio_endpoint_locked(
756 &self,
757 device: DeviceUid,
758 endpoint: u8,
759 locked: bool,
760 ) -> Result<(), ControlError> {
761 self.featured_audio_endpoint(
762 device,
763 audio_sub::LOCK,
764 u16::from(endpoint),
765 locked,
766 AudioFeatures::AUDIO_LOCK,
767 )
768 }
769
770 /// Sends an on/off endpoint command, refused unless the endpoint reports
771 /// `feature`, the only case a unit acts on.
772 fn featured_audio_endpoint(
773 &self,
774 device: DeviceUid,
775 sub: u16,
776 endpoint: u16,
777 on: bool,
778 feature: AudioFeatures,
779 ) -> Result<(), ControlError> {
780 self.shared.command(move |state| {
781 let d = device_of(state, device)?;
782 let Some(endpoints) = d.audio.as_ref() else {
783 return Err(ControlError::NotReported("the device's audio endpoints"));
784 };
785 let id = u8::try_from(endpoint).ok();
786 if !id
787 .and_then(|id| endpoints.get(id))
788 .is_some_and(|ep| ep.features.has(feature))
789 {
790 return Err(ControlError::Unsupported(
791 "the endpoint does not have that feature",
792 ));
793 }
794 let mut payload = audio_cmd_header(sub, d.uid);
795 payload.extend_from_slice(&audio_param(endpoint, u32::from(on)));
796 Ok(Command::new(Addressee::device(d), op::V2IP_AUDIO, payload))
797 })
798 }
799
800 /// Sets an audio endpoint's volume. Always refused.
801 ///
802 /// No unit acts on the command: the audio module defines it but neither
803 /// sends nor handles it, and a unit on released firmware reads the same
804 /// sub-opcode as a request to select an output.
805 pub fn set_audio_endpoint_volume(
806 &self,
807 _device: DeviceUid,
808 _endpoint: u16,
809 _volume: u32,
810 ) -> Result<(), ControlError> {
811 Err(ControlError::Unsupported(
812 "no unit acts on an endpoint volume",
813 ))
814 }
815
816 /// Routes a source endpoint on one device to a sink endpoint on another.
817 ///
818 /// A unit on released firmware up to 4.4.2 routes only between its own
819 /// endpoints, so a route from another unit to one is refused.
820 pub fn select_audio_endpoint_input(
821 &self,
822 sink: DeviceUid,
823 sink_endpoint: u16,
824 source: DeviceUid,
825 source_endpoint: u16,
826 ) -> Result<(), ControlError> {
827 self.shared.command(move |state| {
828 let device = device_of(state, sink)?;
829 // An audio module on released firmware up to 4.4.2 reads an
830 // 8-byte body naming two of its own endpoints, and the current
831 // one reads 36 bytes naming both units; neither checks the
832 // length, so each is sent only its own.
833 let payload = if device
834 .audio_legacy
835 .unwrap_or(device.hello.supported_protocol < AUDIO_LAYOUT_SINCE)
836 {
837 if source != sink {
838 return Err(ControlError::Unsupported(
839 "the unit routes only its own audio endpoints",
840 ));
841 }
842 let mut payload = audio_cmd_header(audio_sub::SELECT_INPUT, sink);
843 payload.extend_from_slice(&audio_param(sink_endpoint, u32::from(source_endpoint)));
844 payload
845 } else {
846 build_audio_select_input(sink, sink_endpoint, source, source_endpoint)
847 };
848 Ok(Command::new(
849 Addressee::device(device),
850 op::V2IP_AUDIO,
851 payload,
852 ))
853 })
854 }
855
856 // ---- the whole device ----
857
858 /// Starts or stops a V2IP device reporting its transport statistics.
859 ///
860 /// There is no free-running mode: a device reports only while a
861 /// subscription is live, at 1Hz, and the subscription lapses after a
862 /// minute. A caller that wants a continuous feed re-sends inside the
863 /// minute; nothing here re-arms it.
864 ///
865 /// Reports reach [`crate::EventHandler::on_v2ip_stats_changed`] and read
866 /// back through [`Remote::v2ip_stats`]. A device new enough to send it also
867 /// carries what the sink's decoder recovered, in
868 /// [`crate::V2ipDeviceStats::decoder`].
869 pub fn subscribe_v2ip_stats(
870 &self,
871 device: DeviceUid,
872 subscribe: bool,
873 ) -> Result<(), ControlError> {
874 self.shared.command(move |state| {
875 let device = device_of(state, device)?;
876 Ok(Command::new(
877 Addressee::device(device),
878 op::V2IP_STATS,
879 build_stats_request(device.uid, subscribe),
880 ))
881 })
882 }
883
884 /// Asks a device for an EDID: the one the display on its output
885 /// publishes, or the one it presents to the source on its input.
886 ///
887 /// The device answers with a frame the receive path decodes, so the bytes
888 /// arrive at [`crate::EventHandler::on_edid_received`] and stay readable
889 /// through [`Remote::edid`].
890 ///
891 /// Only V2IP hardware handles this opcode. A matrix or an amplifier
892 /// accepts the frame and answers nothing, at any protocol version, so the
893 /// silence that follows is permanent rather than a reply still to come.
894 /// This call cannot tell the two apart and does not try: it reports what
895 /// was sent, and a caller polling for an EDID should ask a device that can
896 /// answer rather than wait on one that cannot.
897 pub fn request_edid(&self, device: DeviceUid, output: bool) -> Result<(), ControlError> {
898 self.shared.command(move |state| {
899 let device = device_of(state, device)?;
900 Ok(Command::new(
901 Addressee::device(device),
902 op::DEV_EDID,
903 build_edid_request(device.uid, output),
904 ))
905 })
906 }
907
908 /// Asks for a detailed signal report from every bay of one device, or -
909 /// with no device named - from every bay on the network.
910 ///
911 /// Devices report on their own when a signal changes, so this is what a
912 /// client that has just started needs: without it, a bay that has been
913 /// showing the same picture for an hour says nothing until it changes.
914 pub fn request_signal_status(&self, device: Option<DeviceUid>) -> Result<(), ControlError> {
915 let Some(device) = device else {
916 self.shared
917 .send(&Addressee::Broadcast, op::BAY_SIGNAL_STATUS, &[])?;
918 return Ok(());
919 };
920 self.shared.command(move |state| {
921 let device = device_of(state, device)?;
922 Ok(Command::new(
923 Addressee::device(device),
924 op::BAY_SIGNAL_STATUS,
925 build_target_only(device.uid),
926 ))
927 })
928 }
929
930 /// Reboots a device.
931 ///
932 /// The device is marked as rebooting once the frame is away, so the
933 /// silence that follows does not read as one that went offline. A device
934 /// on protocol 0x2C or later takes it only from its mesh controller or a
935 /// management application, which this client announces itself as.
936 pub fn reboot(&self, device: DeviceUid) -> Result<(), ControlError> {
937 self.shared.command(move |state| {
938 let d = device_of(state, device)?;
939 Ok(Command::new(
940 Addressee::device(d),
941 op::SYS_REBOOT,
942 build_target_only(d.uid),
943 )
944 .then(move |state, _| {
945 if let Some(d) = state.device_mut(device) {
946 d.rebooting = true;
947 }
948 }))
949 })
950 }
951
952 /// Asks a device to announce itself now.
953 ///
954 /// Its hello marks it online again, so a device suspected to be gone is
955 /// confirmed or ruled out within a second or two rather than at the end of
956 /// its silence window. A device that stays silent is not taken offline
957 /// here: that remains the silence window's call.
958 pub fn ping(&self, device: DeviceUid) -> Result<(), ControlError> {
959 self.shared.command(move |state| {
960 let d = device_of(state, device)?;
961 Ok(Command::new(
962 Addressee::device(d),
963 op::SYS_PING,
964 build_target_only(d.uid),
965 ))
966 })
967 }
968
969 /// Hands a V2IP source's stream addresses back to automatic assignment,
970 /// undoing addresses that were set on it by hand.
971 ///
972 /// The device takes this only from management, which this client
973 /// announces itself as. Nothing acknowledges it: the device's next
974 /// configuration report carries the addresses it ends up with, and
975 /// [`Remote::v2ip_details`] reads them.
976 ///
977 /// Refused for a device that is not a V2IP source, and for one below
978 /// protocol 0x2B, which predates the operation and ignores it.
979 pub fn auto_assign_v2ip_source_addresses(&self, device: DeviceUid) -> Result<(), ControlError> {
980 self.shared.command(move |state| {
981 let d = device_of(state, device)?;
982 if !d.hello.features.has(DeviceFeature::V2IP_SOURCE) {
983 return Err(ControlError::Unsupported("the device is not a V2IP source"));
984 }
985 let have = d.hello.supported_protocol;
986 if have != 0 && have < AUTO_ADDRESSES_PROTOCOL {
987 return Err(ControlError::Send(SendError::ProtocolTooOld {
988 serial: d.serial().to_owned(),
989 opcode: op::MESH_OPERATION.0,
990 have,
991 need: AUTO_ADDRESSES_PROTOCOL,
992 }));
993 }
994 Ok(Command::new(
995 Addressee::device(d),
996 op::MESH_OPERATION,
997 build_mesh_operation(mesh_op::AUTO_ADDRESSES, d.uid),
998 ))
999 })
1000 }
1001
1002 // ---- V2IP session descriptions ----
1003
1004 /// Has a V2IP output play the stream a session description describes.
1005 ///
1006 /// The description goes out in pages of up to 1 KB, and the output plays
1007 /// it once the last page is in. It takes them only from its mesh
1008 /// controller or a management application, which this client announces
1009 /// itself as. Nothing acknowledges the pages: [`Remote::v2ip_sink_sdp`]
1010 /// reads what the output reports next. Refused for an empty description
1011 /// or one longer than [`V2IP_SDP_MAX`](crate::V2IP_SDP_MAX) bytes, for a
1012 /// device that is not a V2IP output, and for one below protocol 0x2E.
1013 pub fn play_v2ip_sdp(&self, device: DeviceUid, sdp: &str) -> Result<(), ControlError> {
1014 if sdp.is_empty() || sdp.len() > V2IP_SDP_MAX {
1015 return Err(ControlError::InvalidRequest(
1016 "a session description of 1 to 65535 bytes",
1017 ));
1018 }
1019 let (to, pages) = self.shared.read(|state| {
1020 let d = sdp_sink_of(state, device)?;
1021 Ok::<_, ControlError>((
1022 Addressee::device(d),
1023 build_sink_sdp_pages(d.uid, sdp.as_bytes()),
1024 ))
1025 })?;
1026 for page in pages {
1027 self.shared.send(&to, op::V2IP_SINK_SDP, &page)?;
1028 }
1029 Ok(())
1030 }
1031
1032 /// Stops the stream a V2IP output plays from a session description. As
1033 /// [`Remote::play_v2ip_sdp`].
1034 pub fn stop_v2ip_sdp(&self, device: DeviceUid) -> Result<(), ControlError> {
1035 self.sink_sdp_command(device, sdp_type::STOP, 0)
1036 }
1037
1038 /// Sets what a V2IP output shows while it plays audio alone, as the wire
1039 /// value. As [`Remote::play_v2ip_sdp`].
1040 pub fn set_v2ip_sdp_screen(&self, device: DeviceUid, screen: u8) -> Result<(), ControlError> {
1041 self.sink_sdp_command(device, sdp_type::SCREEN, screen)
1042 }
1043
1044 /// Asks a V2IP output what it plays from a session description now,
1045 /// rather than at its next report, which comes at least once a minute.
1046 /// As [`Remote::play_v2ip_sdp`].
1047 pub fn request_v2ip_sink_sdp(&self, device: DeviceUid) -> Result<(), ControlError> {
1048 self.sink_sdp_command(device, sdp_type::REQUEST, 0)
1049 }
1050
1051 /// Asks a V2IP source unit how its streams are encrypted now, rather than
1052 /// at its next report, which comes at least once a minute and whenever
1053 /// they change. [`Remote::v2ip_privacy`] reads the answer.
1054 ///
1055 /// Refused for a device that is not a V2IP source, and for one below
1056 /// protocol 0x30, which predates the report.
1057 pub fn request_v2ip_privacy(&self, device: DeviceUid) -> Result<(), ControlError> {
1058 self.shared.command(move |state| {
1059 let d = device_of(state, device)?;
1060 if !d.hello.features.has(DeviceFeature::V2IP_SOURCE) {
1061 return Err(ControlError::Unsupported("the device is not a V2IP source"));
1062 }
1063 Ok(Command::new(
1064 Addressee::device(d),
1065 op::V2IP_PRIVACY,
1066 build_privacy_request(d.uid),
1067 ))
1068 })
1069 }
1070
1071 /// Asks every V2IP source unit of the mesh how its streams are encrypted,
1072 /// as [`Remote::request_v2ip_privacy`] asks one. A unit below protocol
1073 /// 0x30 ignores it.
1074 pub fn request_all_v2ip_privacy(&self) -> Result<(), ControlError> {
1075 self.shared.send(
1076 &Addressee::Broadcast,
1077 op::V2IP_PRIVACY,
1078 &build_privacy_request(DeviceUid::ZERO),
1079 )?;
1080 Ok(())
1081 }
1082
1083 /// The one send behind the session description commands.
1084 fn sink_sdp_command(&self, device: DeviceUid, kind: u8, value: u8) -> Result<(), ControlError> {
1085 self.shared.command(move |state| {
1086 let d = sdp_sink_of(state, device)?;
1087 Ok(Command::new(
1088 Addressee::device(d),
1089 op::V2IP_SINK_SDP,
1090 build_sink_sdp_command(d.uid, kind, value),
1091 ))
1092 })
1093 }
1094
1095 // ---- units ----
1096
1097 /// Clears a unit's current system status.
1098 ///
1099 /// The terms of [`Remote::upgrade_unit_fpga`] apply.
1100 pub fn clear_unit_status(&self, device: DeviceUid) -> Result<(), ControlError> {
1101 self.unit_command(
1102 device,
1103 UnitCommandKind::CLEAR_STATUS,
1104 UnitCommandFlag::NONE,
1105 None,
1106 )
1107 }
1108
1109 /// Upgrades a unit's video processor to the latest image it holds, or
1110 /// flashes that image again when `force` is set and it already runs it.
1111 ///
1112 /// The unit takes a command only from its mesh controller or a management
1113 /// application, which this client announces itself as. Nothing
1114 /// acknowledges it: [`Remote::unit_status`] reads what the unit reports
1115 /// next. Refused for a device that is not a V2IP unit, and for one below
1116 /// protocol 0x2C, which predates the command.
1117 pub fn upgrade_unit_fpga(&self, device: DeviceUid, force: bool) -> Result<(), ControlError> {
1118 let flags = if force {
1119 UnitCommandFlag::FORCE
1120 } else {
1121 UnitCommandFlag::NONE
1122 };
1123 self.unit_command(device, UnitCommandKind::FPGA_UPGRADE, flags, None)
1124 }
1125
1126 /// Switches a unit to an IP configuration from DHCP.
1127 ///
1128 /// The terms of [`Remote::set_unit_network`] apply.
1129 pub fn set_unit_dhcp(&self, device: DeviceUid) -> Result<(), ControlError> {
1130 self.unit_command(
1131 device,
1132 UnitCommandKind::NET_CONFIG,
1133 UnitCommandFlag::DHCP,
1134 Some(UnitNetConfig::default()),
1135 )
1136 }
1137
1138 /// Gives a unit a static IP configuration.
1139 ///
1140 /// The address must be one a unit can hold and the netmask a run of ones
1141 /// from the top; anything else is refused before it is sent.
1142 ///
1143 /// The unit applies the change at once and reverts it unless its mesh
1144 /// controller, hearing the unit report it, confirms it in time.
1145 /// [`UnitStatus::network_pending`](crate::UnitStatus::network_pending) says
1146 /// whether it still waits, and
1147 /// [`Remote::confirm_unit_network`] confirms it. Otherwise as
1148 /// [`Remote::upgrade_unit_fpga`].
1149 pub fn set_unit_network(
1150 &self,
1151 device: DeviceUid,
1152 network: UnitNetConfig,
1153 ) -> Result<(), ControlError> {
1154 if !network.is_valid() {
1155 return Err(ControlError::InvalidRequest(
1156 "not an address and netmask a unit can hold",
1157 ));
1158 }
1159 self.unit_command(
1160 device,
1161 UnitCommandKind::NET_CONFIG,
1162 UnitCommandFlag::NONE,
1163 Some(network),
1164 )
1165 }
1166
1167 /// Keeps a unit's pending IP configuration, as its mesh controller does
1168 /// when it hears the unit at its new address.
1169 ///
1170 /// The terms of [`Remote::upgrade_unit_fpga`] apply.
1171 pub fn confirm_unit_network(&self, device: DeviceUid) -> Result<(), ControlError> {
1172 self.unit_command(
1173 device,
1174 UnitCommandKind::NET_CONFIRM,
1175 UnitCommandFlag::NONE,
1176 None,
1177 )
1178 }
1179
1180 /// The one send behind the unit command methods.
1181 fn unit_command(
1182 &self,
1183 device: DeviceUid,
1184 command: UnitCommandKind,
1185 flags: UnitCommandFlag,
1186 network: Option<UnitNetConfig>,
1187 ) -> Result<(), ControlError> {
1188 self.shared.command(move |state| {
1189 let d = device_of(state, device)?;
1190 if !d.is_v2ip() {
1191 return Err(ControlError::Unsupported("the device is not a V2IP unit"));
1192 }
1193 Ok(Command::new(
1194 Addressee::device(d),
1195 op::SYS_UNIT_COMMAND,
1196 build_unit_command(d.uid, command, flags, network.as_ref()),
1197 ))
1198 })
1199 }
1200
1201 /// Sets the time zone of every device that hears it.
1202 ///
1203 /// `zone` is an IANA name such as `Europe/Amsterdam` and `rule` the POSIX
1204 /// TZ rule the devices keep time by, such as `CET-1CEST,M3.5.0,M10.5.0/3`.
1205 /// A device applies the rule and shows the name.
1206 /// Neither may be empty, hold a NUL, or be longer than its field on the
1207 /// wire leaves room for; [`Remote::clear_mesh_time_zone`] sends both empty.
1208 ///
1209 /// The mesh controller takes it too, and announces it from then on with
1210 /// every periodic broadcast. A device takes it only from a management
1211 /// application or the controller, and this client announces itself as a
1212 /// management application.
1213 pub fn set_mesh_time_zone(&self, zone: &str, rule: &str) -> Result<(), ControlError> {
1214 let fits = |s: &str, len: usize| !s.is_empty() && s.len() < len && !s.contains('\0');
1215 if !fits(zone, TIME_ZONE_NAME_LEN) || !fits(rule, TIME_ZONE_RULE_LEN) {
1216 return Err(ControlError::InvalidRequest(
1217 "a time zone name or rule is empty, holds a NUL, or is too long",
1218 ));
1219 }
1220 self.shared.send(
1221 &Addressee::Broadcast,
1222 op::TIME_ZONE,
1223 &build_time_zone(zone, rule),
1224 )?;
1225 Ok(())
1226 }
1227
1228 /// Clears the time zone of every device that hears it, which then keeps
1229 /// UTC. Sent, received and announced as [`Remote::set_mesh_time_zone`]
1230 /// with an empty name and rule.
1231 pub fn clear_mesh_time_zone(&self) -> Result<(), ControlError> {
1232 self.shared.send(
1233 &Addressee::Broadcast,
1234 op::TIME_ZONE,
1235 &build_time_zone("", ""),
1236 )?;
1237 Ok(())
1238 }
1239
1240 /// Sets the clock of every device that hears it to `time`.
1241 ///
1242 /// A device keeps its own clock where that is within 2s of `time`. It takes
1243 /// the time only from a management application or the controller; see
1244 /// [`Remote::set_mesh_time_zone`]. A time before 1970 or past what 32 bits
1245 /// of seconds hold, in 2106, is refused.
1246 pub fn set_mesh_time(&self, time: SystemTime) -> Result<(), ControlError> {
1247 let utc = time
1248 .duration_since(UNIX_EPOCH)
1249 .ok()
1250 .and_then(|d| u32::try_from(d.as_secs()).ok())
1251 .ok_or(ControlError::InvalidRequest(
1252 "the time does not fit the frame",
1253 ))?;
1254 self.shared
1255 .send(&Addressee::Broadcast, op::TIME, &utc.to_le_bytes())?;
1256 Ok(())
1257 }
1258
1259 /// Tells every unit to check its update server for new firmware now.
1260 pub fn send_monitoring_pulse(&self) -> Result<(), ControlError> {
1261 self.shared
1262 .send(&Addressee::Broadcast, op::SYS_MONITORING_PULSE, &[])?;
1263 Ok(())
1264 }
1265
1266 // ---- V2IP scaling ----
1267
1268 /// Turns a V2IP sink's automatic scaling on or off.
1269 ///
1270 /// Refused for a unit below protocol 0x2F: released firmware drops a
1271 /// scaling write from management.
1272 ///
1273 /// Automatic scaling and a configured output mode are separate reasons for
1274 /// a sink to scale, and this moves only the first: a sink with a mode
1275 /// configured goes on scaling to it with automatic scaling off. Turning
1276 /// both off is this call plus [`Remote::clear_v2ip_output_mode`].
1277 ///
1278 /// Nothing acknowledges the frame. Read the sink back through
1279 /// [`Remote::v2ip_details`] to learn what it did, and treat the block as
1280 /// meaningful only where [`crate::DeviceInfo::config_initialised`] is set.
1281 /// **Read any route you still need before writing.** The sink rebuilds
1282 /// and rebroadcasts its subscription in response, and that report can
1283 /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
1284 /// and why.
1285 pub fn set_v2ip_auto_scaling(
1286 &self,
1287 device: DeviceUid,
1288 enabled: bool,
1289 ) -> Result<(), ControlError> {
1290 let written = if enabled {
1291 SCALING_FLAG_OPTIONS_VALID | SCALING_FLAG_AUTO_SCALING
1292 } else {
1293 SCALING_FLAG_OPTIONS_VALID
1294 };
1295 self.set_v2ip_scaling(device, MxrSignalType::NONE, 0, written, move |cached| {
1296 V2ipScalingSettings {
1297 flags: (cached.flags & !SCALING_FLAG_AUTO_SCALING)
1298 | SCALING_FLAG_OPTIONS_VALID
1299 | (written & SCALING_FLAG_AUTO_SCALING),
1300 ..cached
1301 }
1302 })
1303 }
1304
1305 /// Sets the output format a V2IP sink scales to.
1306 ///
1307 /// Refused for a unit below protocol 0x2F: released firmware drops a
1308 /// scaling write from management.
1309 ///
1310 /// The mode is checked here and nothing is sent if it fails, because every
1311 /// value a sink refuses it refuses in silence. Passing that check is not a
1312 /// guarantee: the sink also weighs the format against the display's EDID
1313 /// and against what its own output stage can produce.
1314 ///
1315 /// **Turn automatic scaling off first if it is on.** A sink refuses a mode
1316 /// whose format the attached display does not list while it is scaling
1317 /// automatically, and refuses it silently. Setting a mode and then turning
1318 /// automatic scaling back on is the order that survives, because the mode
1319 /// is checked while automatic scaling is still off.
1320 ///
1321 /// Configuring a mode is itself a reason to scale, so a sink with one
1322 /// scales whether or not automatic scaling is on.
1323 ///
1324 /// **Pass a descriptor and a refresh rate that agree.** A sink stores both
1325 /// halves and, with its match-source setting on as it ships, reports back
1326 /// the descriptor matching the refresh it holds: a 60Hz descriptor written
1327 /// with a refresh of 50 reads back as that descriptor's 50Hz sibling, once,
1328 /// and stays there. A sink with match-source off reports the descriptor it
1329 /// was given. Either way a pair that agrees reads back unchanged, and the
1330 /// format driven is the same - so this costs a caller nothing except a
1331 /// descriptor it did not write. That substitution shows up on the sink's
1332 /// next report rather than immediately, because
1333 /// [`crate::V2ipScalingSettings`] holds what was written until then.
1334 ///
1335 /// A mode read from the sink's own web interface is not interchangeable
1336 /// with this pair. That interface reports the descriptor's 60Hz sibling and
1337 /// carries the refresh in a field of its own, so writing back what it shows
1338 /// as the mode, on its own, changes the setting rather than restoring it.
1339 /// **Read any route you still need before writing.** The sink rebuilds
1340 /// and rebroadcasts its subscription in response, and that report can
1341 /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
1342 /// and why.
1343 pub fn set_v2ip_output_mode(
1344 &self,
1345 device: DeviceUid,
1346 mode: V2ipOutputMode,
1347 ) -> Result<(), ControlError> {
1348 mode.validate().map_err(ControlError::InvalidRequest)?;
1349 let signal = mode.to_signal_type();
1350 let refresh = mode.refresh;
1351 self.set_v2ip_scaling(
1352 device,
1353 signal,
1354 refresh,
1355 SCALING_FLAG_MODE_VALID,
1356 move |cached| V2ipScalingSettings {
1357 mode: signal,
1358 refresh,
1359 flags: cached.flags | SCALING_FLAG_MODE_VALID,
1360 },
1361 )
1362 }
1363
1364 /// Clears the output format a V2IP sink is configured to scale to.
1365 ///
1366 /// Refused for a unit below protocol 0x2F: released firmware drops a
1367 /// scaling write from management.
1368 ///
1369 /// The sink stops scaling for that reason and keeps its automatic scaling
1370 /// setting, so a sink scaling for both reasons goes on scaling until
1371 /// [`Remote::set_v2ip_auto_scaling`] turns the other one off.
1372 ///
1373 /// This is the only way to express "no mode configured", and it is what a
1374 /// caller restoring a sink that had none has to send: a sink reports no
1375 /// mode by leaving the mode's valid bit clear, which is not something a
1376 /// write can say.
1377 /// **Read any route you still need before writing.** The sink rebuilds
1378 /// and rebroadcasts its subscription in response, and that report can
1379 /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
1380 /// and why.
1381 pub fn clear_v2ip_output_mode(&self, device: DeviceUid) -> Result<(), ControlError> {
1382 // The valid bit with a zero descriptor is the clear. The receiver takes
1383 // that branch ahead of validating anything, and ignores the depth,
1384 // colour space and refresh rate beside it.
1385 self.set_v2ip_scaling(
1386 device,
1387 MxrSignalType::NONE,
1388 0,
1389 SCALING_FLAG_MODE_VALID,
1390 |cached| V2ipScalingSettings {
1391 mode: MxrSignalType::NONE,
1392 refresh: 0,
1393 flags: cached.flags & !SCALING_FLAG_MODE_VALID,
1394 },
1395 )
1396 }
1397
1398 /// The one send behind the scaling methods.
1399 ///
1400 /// `written` is the flag byte that goes out, and `applied` says what the
1401 /// sink will report afterwards. The two differ where the wire spells a
1402 /// write differently from the state it produces - clearing a mode is sent
1403 /// as the valid bit over a zero mode and read back as the valid bit clear -
1404 /// so predicting the cached value from the frame alone would leave a
1405 /// caller reading a state no device ever broadcasts.
1406 fn set_v2ip_scaling(
1407 &self,
1408 device: DeviceUid,
1409 mode: MxrSignalType,
1410 refresh: u16,
1411 written: u8,
1412 applied: impl FnOnce(V2ipScalingSettings) -> V2ipScalingSettings + Send + 'static,
1413 ) -> Result<(), ControlError> {
1414 self.shared.command(move |state| {
1415 let d = device_of(state, device)?;
1416 if !d.is_v2ip_sink() {
1417 return Err(ControlError::Unsupported(
1418 "scaling settings need a V2IP sink",
1419 ));
1420 }
1421 // A unit below this protocol drops a scaling write from
1422 // management.
1423 if d.hello.supported_protocol < SCALING_WRITE_PROTOCOL {
1424 return Err(ControlError::Unsupported(
1425 "the unit's firmware ignores scaling writes",
1426 ));
1427 }
1428 Ok(Command::new(
1429 Addressee::device(d),
1430 op::V2IP_DEVICE_CFG,
1431 build_v2ip_scaling(d.uid, mode, refresh, written),
1432 )
1433 .then(move |state, ev| {
1434 if let Some(d) = state.device_mut(device) {
1435 let cached = d.v2ip_scaling();
1436 d.set_v2ip_scaling(applied(cached), ev);
1437 }
1438 }))
1439 })
1440 }
1441
1442 // ---- V2IP device settings ----
1443
1444 /// Switches on/off settings of a V2IP device, all to the same value.
1445 ///
1446 /// `setting` names one or more of [`V2ipDeviceSetting::SWITCHES`], and
1447 /// each must be one the device has reported: a device ignores a setting it
1448 /// does not have, so a write for one would read back as applied here and
1449 /// change nothing there.
1450 ///
1451 /// Nothing acknowledges the frame. The device answers by reporting its
1452 /// settings, and until then [`Remote::v2ip_device_settings`] reads back
1453 /// what was written.
1454 pub fn set_v2ip_device_setting(
1455 &self,
1456 device: DeviceUid,
1457 setting: V2ipDeviceSetting,
1458 enabled: bool,
1459 ) -> Result<(), ControlError> {
1460 if setting.is_empty() || !setting.without(V2ipDeviceSetting::SWITCHES).is_empty() {
1461 return Err(ControlError::InvalidRequest(
1462 "only on/off device settings are switched",
1463 ));
1464 }
1465 self.set_v2ip_device_settings(
1466 device,
1467 V2ipDeviceSettings {
1468 valid: setting,
1469 flags: if enabled {
1470 setting
1471 } else {
1472 V2ipDeviceSetting::NONE
1473 },
1474 ..V2ipDeviceSettings::default()
1475 },
1476 )
1477 }
1478
1479 /// Sets the infrared profile of a V2IP device's global infrared port.
1480 ///
1481 /// `profile` is below [`V2IP_IR_PROFILE_MAX`], and is checked here because
1482 /// a device ignores one out of range. The terms of
1483 /// [`Remote::set_v2ip_device_setting`] apply.
1484 pub fn set_v2ip_ir_profile(&self, device: DeviceUid, profile: i8) -> Result<(), ControlError> {
1485 if !(0..V2IP_IR_PROFILE_MAX).contains(&profile) {
1486 return Err(ControlError::InvalidRequest("no such infrared profile"));
1487 }
1488 self.set_v2ip_device_settings(
1489 device,
1490 V2ipDeviceSettings {
1491 valid: V2ipDeviceSetting::IR_PROFILE,
1492 ir_profile: profile,
1493 ..V2ipDeviceSettings::default()
1494 },
1495 )
1496 }
1497
1498 /// Sets the infrared profile of a V2IP device's output infrared port.
1499 ///
1500 /// [`V2IP_IR_PROFILE_NOT_SET`] makes the port follow the global one.
1501 /// Otherwise as [`Remote::set_v2ip_ir_profile`].
1502 pub fn set_v2ip_sink_ir_profile(
1503 &self,
1504 device: DeviceUid,
1505 profile: i8,
1506 ) -> Result<(), ControlError> {
1507 if !(V2IP_IR_PROFILE_NOT_SET..V2IP_IR_PROFILE_MAX).contains(&profile) {
1508 return Err(ControlError::InvalidRequest("no such infrared profile"));
1509 }
1510 self.set_v2ip_device_settings(
1511 device,
1512 V2ipDeviceSettings {
1513 valid: V2ipDeviceSetting::IR_PROFILE_SINK,
1514 ir_profile_sink: profile,
1515 ..V2ipDeviceSettings::default()
1516 },
1517 )
1518 }
1519
1520 /// Sets how many idle minutes a V2IP device waits before it powers down by
1521 /// itself, 0 for never. The terms of [`Remote::set_v2ip_device_setting`]
1522 /// apply.
1523 pub fn set_v2ip_auto_power_save(
1524 &self,
1525 device: DeviceUid,
1526 minutes: u16,
1527 ) -> Result<(), ControlError> {
1528 self.set_v2ip_device_settings(
1529 device,
1530 V2ipDeviceSettings {
1531 valid: V2ipDeviceSetting::AUTO_POWER_SAVE,
1532 auto_power_save: minutes,
1533 ..V2ipDeviceSettings::default()
1534 },
1535 )
1536 }
1537
1538 /// Sets a V2IP device's daily power save windows.
1539 ///
1540 /// Every time is below [`V2IP_MINUTES_PER_DAY`](crate::V2IP_MINUTES_PER_DAY),
1541 /// and is checked here.
1542 /// The windows are kept in the device's own time zone. The terms of
1543 /// [`Remote::set_v2ip_device_setting`] apply.
1544 pub fn set_v2ip_power_save_schedule(
1545 &self,
1546 device: DeviceUid,
1547 schedule: V2ipPowerSaveSchedule,
1548 ) -> Result<(), ControlError> {
1549 if !schedule.is_valid() {
1550 return Err(ControlError::InvalidRequest(
1551 "a power save time is not a time of day",
1552 ));
1553 }
1554 self.set_v2ip_device_settings(
1555 device,
1556 V2ipDeviceSettings {
1557 valid: V2ipDeviceSetting::POWER_SAVE_SCHEDULE,
1558 power_save: schedule,
1559 ..V2ipDeviceSettings::default()
1560 },
1561 )
1562 }
1563
1564 /// Sets the level a V2IP device powers down to in power save. A level the
1565 /// unit does not offer is one its [`UnitStatus`](crate::UnitStatus)
1566 /// reports without [`SUPPORTED`](crate::UnitPowerSave::SUPPORTED). It also
1567 /// switches [`POWER_SAVE_DISABLED`](V2ipDeviceSetting::POWER_SAVE_DISABLED)
1568 /// off. The terms of [`Remote::set_v2ip_device_setting`] apply.
1569 pub fn set_v2ip_power_save_level(
1570 &self,
1571 device: DeviceUid,
1572 level: V2ipPowerSaveLevel,
1573 ) -> Result<(), ControlError> {
1574 if level > V2ipPowerSaveLevel::OFF {
1575 return Err(ControlError::InvalidRequest("no such power save level"));
1576 }
1577 self.set_v2ip_device_settings(
1578 device,
1579 V2ipDeviceSettings {
1580 valid: V2ipDeviceSetting::POWER_SAVE_LEVEL,
1581 power_save_level: level,
1582 ..V2ipDeviceSettings::default()
1583 },
1584 )
1585 }
1586
1587 /// Sets how a V2IP device paces its PTP messages, and its SAP timings: the
1588 /// values of `messages` behind the bits of `which`, each one of
1589 /// [`SETTINGS_BLOCK`](V2ipDeviceSetting::SETTINGS_BLOCK). A DSCP above
1590 /// [`V2IP_DSCP_MAX`](crate::V2IP_DSCP_MAX) and a stream TTL of 0 are
1591 /// refused. The terms of [`Remote::set_v2ip_device_setting`] apply.
1592 pub fn set_v2ip_ptp_messages(
1593 &self,
1594 device: DeviceUid,
1595 which: V2ipDeviceSetting,
1596 messages: V2ipPtpMessages,
1597 ) -> Result<(), ControlError> {
1598 if which.is_empty() || !V2ipDeviceSetting::SETTINGS_BLOCK.has(which) {
1599 return Err(ControlError::InvalidRequest(
1600 "only PTP message, SAP timing and stream TTL settings are carried here",
1601 ));
1602 }
1603 if which.has(V2ipDeviceSetting::PTP_DSCP) && messages.dscp > V2IP_DSCP_MAX {
1604 return Err(ControlError::InvalidRequest("a DSCP above 63"));
1605 }
1606 if which.has(V2ipDeviceSetting::STREAM_TTL) && messages.stream_ttl == 0 {
1607 return Err(ControlError::InvalidRequest("a stream TTL of 0"));
1608 }
1609 self.set_v2ip_device_settings(
1610 device,
1611 V2ipDeviceSettings {
1612 valid: which,
1613 ptp_messages: messages,
1614 ..V2ipDeviceSettings::default()
1615 },
1616 )
1617 }
1618
1619 /// Sets the PTP mode a V2IP device runs. The terms of
1620 /// [`Remote::set_v2ip_device_setting`] apply.
1621 pub fn set_v2ip_ptp_mode(
1622 &self,
1623 device: DeviceUid,
1624 mode: V2ipPtpMode,
1625 ) -> Result<(), ControlError> {
1626 if mode > V2ipPtpMode::AUTO {
1627 return Err(ControlError::InvalidRequest("no such PTP mode"));
1628 }
1629 self.set_v2ip_device_settings(
1630 device,
1631 V2ipDeviceSettings {
1632 valid: V2ipDeviceSetting::PTP_MODE,
1633 ptp_mode: mode,
1634 ..V2ipDeviceSettings::default()
1635 },
1636 )
1637 }
1638
1639 /// Sets the PTP domain a V2IP device runs in, at most
1640 /// [`V2IP_PTP_DOMAIN_MAX`](crate::V2IP_PTP_DOMAIN_MAX). The terms of
1641 /// [`Remote::set_v2ip_device_setting`] apply.
1642 pub fn set_v2ip_ptp_domain(&self, device: DeviceUid, domain: u8) -> Result<(), ControlError> {
1643 if domain > V2IP_PTP_DOMAIN_MAX {
1644 return Err(ControlError::InvalidRequest("no such PTP domain"));
1645 }
1646 self.set_v2ip_device_settings(
1647 device,
1648 V2ipDeviceSettings {
1649 valid: V2ipDeviceSetting::PTP_DOMAIN,
1650 ptp_domain: domain,
1651 ..V2ipDeviceSettings::default()
1652 },
1653 )
1654 }
1655
1656 /// Sets the priority1 a V2IP device announces as a PTP grandmaster, lower
1657 /// winning. The terms of [`Remote::set_v2ip_device_setting`] apply.
1658 pub fn set_v2ip_ptp_priority1(
1659 &self,
1660 device: DeviceUid,
1661 priority1: u8,
1662 ) -> Result<(), ControlError> {
1663 self.set_v2ip_device_settings(
1664 device,
1665 V2ipDeviceSettings {
1666 valid: V2ipDeviceSetting::PTP_PRIORITY1,
1667 ptp_priority1: priority1,
1668 ..V2ipDeviceSettings::default()
1669 },
1670 )
1671 }
1672
1673 /// Changes settings on every V2IP device of the mesh with one frame.
1674 ///
1675 /// `settings` carries the settings behind their bits in
1676 /// [`valid`](V2ipDeviceSettings::valid), as a device reports them. Each
1677 /// device applies those it has and ignores the rest, and none that
1678 /// predates the frame applies any. A setting only a device reports about
1679 /// itself, a profile, PTP mode, PTP domain, power save level or PTP DSCP
1680 /// out of range, a stream TTL of 0 and a schedule time that is not a time
1681 /// of day are refused
1682 /// here, since every device would ignore them.
1683 ///
1684 /// Nothing is cached: each device that applies a change reports its
1685 /// settings, and [`Remote::v2ip_device_settings`] reads that.
1686 pub fn set_all_v2ip_device_settings(
1687 &self,
1688 settings: V2ipDeviceSettings,
1689 ) -> Result<(), ControlError> {
1690 let valid = settings.valid;
1691 if valid.is_empty() {
1692 return Err(ControlError::InvalidRequest("no setting is carried"));
1693 }
1694 if !(valid & V2ipDeviceSetting::REPORTED_ONLY).is_empty() {
1695 return Err(ControlError::InvalidRequest(
1696 "a setting only a device reports is carried",
1697 ));
1698 }
1699 if valid.has(V2ipDeviceSetting::IR_PROFILE)
1700 && !(0..V2IP_IR_PROFILE_MAX).contains(&settings.ir_profile)
1701 {
1702 return Err(ControlError::InvalidRequest("no such infrared profile"));
1703 }
1704 if valid.has(V2ipDeviceSetting::IR_PROFILE_SINK)
1705 && !(V2IP_IR_PROFILE_NOT_SET..V2IP_IR_PROFILE_MAX).contains(&settings.ir_profile_sink)
1706 {
1707 return Err(ControlError::InvalidRequest("no such infrared profile"));
1708 }
1709 if valid.has(V2ipDeviceSetting::POWER_SAVE_SCHEDULE) && !settings.power_save.is_valid() {
1710 return Err(ControlError::InvalidRequest(
1711 "a power save time is not a time of day",
1712 ));
1713 }
1714 if valid.has(V2ipDeviceSetting::PTP_MODE) && settings.ptp_mode > V2ipPtpMode::AUTO {
1715 return Err(ControlError::InvalidRequest("no such PTP mode"));
1716 }
1717 if valid.has(V2ipDeviceSetting::PTP_DOMAIN) && settings.ptp_domain > V2IP_PTP_DOMAIN_MAX {
1718 return Err(ControlError::InvalidRequest("no such PTP domain"));
1719 }
1720 if valid.has(V2ipDeviceSetting::POWER_SAVE_LEVEL)
1721 && settings.power_save_level > V2ipPowerSaveLevel::OFF
1722 {
1723 return Err(ControlError::InvalidRequest("no such power save level"));
1724 }
1725 if valid.has(V2ipDeviceSetting::PTP_DSCP) && settings.ptp_messages.dscp > V2IP_DSCP_MAX {
1726 return Err(ControlError::InvalidRequest("a DSCP above 63"));
1727 }
1728 if valid.has(V2ipDeviceSetting::STREAM_TTL) && settings.ptp_messages.stream_ttl == 0 {
1729 return Err(ControlError::InvalidRequest("a stream TTL of 0"));
1730 }
1731 self.shared.send(
1732 &Addressee::Broadcast,
1733 op::V2IP_SETTINGS_ALL,
1734 &build_v2ip_settings_all(&settings),
1735 )?;
1736 Ok(())
1737 }
1738
1739 /// The one send behind the device settings methods.
1740 fn set_v2ip_device_settings(
1741 &self,
1742 device: DeviceUid,
1743 settings: V2ipDeviceSettings,
1744 ) -> Result<(), ControlError> {
1745 self.shared.command(move |state| {
1746 let d = device_of(state, device)?;
1747 let Some(reported) = d.v2ip_settings else {
1748 return Err(ControlError::NotReported("the device's settings"));
1749 };
1750 if !reported.valid.has(settings.valid) {
1751 return Err(ControlError::Unsupported(
1752 "the device does not have this setting",
1753 ));
1754 }
1755 Ok(Command::new(
1756 Addressee::device(d),
1757 op::V2IP_DEVICE_CFG,
1758 build_v2ip_device_settings(d.uid, &settings),
1759 )
1760 .then(move |state, ev| {
1761 if let Some(d) = state.device_mut(device) {
1762 d.merge_v2ip_settings(settings, ev);
1763 }
1764 }))
1765 })
1766 }
1767
1768 // ---- V2IP VLAN ----
1769
1770 /// Changes a V2IP device's VLAN configuration.
1771 ///
1772 /// Writes the VLAN ids, the pinned uplink and the
1773 /// [`TRUNK`](V2ipVlanFlag::TRUNK) flag of `vlan`; its other flags and the
1774 /// fields only the device reports are not sent. Refused before anything is
1775 /// sent unless the device announces [`DeviceFeature::VLAN`] and has
1776 /// reported its configuration, every id is at most
1777 /// [`V2IP_VLAN_ID_MAX`](crate::V2IP_VLAN_ID_MAX), and the uplink is
1778 /// detected or names a port the device has.
1779 ///
1780 /// The device applies the change at once and reverts it unless the mesh
1781 /// controller, hearing the device report it, confirms it. Nothing is
1782 /// cached here: [`Remote::v2ip_vlan`] reads what the device reports, and
1783 /// [`V2ipVlan::is_pending`] whether it is still to be confirmed.
1784 pub fn set_v2ip_vlan(&self, device: DeviceUid, vlan: V2ipVlan) -> Result<(), ControlError> {
1785 if !vlan.is_valid() {
1786 return Err(ControlError::InvalidRequest(
1787 "a VLAN id is out of range or the uplink names no port",
1788 ));
1789 }
1790 let written = V2ipVlan {
1791 flags: V2ipVlanFlag::VALID | (vlan.flags & V2ipVlanFlag::TRUNK),
1792 device: vlan.device,
1793 port: vlan.port,
1794 uplink: vlan.uplink,
1795 active_uplink: 0,
1796 revert_s: 0,
1797 };
1798 self.shared.command(move |state| {
1799 let d = device_of(state, device)?;
1800 if !d.hello.features.has(DeviceFeature::VLAN) {
1801 return Err(ControlError::Unsupported("the device does not take VLANs"));
1802 }
1803 let Some(reported) = d.v2ip_vlan else {
1804 return Err(ControlError::NotReported("the device's VLAN configuration"));
1805 };
1806 if written.pinned_uplink() == Some(V2IP_VLAN_PORT_SFP) && !reported.has_sfp() {
1807 return Err(ControlError::Unsupported("the device has no SFP port"));
1808 }
1809 Ok(Command::new(
1810 Addressee::device(d),
1811 op::V2IP_DEVICE_CFG,
1812 build_v2ip_vlan(d.uid, &written),
1813 ))
1814 })
1815 }
1816
1817 // ---- V2IP test card ----
1818
1819 /// Asks a V2IP sink for its test card, which it reports straight back;
1820 /// [`Remote::v2ip_testcard`] reads it.
1821 ///
1822 /// Refused unless the sink's video processor has reported
1823 /// [`SINK_TEST_PATTERN`](V2ipFpgaFeature::SINK_TEST_PATTERN). A sink with
1824 /// that feature but without the module that draws the test card does not
1825 /// answer.
1826 pub fn request_v2ip_testcard(&self, device: DeviceUid) -> Result<(), ControlError> {
1827 self.send_v2ip_testcard(device, testcard_type::REQUEST, 0, V2ipTestcard::default())
1828 }
1829
1830 /// Shows a test pattern on a V2IP sink's output, or none for
1831 /// [`V2ipTestPattern::OFF`]. `colour` is `0xRRGGBB`, used by
1832 /// [`V2ipTestPattern::FLAT`].
1833 ///
1834 /// A pattern runs until it is turned off, and holds the output on while it
1835 /// does. The sink reports its test card in answer. Refused as
1836 /// [`Remote::request_v2ip_testcard`] is, and for a pattern this library
1837 /// does not name or a colour above `0xFFFFFF`.
1838 pub fn set_v2ip_test_pattern(
1839 &self,
1840 device: DeviceUid,
1841 pattern: V2ipTestPattern,
1842 colour: u32,
1843 ) -> Result<(), ControlError> {
1844 if pattern.to_wire() > V2ipTestPattern::CARD.to_wire() || colour > 0x00FF_FFFF {
1845 return Err(ControlError::InvalidRequest(
1846 "no such test pattern, or a colour wider than 24 bits",
1847 ));
1848 }
1849 self.send_v2ip_testcard(
1850 device,
1851 testcard_type::SET,
1852 testcard_part::PATTERN,
1853 V2ipTestcard {
1854 pattern,
1855 colour,
1856 ..V2ipTestcard::default()
1857 },
1858 )
1859 }
1860
1861 /// Plays a test tone on a V2IP sink's output.
1862 ///
1863 /// Every value is checked here, as the sink ignores a tone that is not
1864 /// [`V2ipTestTone::is_valid`]; [`V2ipToneMode::OFF`] stops it whatever the
1865 /// rest holds, and the sink keeps those values as its last ones. Otherwise
1866 /// as [`Remote::set_v2ip_test_pattern`].
1867 pub fn set_v2ip_test_tone(
1868 &self,
1869 device: DeviceUid,
1870 tone: V2ipTestTone,
1871 ) -> Result<(), ControlError> {
1872 if tone.mode != V2ipToneMode::OFF && !tone.is_valid() {
1873 return Err(ControlError::InvalidRequest(
1874 "a test tone value is out of range",
1875 ));
1876 }
1877 self.send_v2ip_testcard(
1878 device,
1879 testcard_type::SET,
1880 testcard_part::TONE,
1881 V2ipTestcard {
1882 tone,
1883 ..V2ipTestcard::default()
1884 },
1885 )
1886 }
1887
1888 /// Sets a V2IP sink's lip-sync flash.
1889 ///
1890 /// Checked here as [`V2ipTestSync::is_valid`], since the sink ignores
1891 /// settings that are not. Otherwise as [`Remote::set_v2ip_test_pattern`].
1892 pub fn set_v2ip_test_sync(
1893 &self,
1894 device: DeviceUid,
1895 sync: V2ipTestSync,
1896 ) -> Result<(), ControlError> {
1897 if !sync.is_valid() {
1898 return Err(ControlError::InvalidRequest(
1899 "a lip-sync value is out of range",
1900 ));
1901 }
1902 self.send_v2ip_testcard(
1903 device,
1904 testcard_type::SET,
1905 testcard_part::SYNC,
1906 V2ipTestcard {
1907 sync,
1908 ..V2ipTestcard::default()
1909 },
1910 )
1911 }
1912
1913 /// The one send behind the test card methods. Nothing is cached: the sink
1914 /// answers every frame with its test card.
1915 fn send_v2ip_testcard(
1916 &self,
1917 device: DeviceUid,
1918 kind: u8,
1919 parts: u8,
1920 testcard: V2ipTestcard,
1921 ) -> Result<(), ControlError> {
1922 self.shared.command(move |state| {
1923 let d = device_of(state, device)?;
1924 let Some(features) = d.v2ip_features else {
1925 return Err(ControlError::NotReported(
1926 "the sink's video processor features",
1927 ));
1928 };
1929 if !features.has(V2ipFpgaFeature::SINK_TEST_PATTERN) {
1930 return Err(ControlError::Unsupported(
1931 "the sink cannot draw a test card",
1932 ));
1933 }
1934 Ok(Command::new(
1935 Addressee::device(d),
1936 op::V2IP_TESTCARD,
1937 build_v2ip_testcard(d.uid, kind, parts, &testcard),
1938 ))
1939 })
1940 }
1941
1942 // ---- video wall ----
1943
1944 /// Shows a window on a sink's video wall without persisting it.
1945 ///
1946 /// The window survives until the sink is told otherwise or restarts.
1947 /// [`Remote::revert_video_wall`] puts back whatever was stored.
1948 ///
1949 /// Pass [`crate::VIDEO_WALL_CLEARED`] to show the whole frame again.
1950 pub fn preview_video_wall(
1951 &self,
1952 sink: DeviceUid,
1953 window: VideoWallWindow,
1954 ) -> Result<(), ControlError> {
1955 self.set_video_wall(sink, window, VideoWallOp::PREVIEW)
1956 }
1957
1958 /// Persists a window as a sink's video wall.
1959 ///
1960 /// The geometry is checked here, before anything is sent, because the sink
1961 /// is not guaranteed to check it. A sink running a video-wall module older
1962 /// than 2026083100 writes the window to its configuration *before* asking
1963 /// its video processor to apply it, and the processor's refusal does not
1964 /// undo that write - so an out-of-spec window survives a reboot and is
1965 /// re-offered on every stream restart until something else replaces it. A
1966 /// power cycle does not clear it.
1967 ///
1968 /// Nothing acknowledges this frame either way, so an `Ok` says only that
1969 /// it was sent. Read the sink's state back to learn what it did.
1970 ///
1971 /// Pass [`crate::VIDEO_WALL_CLEARED`] to store "show the whole frame".
1972 pub fn store_video_wall(
1973 &self,
1974 sink: DeviceUid,
1975 window: VideoWallWindow,
1976 ) -> Result<(), ControlError> {
1977 self.set_video_wall(sink, window, VideoWallOp::STORE)
1978 }
1979
1980 /// Restores the window a sink has stored, discarding a preview.
1981 ///
1982 /// Carries no window of its own: the sink already holds the one this puts
1983 /// back.
1984 pub fn revert_video_wall(&self, sink: DeviceUid) -> Result<(), ControlError> {
1985 self.set_video_wall(sink, VIDEO_WALL_CLEARED, VideoWallOp::REVERT)
1986 }
1987
1988 /// The one send behind the three video-wall methods.
1989 ///
1990 /// Validation sits here rather than in each of them, so an operation added
1991 /// later cannot reach the wire without it, and is skipped for a revert
1992 /// because the sink ignores the window on that operation rather than
1993 /// checking it.
1994 ///
1995 /// Refused for a unit that does not report
1996 /// [`VIDEO_WALL`](DeviceFeature::VIDEO_WALL), which drops the frame
1997 /// unanswered.
1998 ///
1999 /// Passing it is not proof a wall appeared. Two things the sink refuses
2000 /// afterwards are equally silent: a window it will not draw, which it logs
2001 /// and drops, and a sink whose image has no tiling support at all, which
2002 /// takes the window into its own state and then fails to push it to the
2003 /// hardware. Neither reaches the wire, so read the sink back over HTTP to
2004 /// learn a window landed.
2005 fn set_video_wall(
2006 &self,
2007 sink: DeviceUid,
2008 window: VideoWallWindow,
2009 op: VideoWallOp,
2010 ) -> Result<(), ControlError> {
2011 if op != VideoWallOp::REVERT {
2012 window.validate().map_err(ControlError::InvalidRequest)?;
2013 }
2014 self.shared.command(move |state| {
2015 let device = device_of(state, sink)?;
2016 // A unit without the module drops the frame unanswered.
2017 if !device.hello.features.has(DeviceFeature::VIDEO_WALL) {
2018 return Err(ControlError::Unsupported("the unit has no video wall"));
2019 }
2020 Ok(Command::new(
2021 Addressee::device(device),
2022 op::V2IP_VIDEO_WALL,
2023 build_video_wall(device.uid, window, op),
2024 ))
2025 })
2026 }
2027
2028 // ---- multiviewer ----
2029
2030 /// Sets the window layout.
2031 pub fn set_multiviewer_view_mode(
2032 &self,
2033 device: DeviceUid,
2034 mode: MultiviewerViewMode,
2035 ) -> Result<(), ControlError> {
2036 let mode = mv_setting(mode.to_wire(), 8, "the multiviewer has no such view mode")?;
2037 self.multiviewer(device, mv_sub::VIEW_MODE, &[mode])
2038 }
2039
2040 /// Assigns a source to one window, counting windows from zero.
2041 ///
2042 /// A window index the multiviewer is not currently showing is refused
2043 /// rather than sent: firmware accepts an index one past the last window
2044 /// and writes through the end of the array it indexes, so the frame that
2045 /// would carry it is the one frame this library must never put on the
2046 /// wire. The bound comes from the layout in the multiviewer's last status
2047 /// report, so a multiviewer that has reported none can only be given
2048 /// window zero, which every layout has.
2049 pub fn set_multiviewer_video_source(
2050 &self,
2051 device: DeviceUid,
2052 screen: u8,
2053 source: MultiviewerSource,
2054 ) -> Result<(), ControlError> {
2055 let source = source_index(source, "the source names no multiviewer input")?;
2056 self.shared.command(|state| {
2057 let target = multiviewer_of(state, device)?;
2058 let windows = target
2059 .multiviewer
2060 .as_ref()
2061 .and_then(MultiviewerStatus::window_count)
2062 .unwrap_or(1);
2063 if screen >= windows {
2064 return Err(ControlError::InvalidRequest(
2065 "the window is not one the multiviewer is showing",
2066 ));
2067 }
2068 Ok(mv_command(target, mv_sub::VIDEO_SOURCE, &[screen, source]))
2069 })
2070 }
2071
2072 /// Selects which window's audio is output.
2073 pub fn set_multiviewer_audio_source(
2074 &self,
2075 device: DeviceUid,
2076 source: MultiviewerSource,
2077 ) -> Result<(), ControlError> {
2078 let source = source_index(source, "the audio source names no multiviewer input")?;
2079 self.multiviewer(device, mv_sub::AUDIO_SOURCE, &[source])
2080 }
2081
2082 /// Sets the output volume, as a percentage, and the mute state.
2083 ///
2084 /// A volume above 100 is refused rather than sent. What a multiviewer does
2085 /// with one depends on its module version: from 2026083100 it drops the
2086 /// whole frame, and before that it dropped the volume alone and still
2087 /// acted on the mute beside it. Neither is what the caller asked for, and
2088 /// neither is reported back.
2089 pub fn set_multiviewer_audio_volume(
2090 &self,
2091 device: DeviceUid,
2092 volume: u8,
2093 muted: bool,
2094 ) -> Result<(), ControlError> {
2095 if volume > 100 {
2096 return Err(ControlError::InvalidRequest(
2097 "a multiviewer volume is a percentage",
2098 ));
2099 }
2100 self.multiviewer(device, mv_sub::AUDIO_VOLUME, &[volume, u8::from(muted)])
2101 }
2102
2103 /// Sets the EDID template presented to the sources.
2104 pub fn set_multiviewer_edid_template(
2105 &self,
2106 device: DeviceUid,
2107 template: MultiviewerEdidTemplate,
2108 ) -> Result<(), ControlError> {
2109 let template = mv_setting(
2110 template.to_wire(),
2111 19,
2112 "the multiviewer has no such EDID template",
2113 )?;
2114 self.multiviewer(device, mv_sub::EDID_TEMPLATE, &[template])
2115 }
2116
2117 /// Selects which window receives remote-control passthrough.
2118 pub fn set_multiviewer_remote_control(
2119 &self,
2120 device: DeviceUid,
2121 source: MultiviewerSource,
2122 ) -> Result<(), ControlError> {
2123 let source = source_index(
2124 source,
2125 "the remote-control source names no multiviewer input",
2126 )?;
2127 self.multiviewer(device, mv_sub::ROUTE_RC, &[source])
2128 }
2129
2130 /// Sets how large the picture-in-picture window is.
2131 pub fn set_multiviewer_pip_size(
2132 &self,
2133 device: DeviceUid,
2134 size: MultiviewerPipSize,
2135 ) -> Result<(), ControlError> {
2136 let size = mv_setting(
2137 size.to_wire(),
2138 3,
2139 "the multiviewer has no such picture-in-picture size",
2140 )?;
2141 self.multiviewer(device, mv_sub::PIP_SIZE, &[size])
2142 }
2143
2144 /// Sets which corner the picture-in-picture window sits in.
2145 pub fn set_multiviewer_pip_position(
2146 &self,
2147 device: DeviceUid,
2148 position: MultiviewerPipPosition,
2149 ) -> Result<(), ControlError> {
2150 let position = mv_setting(
2151 position.to_wire(),
2152 4,
2153 "the multiviewer has no such picture-in-picture position",
2154 )?;
2155 self.multiviewer(device, mv_sub::PIP_POSITION, &[position])
2156 }
2157
2158 /// Sets the aspect ratio the windows are scaled to.
2159 pub fn set_multiviewer_aspect_ratio(
2160 &self,
2161 device: DeviceUid,
2162 aspect: MultiviewerAspectRatio,
2163 ) -> Result<(), ControlError> {
2164 let aspect = mv_setting(
2165 aspect.to_wire(),
2166 2,
2167 "the multiviewer has no such aspect ratio",
2168 )?;
2169 self.multiviewer(device, mv_sub::ASPECT, &[aspect])
2170 }
2171
2172 /// Enables or disables switching windows on its own.
2173 pub fn set_multiviewer_auto_switch(
2174 &self,
2175 device: DeviceUid,
2176 enable: bool,
2177 ) -> Result<(), ControlError> {
2178 self.multiviewer(device, mv_sub::AUTO_SWITCH, &[u8::from(enable)])
2179 }
2180
2181 /// Sets the output resolution and refresh rate.
2182 pub fn set_multiviewer_output_mode(
2183 &self,
2184 device: DeviceUid,
2185 mode: MultiviewerOutputMode,
2186 ) -> Result<(), ControlError> {
2187 let mode = mv_setting(
2188 mode.to_wire(),
2189 14,
2190 "the multiviewer has no such output mode",
2191 )?;
2192 self.multiviewer(device, mv_sub::OUTPUT_MODE, &[mode])
2193 }
2194
2195 /// Sets the IT-content flag on the output.
2196 pub fn set_multiviewer_output_itc(
2197 &self,
2198 device: DeviceUid,
2199 mode: MultiviewerItcMode,
2200 ) -> Result<(), ControlError> {
2201 let mode = mv_setting(
2202 mode.to_wire(),
2203 2,
2204 "the multiviewer has no such IT-content mode",
2205 )?;
2206 self.multiviewer(device, mv_sub::OUTPUT_ITC_MODE, &[mode])
2207 }
2208
2209 /// Sets the HDCP version negotiated on the output.
2210 pub fn set_multiviewer_hdcp_mode(
2211 &self,
2212 device: DeviceUid,
2213 mode: MultiviewerHdcpMode,
2214 ) -> Result<(), ControlError> {
2215 let mode = mv_setting(mode.to_wire(), 3, "the multiviewer has no such HDCP mode")?;
2216 self.multiviewer(device, mv_sub::HDCP_MODE, &[mode])
2217 }
2218
2219 /// Maps a source device onto one of the multiviewer's inputs, counting
2220 /// inputs from zero.
2221 ///
2222 /// [`DeviceUid::ZERO`] clears the mapping on a multiviewer running module
2223 /// version 2026083100 or newer, and is stored as a mapping like any other
2224 /// on anything older. No version checks that a mapping names a device on
2225 /// the mesh.
2226 ///
2227 /// Which of the two happened shows in `mappings` on a later status report,
2228 /// where a cleared input reads as [`DeviceUid::ZERO`] only from that same
2229 /// version. It will not be the next frame this multiviewer sends: this is
2230 /// one of the two settings that schedule no status broadcast of their own,
2231 /// so the answer arrives whenever something else prompts one.
2232 pub fn set_multiviewer_input_source(
2233 &self,
2234 device: DeviceUid,
2235 input: u8,
2236 source: DeviceUid,
2237 ) -> Result<(), ControlError> {
2238 if usize::from(input) >= MULTIVIEWER_INPUTS {
2239 return Err(ControlError::InvalidRequest(
2240 "the multiviewer has no such input",
2241 ));
2242 }
2243 let mut args = Vec::with_capacity(24);
2244 args.extend_from_slice(source.as_bytes());
2245 args.push(input);
2246 // The body is 8-aligned, so seven bytes of padding round the uid and
2247 // the input index up to 24.
2248 args.extend_from_slice(&[0; 7]);
2249 self.multiviewer(device, mv_sub::CONFIG_SOURCE, &args)
2250 }
2251
2252 /// Asks the multiviewer to route its sources itself.
2253 pub fn multiviewer_auto_route(&self, device: DeviceUid) -> Result<(), ControlError> {
2254 self.multiviewer(device, mv_sub::AUTO_ROUTE, &[])
2255 }
2256
2257 fn multiviewer(&self, device: DeviceUid, sub: u8, args: &[u8]) -> Result<(), ControlError> {
2258 self.shared
2259 .command(|state| Ok(mv_command(multiviewer_of(state, device)?, sub, args)))
2260 }
2261}