Skip to main content

mx_remote/runtime/
control.rs

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