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