Skip to main content

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