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;
29
30use crate::event::Event;
31use crate::state::{Bay, Device, State};
32use crate::types::{
33    AmpZoneSettings, HiddenStatus, MultiviewerStatus, PowerStatus, V2ipAudioFormat,
34    V2ipDeviceSettings, V2ipOutputMode, V2ipRoute, V2ipRouteTarget, V2ipScalingSettings,
35    V2ipStreamSources, VideoWallOp, VideoWallWindow, VolumeMuteStatus, MULTIVIEWER_INPUTS,
36    SCALING_FLAG_AUTO_SCALING, SCALING_FLAG_MODE_VALID, SCALING_FLAG_OPTIONS_VALID,
37    VIDEO_WALL_CLEARED,
38};
39use crate::wire::{
40    audio_cmd_header, audio_param, audio_sub, build_amp_zone_settings, build_audio_select_input,
41    build_bay_hide, build_edid_profile, build_edid_request, build_rc_action, build_rc_key,
42    build_set_bay_name, build_set_volume, build_stats_request, build_target_only,
43    build_v2ip_device_settings, build_v2ip_manual_source_switch, build_v2ip_scaling,
44    build_v2ip_source_switch, build_video_wall, mv_cmd_payload, mv_sub, op, Addressee, BayUid,
45    DeviceUid, EdidProfile, MultiviewerAspectRatio, MultiviewerEdidTemplate, MultiviewerHdcpMode,
46    MultiviewerItcMode, MultiviewerOutputMode, MultiviewerPipPosition, MultiviewerPipSize,
47    MultiviewerSource, MultiviewerViewMode, MxrSignalType, Opcode, RcAction, RcKey, SendError,
48    StreamAddr, V2ipDeviceSetting, V2ipStreams, DEVICE_NAME_LEN, V2IP_IR_PROFILE_MAX,
49    V2IP_IR_PROFILE_NOT_SET, V2IP_PORT_ANC, V2IP_PORT_AUDIO, V2IP_PORT_VIDEO,
50};
51
52use super::{Remote, Shared};
53
54/// Why a control method did nothing.
55#[derive(Debug)]
56#[non_exhaustive]
57pub enum ControlError {
58    /// No device with this identifier has been heard from.
59    UnknownDevice(DeviceUid),
60    /// The device has reported no bay on this port.
61    UnknownBay(BayUid),
62    /// No input bay on the device carries this user-assigned name.
63    UnknownSource(String),
64    /// The addressee does not do what was asked of it.
65    Unsupported(&'static str),
66    /// The request breaks a rule the device is not guaranteed to check.
67    ///
68    /// Nothing was sent. This is the caller's to fix, and it is separate from
69    /// [`ControlError::Unsupported`] because the device would have taken the
70    /// frame: refusing here is this library declining to let a bad value
71    /// reach hardware that may store it rather than reject it.
72    InvalidRequest(&'static str),
73    /// The device has not reported something the request is assembled from.
74    ///
75    /// Unlike [`ControlError::Unsupported`], the same call may succeed once it
76    /// has: this says the value is missing, not that it cannot exist.
77    NotReported(&'static str),
78    /// The frame could not be sent.
79    Send(SendError),
80}
81
82impl fmt::Display for ControlError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::UnknownDevice(uid) => write!(f, "no device {uid}"),
86            Self::UnknownBay(uid) => write!(f, "no bay {uid}"),
87            Self::UnknownSource(name) => write!(f, "no source named {name:?}"),
88            Self::Unsupported(what) => f.write_str(what),
89            Self::InvalidRequest(what) => f.write_str(what),
90            Self::NotReported(what) => write!(f, "{what} has not been reported"),
91            Self::Send(e) => write!(f, "{e}"),
92        }
93    }
94}
95
96impl std::error::Error for ControlError {
97    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
98        match self {
99            Self::Send(e) => Some(e),
100            _ => None,
101        }
102    }
103}
104
105impl From<SendError> for ControlError {
106    fn from(e: SendError) -> Self {
107        Self::Send(e)
108    }
109}
110
111/// What a command does to this client's copy of the registry once its frame is
112/// away.
113///
114/// A device does not acknowledge a command, so without this a caller that read
115/// back what it just wrote would see the old value until some unrelated report
116/// happened to carry the new one.
117type WriteBack = Box<dyn FnOnce(&mut State, &mut Vec<Event>) + Send>;
118
119/// One command: the frame to send, and what the addressee will do with it.
120struct Command {
121    to: Addressee,
122    opcode: Opcode,
123    payload: Vec<u8>,
124    write_back: Option<WriteBack>,
125}
126
127impl Command {
128    fn new(to: Addressee, opcode: Opcode, payload: Vec<u8>) -> Self {
129        Self {
130            to,
131            opcode,
132            payload,
133            write_back: None,
134        }
135    }
136
137    /// Records what to apply locally once the frame is away.
138    fn then(mut self, f: impl FnOnce(&mut State, &mut Vec<Event>) + Send + 'static) -> Self {
139        self.write_back = Some(Box::new(f));
140        self
141    }
142}
143
144impl Shared {
145    /// Runs one command: prepare under the registry lock, send without it,
146    /// then write back.
147    fn command(
148        &self,
149        prepare: impl FnOnce(&State) -> Result<Command, ControlError>,
150    ) -> Result<(), ControlError> {
151        let command = self.read(prepare)?;
152        self.send(&command.to, command.opcode, &command.payload)?;
153        if let Some(write_back) = command.write_back {
154            self.mutate(|state, ev| write_back(state, ev));
155        }
156        Ok(())
157    }
158}
159
160fn device_of(state: &State, uid: DeviceUid) -> Result<&Device, ControlError> {
161    state.device(uid).ok_or(ControlError::UnknownDevice(uid))
162}
163
164/// The device behind `uid`, once it is known to be a multiviewer.
165fn multiviewer_of(state: &State, uid: DeviceUid) -> Result<&Device, ControlError> {
166    let device = device_of(state, uid)?;
167    if !device.is_multiviewer() {
168        return Err(ControlError::Unsupported("the device is not a multiviewer"));
169    }
170    Ok(device)
171}
172
173/// Wraps one multiviewer sub-command in the envelope every one of them shares.
174fn mv_command(device: &Device, sub: u8, args: &[u8]) -> Command {
175    Command::new(
176        Addressee::device(device),
177        op::V2IP_MULTIVIEWER,
178        mv_cmd_payload(device.uid, sub, args),
179    )
180}
181
182/// The zero-based input a source names, refused when it names none.
183///
184/// A multiviewer reads zero as its first input, so there is no value that says
185/// "no input": a source that names none would arrive as a request to switch to
186/// input 1.
187fn source_index(source: MultiviewerSource, what: &'static str) -> Result<u8, ControlError> {
188    source
189        .to_zero_based()
190        .ok_or(ControlError::InvalidRequest(what))
191}
192
193/// A multiviewer setting within the range its firmware accepts.
194///
195/// Every one of these settings is numbered from one, with zero reserved for
196/// "the device has reported nothing". The device drops a value it does not
197/// know without answering, so a caller sending one would see a send succeed
198/// and the setting stay as it was; this is what turns that into an error.
199fn mv_setting(value: u8, highest: u8, what: &'static str) -> Result<u8, ControlError> {
200    if (1..=highest).contains(&value) {
201        Ok(value)
202    } else {
203        Err(ControlError::InvalidRequest(what))
204    }
205}
206
207fn bay_of(state: &State, uid: BayUid) -> Result<(&Device, &Bay), ControlError> {
208    let device = device_of(state, uid.device)?;
209    let bay = device.bay(uid.port).ok_or(ControlError::UnknownBay(uid))?;
210    Ok((device, bay))
211}
212
213/// The streams the source bay on `port` advertises.
214fn source_streams(device: &Device, port: u16) -> Result<&V2ipStreamSources, ControlError> {
215    let source = device
216        .bay(port)
217        .ok_or(ControlError::UnknownBay(BayUid::new(device.uid, port)))?;
218    device
219        .v2ip_source_for(source)
220        .ok_or(ControlError::NotReported("the source's stream addresses"))
221}
222
223/// A sink bay, or the reason it cannot be routed.
224fn v2ip_sink(state: &State, uid: BayUid) -> Result<(&Device, &Bay), ControlError> {
225    let (device, bay) = bay_of(state, uid)?;
226    if !bay.is_v2ip_sink() {
227        return Err(ControlError::Unsupported("routing needs a V2IP sink"));
228    }
229    Ok((device, bay))
230}
231
232/// One route slot as the wire carries it, substituting the stream's standard
233/// port for an unset one.
234///
235/// An unset address sends the slot zeroed, port included: the firmware reads
236/// the pair together, and a port beside 0.0.0.0 describes nothing.
237fn stream_addr(target: V2ipRouteTarget, standard_port: u16) -> StreamAddr {
238    if target.ip.is_unspecified() {
239        return StreamAddr::default();
240    }
241    StreamAddr {
242        ip: target.ip,
243        port: target.port_or(standard_port),
244    }
245}
246
247/// The name as the device will store it: the field is
248/// [`DEVICE_NAME_LEN`] bytes wide, so a longer one is cut there.
249fn stored_name(name: &str) -> String {
250    let bytes = name.as_bytes();
251    String::from_utf8_lossy(bytes.get(..DEVICE_NAME_LEN).unwrap_or(bytes)).into_owned()
252}
253
254impl Remote {
255    // ---- routing ----
256
257    /// Routes this V2IP sink's video to the stream a source port advertises.
258    pub fn select_video_source(&self, sink: BayUid, source_port: u16) -> Result<(), ControlError> {
259        self.shared.command(|state| {
260            let (device, bay) = v2ip_sink(state, sink)?;
261            if !bay.is_output() {
262                return Err(ControlError::Unsupported("not an output bay"));
263            }
264            let streams = source_streams(device, source_port)?;
265            Ok(Command::new(
266                Addressee::device(device),
267                op::V2IP_SOURCE_SWITCH,
268                build_v2ip_source_switch(device.uid, streams.video.ip, Ipv4Addr::UNSPECIFIED),
269            ))
270        })
271    }
272
273    /// Routes this V2IP sink's audio to the stream a source port advertises.
274    pub fn select_audio_source(&self, sink: BayUid, source_port: u16) -> Result<(), ControlError> {
275        self.shared.command(|state| {
276            let (device, _) = v2ip_sink(state, sink)?;
277            let streams = source_streams(device, source_port)?;
278            Ok(Command::new(
279                Addressee::device(device),
280                op::V2IP_SOURCE_SWITCH,
281                build_v2ip_source_switch(device.uid, Ipv4Addr::UNSPECIFIED, streams.audio.ip),
282            ))
283        })
284    }
285
286    /// Routes this V2IP sink's video to the input bay with the given
287    /// user-assigned name.
288    pub fn select_video_source_by_name(
289        &self,
290        sink: BayUid,
291        name: &str,
292    ) -> Result<(), ControlError> {
293        self.select_video_source(sink, self.source_port(sink, name)?)
294    }
295
296    /// Routes this V2IP sink's audio to a multicast address directly, leaving
297    /// its video and ancillary streams alone.
298    ///
299    /// An unset port is the standard V2IP audio port. A format overrides the
300    /// sample rate and channel count the receiver would otherwise assume.
301    pub fn select_audio_source_addr(
302        &self,
303        sink: BayUid,
304        audio_ip: Ipv4Addr,
305        audio_port: Option<u16>,
306        format: Option<V2ipAudioFormat>,
307    ) -> Result<(), ControlError> {
308        self.shared.command(move |state| {
309            let (device, _) = v2ip_sink(state, sink)?;
310            let streams = V2ipStreams {
311                audio: StreamAddr {
312                    ip: audio_ip,
313                    port: audio_port.unwrap_or(V2IP_PORT_AUDIO),
314                },
315                ..V2ipStreams::default()
316            };
317            Ok(Command::new(
318                Addressee::device(device),
319                op::V2IP_MANUAL_SRC_SWITCH,
320                build_v2ip_manual_source_switch(device.uid, streams, format),
321            ))
322        })
323    }
324
325    /// Routes this V2IP sink's video, audio and ancillary streams to
326    /// multicast groups the caller names.
327    ///
328    /// This is the only way to reach a stream no device on the mesh
329    /// advertises, such as one the host is transmitting itself; a route by
330    /// source port can only name a stream some bay has announced.
331    ///
332    /// Set all three groups. The firmware decides whether a sink has a manual
333    /// route by looking at the video and ancillary groups, so a route that
334    /// leaves either unset does not register as one and the sink falls back to
335    /// the audio source its mesh picks.
336    ///
337    /// An unset `format` sends [`V2ipAudioFormat::STANDARD`] rather than
338    /// omitting the trailer. The firmware stores whatever this frame carries
339    /// and hands it to the FPGA unexamined, so a frame without one leaves a
340    /// zero rate and zero channel count there, which the FPGA rejects and
341    /// which takes the switch down with it.
342    pub fn select_source_addr(
343        &self,
344        sink: BayUid,
345        route: V2ipRoute,
346        format: Option<V2ipAudioFormat>,
347    ) -> Result<(), ControlError> {
348        let streams = V2ipStreams {
349            video: stream_addr(route.video, V2IP_PORT_VIDEO),
350            audio: stream_addr(route.audio, V2IP_PORT_AUDIO),
351            anc: stream_addr(route.anc, V2IP_PORT_ANC),
352        };
353        let format = format.unwrap_or(V2ipAudioFormat::STANDARD);
354        self.shared.command(move |state| {
355            let (device, _) = v2ip_sink(state, sink)?;
356            Ok(Command::new(
357                Addressee::device(device),
358                op::V2IP_MANUAL_SRC_SWITCH,
359                build_v2ip_manual_source_switch(device.uid, streams, Some(format)),
360            ))
361        })
362    }
363
364    /// Routes this V2IP sink's audio from the input bay with the given
365    /// user-assigned name.
366    ///
367    /// A format is carried on the manual switch frame, which is the only form
368    /// that can override the receiver's sample rate and channel count.
369    pub fn select_audio_source_by_name(
370        &self,
371        sink: BayUid,
372        name: &str,
373        format: Option<V2ipAudioFormat>,
374    ) -> Result<(), ControlError> {
375        let port = self.source_port(sink, name)?;
376        let Some(format) = format else {
377            return self.select_audio_source(sink, port);
378        };
379        self.shared.command(move |state| {
380            let (device, _) = v2ip_sink(state, sink)?;
381            let audio = source_streams(device, port)?.audio;
382            let streams = V2ipStreams {
383                audio: StreamAddr {
384                    ip: audio.ip,
385                    port: audio.port,
386                },
387                ..V2ipStreams::default()
388            };
389            Ok(Command::new(
390                Addressee::device(device),
391                op::V2IP_MANUAL_SRC_SWITCH,
392                build_v2ip_manual_source_switch(device.uid, streams, Some(format)),
393            ))
394        })
395    }
396
397    /// The port of the input bay on `sink`'s device carrying `name`.
398    fn source_port(&self, sink: BayUid, name: &str) -> Result<u16, ControlError> {
399        self.shared.read(|state| {
400            let (device, _) = bay_of(state, sink)?;
401            device
402                .bay_by_user_name(name)
403                .map(|b| b.port)
404                .ok_or_else(|| ControlError::UnknownSource(name.to_owned()))
405        })
406    }
407
408    // ---- bay settings ----
409
410    /// Renames a bay.
411    pub fn set_bay_name(&self, bay: BayUid, name: &str) -> Result<(), ControlError> {
412        let name = stored_name(name);
413        self.shared.command(move |state| {
414            let (device, _) = bay_of(state, bay)?;
415            let payload = build_set_bay_name(device.uid, bay.port, &name);
416            Ok(
417                Command::new(Addressee::device(device), op::CHANGE_BAY_NAME, payload).then(
418                    move |state, ev| {
419                        if let Some(b) = state.bay_mut(bay) {
420                            b.set_user_name(name, ev);
421                        }
422                    },
423                ),
424            )
425        })
426    }
427
428    /// Hides a bay from the pickers that list it, or shows it again.
429    pub fn set_bay_hidden(&self, bay: BayUid, hidden: bool) -> Result<(), ControlError> {
430        self.shared.command(move |state| {
431            let (device, _) = bay_of(state, bay)?;
432            Ok(Command::new(
433                Addressee::device(device),
434                op::BAY_HIDE,
435                build_bay_hide(device.uid, bay.port, hidden),
436            )
437            .then(move |state, ev| {
438                if let Some(b) = state.bay_mut(bay) {
439                    let status = if hidden {
440                        HiddenStatus::Hidden
441                    } else {
442                        HiddenStatus::Visible
443                    };
444                    b.apply_hidden(status, ev);
445                }
446            }))
447        })
448    }
449
450    /// Sets the EDID profile an input presents to the source attached to it.
451    pub fn select_edid_profile(
452        &self,
453        bay: BayUid,
454        profile: EdidProfile,
455    ) -> Result<(), ControlError> {
456        self.shared.command(move |state| {
457            let (device, _) = bay_of(state, bay)?;
458            Ok(Command::new(
459                Addressee::device(device),
460                op::BAY_EDID_PROFILE,
461                build_edid_profile(device.uid, profile),
462            )
463            .then(move |state, ev| {
464                if let Some(b) = state.bay_mut(bay) {
465                    b.set_edid_profile(profile, ev);
466                }
467            }))
468        })
469    }
470
471    /// Sends a remote-control action to whatever is attached to a bay.
472    pub fn send_action(&self, bay: BayUid, action: RcAction) -> Result<(), ControlError> {
473        self.shared.command(move |state| {
474            let (device, _) = bay_of(state, bay)?;
475            Ok(Command::new(
476                Addressee::device(device),
477                op::RC_TX_ACTION,
478                build_rc_action(device.uid, bay.port, action),
479            ))
480        })
481    }
482
483    /// Sends a remote-control key press to whatever is attached to a bay.
484    ///
485    /// The device forwards it over CEC, infrared or IP, whichever that bay is
486    /// configured for; the caller does not choose. An action from
487    /// [`Remote::send_action`] names an outcome instead, and the device
488    /// decides which keys reach it.
489    pub fn send_key(&self, bay: BayUid, key: RcKey) -> Result<(), ControlError> {
490        self.shared.command(move |state| {
491            let (device, _) = bay_of(state, bay)?;
492            Ok(Command::new(
493                Addressee::device(device),
494                op::RC_TX_KEY,
495                build_rc_key(device.uid, bay.port, key),
496            ))
497        })
498    }
499
500    /// Powers on the device attached to a bay.
501    pub fn power_on(&self, bay: BayUid) -> Result<(), ControlError> {
502        self.set_power(bay, RcAction::POWER_ON, PowerStatus::On)
503    }
504
505    /// Powers off the device attached to a bay.
506    pub fn power_off(&self, bay: BayUid) -> Result<(), ControlError> {
507        self.set_power(bay, RcAction::POWER_OFF, PowerStatus::Off)
508    }
509
510    fn set_power(
511        &self,
512        bay: BayUid,
513        action: RcAction,
514        power: PowerStatus,
515    ) -> Result<(), ControlError> {
516        self.shared.command(move |state| {
517            let (device, _) = bay_of(state, bay)?;
518            Ok(Command::new(
519                Addressee::device(device),
520                op::RC_TX_ACTION,
521                build_rc_action(device.uid, bay.port, action),
522            )
523            .then(move |state, ev| {
524                if let Some(b) = state.bay_mut(bay) {
525                    b.set_power_status(power, ev);
526                }
527            }))
528        })
529    }
530
531    /// Sets a bay's volume, as a percentage, and optionally its mute state.
532    ///
533    /// Both channels are set together: the wire carries them separately, but
534    /// nothing on this surface splits them.
535    ///
536    /// A bay with no volume control of its own is set through its
537    /// [`linked_bay`](crate::BayInfo::linked_bay), so an output wired to an
538    /// amplifier zone reaches that zone. [`volume_up`](Self::volume_up),
539    /// [`volume_down`](Self::volume_down) and [`set_muted`](Self::set_muted)
540    /// follow the same link, and read the volume they step from through it.
541    pub fn set_volume(
542        &self,
543        bay: BayUid,
544        volume: u8,
545        muted: Option<bool>,
546    ) -> Result<(), ControlError> {
547        let volume = volume.min(100);
548        let wanted = VolumeMuteStatus {
549            volume_left: Some(volume),
550            volume_right: Some(volume),
551            muted_left: muted,
552            muted_right: muted,
553        };
554        self.shared.command(move |state| {
555            // The mesh may put this bay's volume control on another device, and
556            // the command belongs where the volume lives, not where it was
557            // addressed.
558            let target = state.volume_bay(bay);
559            let (device, b) = bay_of(state, target)?;
560            if !b.has_volume_control() {
561                return Err(ControlError::Unsupported("the bay has no volume control"));
562            }
563            Ok(Command::new(
564                Addressee::device(device),
565                op::AUDIO_SET_VOLUME,
566                build_set_volume(device.uid, target.port, wanted),
567            )
568            .then(move |state, ev| {
569                if let Some(device) = state.device_mut(target.device) {
570                    device.apply_bay_volume(target.port, wanted, ev);
571                }
572            }))
573        })
574    }
575
576    /// Raises a bay's volume by one percent.
577    pub fn volume_up(&self, bay: BayUid) -> Result<(), ControlError> {
578        self.set_volume(bay, self.current_volume(bay)?.saturating_add(1), None)
579    }
580
581    /// Lowers a bay's volume by one percent.
582    pub fn volume_down(&self, bay: BayUid) -> Result<(), ControlError> {
583        self.set_volume(bay, self.current_volume(bay)?.saturating_sub(1), None)
584    }
585
586    /// Mutes or unmutes a bay, keeping the volume it is set to.
587    pub fn set_muted(&self, bay: BayUid, muted: bool) -> Result<(), ControlError> {
588        self.set_volume(bay, self.current_volume(bay)?, Some(muted))
589    }
590
591    /// The volume a step or a mute is relative to.
592    fn current_volume(&self, bay: BayUid) -> Result<u8, ControlError> {
593        self.shared.read(|state| {
594            let (_, b) = bay_of(state, state.volume_bay(bay))?;
595            b.audio_volume
596                .map(|v| v.volume())
597                .ok_or(ControlError::NotReported("the bay's volume"))
598        })
599    }
600
601    /// Applies amplifier settings to a zone.
602    pub fn set_amp_zone_settings(
603        &self,
604        bay: BayUid,
605        settings: AmpZoneSettings,
606    ) -> Result<(), ControlError> {
607        self.shared.command(move |state| {
608            let (device, _) = bay_of(state, bay)?;
609            Ok(Command::new(
610                Addressee::device(device),
611                op::AMP_ZONE_SETTINGS,
612                build_amp_zone_settings(device.uid, bay.port, &settings),
613            )
614            .then(move |state, ev| {
615                if let Some(b) = state.bay_mut(bay) {
616                    b.set_amp_settings(settings, ev);
617                }
618            }))
619        })
620    }
621
622    // ---- audio endpoints ----
623
624    /// Mutes or unmutes an audio endpoint.
625    pub fn set_audio_endpoint_muted(
626        &self,
627        device: DeviceUid,
628        endpoint: u16,
629        muted: bool,
630    ) -> Result<(), ControlError> {
631        self.audio_endpoint(device, audio_sub::MUTE, endpoint, u32::from(muted))
632    }
633
634    /// Sets an audio endpoint's trigger output.
635    pub fn set_audio_endpoint_trigger(
636        &self,
637        device: DeviceUid,
638        endpoint: u16,
639        active: bool,
640    ) -> Result<(), ControlError> {
641        self.audio_endpoint(device, audio_sub::TRIGGER, endpoint, u32::from(active))
642    }
643
644    /// Sets an audio endpoint's volume.
645    ///
646    /// **The audio module has no receiver for this command and ignores it.**
647    /// It builds and sends the same shape as
648    /// [`Self::set_audio_endpoint_muted`], and the send succeeds, because
649    /// nothing on these paths is acknowledged - so a caller sees success and no
650    /// change. The module dispatches this sub-command to the branch it uses for
651    /// one it does not recognise.
652    ///
653    /// It is kept because the command is defined and the module transmits it
654    /// itself, so a receiver may appear; read the endpoint back rather than
655    /// assuming either way.
656    pub fn set_audio_endpoint_volume(
657        &self,
658        device: DeviceUid,
659        endpoint: u16,
660        volume: u32,
661    ) -> Result<(), ControlError> {
662        self.audio_endpoint(device, audio_sub::VOLUME, endpoint, volume)
663    }
664
665    fn audio_endpoint(
666        &self,
667        device: DeviceUid,
668        sub: u16,
669        endpoint: u16,
670        value: u32,
671    ) -> Result<(), ControlError> {
672        self.shared.command(move |state| {
673            let device = device_of(state, device)?;
674            let mut payload = audio_cmd_header(sub, device.uid);
675            payload.extend_from_slice(&audio_param(endpoint, value));
676            Ok(Command::new(
677                Addressee::device(device),
678                op::V2IP_AUDIO,
679                payload,
680            ))
681        })
682    }
683
684    /// Routes a source endpoint on one device to a sink endpoint on another.
685    pub fn select_audio_endpoint_input(
686        &self,
687        sink: DeviceUid,
688        sink_endpoint: u16,
689        source: DeviceUid,
690        source_endpoint: u16,
691    ) -> Result<(), ControlError> {
692        self.shared.command(move |state| {
693            let device = device_of(state, sink)?;
694            Ok(Command::new(
695                Addressee::device(device),
696                op::V2IP_AUDIO,
697                build_audio_select_input(sink, sink_endpoint, source, source_endpoint),
698            ))
699        })
700    }
701
702    // ---- the whole device ----
703
704    /// Starts or stops a V2IP device reporting its transport statistics.
705    ///
706    /// There is no free-running mode: a device reports only while a
707    /// subscription is live, at 1Hz, and the subscription lapses after a
708    /// minute. A caller that wants a continuous feed re-sends inside the
709    /// minute; nothing here re-arms it.
710    ///
711    /// Reports reach [`crate::EventHandler::on_v2ip_stats_changed`] and read
712    /// back through [`Remote::v2ip_stats`]. A device new enough to send it also
713    /// carries what the sink's decoder recovered, in
714    /// [`crate::V2ipDeviceStats::decoder`].
715    pub fn subscribe_v2ip_stats(
716        &self,
717        device: DeviceUid,
718        subscribe: bool,
719    ) -> Result<(), ControlError> {
720        self.shared.command(move |state| {
721            let device = device_of(state, device)?;
722            Ok(Command::new(
723                Addressee::device(device),
724                op::V2IP_STATS,
725                build_stats_request(device.uid, subscribe),
726            ))
727        })
728    }
729
730    /// Asks a device for an EDID: the one the display on its output
731    /// publishes, or the one it presents to the source on its input.
732    ///
733    /// The device answers with a frame the receive path decodes, so the bytes
734    /// arrive at [`crate::EventHandler::on_edid_received`] and stay readable
735    /// through [`Remote::edid`].
736    ///
737    /// Only V2IP hardware handles this opcode. A matrix or an amplifier
738    /// accepts the frame and answers nothing, at any protocol version, so the
739    /// silence that follows is permanent rather than a reply still to come.
740    /// This call cannot tell the two apart and does not try: it reports what
741    /// was sent, and a caller polling for an EDID should ask a device that can
742    /// answer rather than wait on one that cannot.
743    pub fn request_edid(&self, device: DeviceUid, output: bool) -> Result<(), ControlError> {
744        self.shared.command(move |state| {
745            let device = device_of(state, device)?;
746            Ok(Command::new(
747                Addressee::device(device),
748                op::DEV_EDID,
749                build_edid_request(device.uid, output),
750            ))
751        })
752    }
753
754    /// Asks for a detailed signal report from every bay of one device, or -
755    /// with no device named - from every bay on the network.
756    ///
757    /// Devices report on their own when a signal changes, so this is what a
758    /// client that has just started needs: without it, a bay that has been
759    /// showing the same picture for an hour says nothing until it changes.
760    pub fn request_signal_status(&self, device: Option<DeviceUid>) -> Result<(), ControlError> {
761        let Some(device) = device else {
762            self.shared
763                .send(&Addressee::Broadcast, op::BAY_SIGNAL_STATUS, &[])?;
764            return Ok(());
765        };
766        self.shared.command(move |state| {
767            let device = device_of(state, device)?;
768            Ok(Command::new(
769                Addressee::device(device),
770                op::BAY_SIGNAL_STATUS,
771                build_target_only(device.uid),
772            ))
773        })
774    }
775
776    /// Reboots a device.
777    ///
778    /// The device is marked as rebooting once the frame is away, so the
779    /// silence that follows does not read as one that went offline.
780    pub fn reboot(&self, device: DeviceUid) -> Result<(), ControlError> {
781        self.shared.command(move |state| {
782            let d = device_of(state, device)?;
783            Ok(Command::new(
784                Addressee::device(d),
785                op::SYS_REBOOT,
786                build_target_only(d.uid),
787            )
788            .then(move |state, _| {
789                if let Some(d) = state.device_mut(device) {
790                    d.rebooting = true;
791                }
792            }))
793        })
794    }
795
796    /// Asks every peer to report its monitoring data now rather than on its own
797    /// schedule.
798    pub fn send_monitoring_pulse(&self) -> Result<(), ControlError> {
799        self.shared
800            .send(&Addressee::Broadcast, op::SYS_MONITORING_PULSE, &[])?;
801        Ok(())
802    }
803
804    // ---- V2IP scaling ----
805
806    /// Turns a V2IP sink's automatic scaling on or off.
807    ///
808    /// Automatic scaling and a configured output mode are separate reasons for
809    /// a sink to scale, and this moves only the first: a sink with a mode
810    /// configured goes on scaling to it with automatic scaling off. Turning
811    /// both off is this call plus [`Remote::clear_v2ip_output_mode`].
812    ///
813    /// Nothing acknowledges the frame. Read the sink back through
814    /// [`Remote::v2ip_details`] to learn what it did, and treat the block as
815    /// meaningful only where [`crate::DeviceInfo::config_initialised`] is set.
816    /// **Read any route you still need before writing.** The sink rebuilds
817    /// and rebroadcasts its subscription in response, and that report can
818    /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
819    /// and why.
820    pub fn set_v2ip_auto_scaling(
821        &self,
822        device: DeviceUid,
823        enabled: bool,
824    ) -> Result<(), ControlError> {
825        let written = if enabled {
826            SCALING_FLAG_OPTIONS_VALID | SCALING_FLAG_AUTO_SCALING
827        } else {
828            SCALING_FLAG_OPTIONS_VALID
829        };
830        self.set_v2ip_scaling(device, MxrSignalType::NONE, 0, written, move |cached| {
831            V2ipScalingSettings {
832                flags: (cached.flags & !SCALING_FLAG_AUTO_SCALING)
833                    | SCALING_FLAG_OPTIONS_VALID
834                    | (written & SCALING_FLAG_AUTO_SCALING),
835                ..cached
836            }
837        })
838    }
839
840    /// Sets the output format a V2IP sink scales to.
841    ///
842    /// The mode is checked here and nothing is sent if it fails, because every
843    /// value a sink refuses it refuses in silence. Passing that check is not a
844    /// guarantee: the sink also weighs the format against the display's EDID
845    /// and against what its own output stage can produce.
846    ///
847    /// **Turn automatic scaling off first if it is on.** A sink refuses a mode
848    /// whose format the attached display does not list while it is scaling
849    /// automatically, and refuses it silently. Setting a mode and then turning
850    /// automatic scaling back on is the order that survives, because the mode
851    /// is checked while automatic scaling is still off.
852    ///
853    /// Configuring a mode is itself a reason to scale, so a sink with one
854    /// scales whether or not automatic scaling is on.
855    ///
856    /// **Pass a descriptor and a refresh rate that agree.** A sink stores both
857    /// halves and, with its match-source setting on as it ships, reports back
858    /// the descriptor matching the refresh it holds: a 60Hz descriptor written
859    /// with a refresh of 50 reads back as that descriptor's 50Hz sibling, once,
860    /// and stays there. A sink with match-source off reports the descriptor it
861    /// was given. Either way a pair that agrees reads back unchanged, and the
862    /// format driven is the same - so this costs a caller nothing except a
863    /// descriptor it did not write. That substitution shows up on the sink's
864    /// next report rather than immediately, because
865    /// [`crate::V2ipScalingSettings`] holds what was written until then.
866    ///
867    /// A mode read from the sink's own web interface is not interchangeable
868    /// with this pair. That interface reports the descriptor's 60Hz sibling and
869    /// carries the refresh in a field of its own, so writing back what it shows
870    /// as the mode, on its own, changes the setting rather than restoring it.
871    /// **Read any route you still need before writing.** The sink rebuilds
872    /// and rebroadcasts its subscription in response, and that report can
873    /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
874    /// and why.
875    pub fn set_v2ip_output_mode(
876        &self,
877        device: DeviceUid,
878        mode: V2ipOutputMode,
879    ) -> Result<(), ControlError> {
880        mode.validate().map_err(ControlError::InvalidRequest)?;
881        let signal = mode.to_signal_type();
882        let refresh = mode.refresh;
883        self.set_v2ip_scaling(
884            device,
885            signal,
886            refresh,
887            SCALING_FLAG_MODE_VALID,
888            move |cached| V2ipScalingSettings {
889                mode: signal,
890                refresh,
891                flags: cached.flags | SCALING_FLAG_MODE_VALID,
892            },
893        )
894    }
895
896    /// Clears the output format a V2IP sink is configured to scale to.
897    ///
898    /// The sink stops scaling for that reason and keeps its automatic scaling
899    /// setting, so a sink scaling for both reasons goes on scaling until
900    /// [`Remote::set_v2ip_auto_scaling`] turns the other one off.
901    ///
902    /// This is the only way to express "no mode configured", and it is what a
903    /// caller restoring a sink that had none has to send: a sink reports no
904    /// mode by leaving the mode's valid bit clear, which is not something a
905    /// write can say.
906    /// **Read any route you still need before writing.** The sink rebuilds
907    /// and rebroadcasts its subscription in response, and that report can
908    /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
909    /// and why.
910    pub fn clear_v2ip_output_mode(&self, device: DeviceUid) -> Result<(), ControlError> {
911        // The valid bit with a zero descriptor is the clear. The receiver takes
912        // that branch ahead of validating anything, and ignores the depth,
913        // colour space and refresh rate beside it.
914        self.set_v2ip_scaling(
915            device,
916            MxrSignalType::NONE,
917            0,
918            SCALING_FLAG_MODE_VALID,
919            |cached| V2ipScalingSettings {
920                mode: MxrSignalType::NONE,
921                refresh: 0,
922                flags: cached.flags & !SCALING_FLAG_MODE_VALID,
923            },
924        )
925    }
926
927    /// The one send behind the scaling methods.
928    ///
929    /// `written` is the flag byte that goes out, and `applied` says what the
930    /// sink will report afterwards. The two differ where the wire spells a
931    /// write differently from the state it produces - clearing a mode is sent
932    /// as the valid bit over a zero mode and read back as the valid bit clear -
933    /// so predicting the cached value from the frame alone would leave a
934    /// caller reading a state no device ever broadcasts.
935    fn set_v2ip_scaling(
936        &self,
937        device: DeviceUid,
938        mode: MxrSignalType,
939        refresh: u16,
940        written: u8,
941        applied: impl FnOnce(V2ipScalingSettings) -> V2ipScalingSettings + Send + 'static,
942    ) -> Result<(), ControlError> {
943        self.shared.command(move |state| {
944            let d = device_of(state, device)?;
945            if !d.is_v2ip_sink() {
946                return Err(ControlError::Unsupported(
947                    "scaling settings need a V2IP sink",
948                ));
949            }
950            Ok(Command::new(
951                Addressee::device(d),
952                op::V2IP_DEVICE_CFG,
953                build_v2ip_scaling(d.uid, mode, refresh, written),
954            )
955            .then(move |state, ev| {
956                if let Some(d) = state.device_mut(device) {
957                    let cached = d.v2ip_scaling();
958                    d.set_v2ip_scaling(applied(cached), ev);
959                }
960            }))
961        })
962    }
963
964    // ---- V2IP device settings ----
965
966    /// Switches on/off settings of a V2IP device, all to the same value.
967    ///
968    /// `setting` names one or more of [`V2ipDeviceSetting::SWITCHES`], and
969    /// each must be one the device has reported: a device ignores a setting it
970    /// does not have, so a write for one would read back as applied here and
971    /// change nothing there.
972    ///
973    /// Nothing acknowledges the frame. The device answers by reporting its
974    /// settings, and until then [`Remote::v2ip_device_settings`] reads back
975    /// what was written.
976    pub fn set_v2ip_device_setting(
977        &self,
978        device: DeviceUid,
979        setting: V2ipDeviceSetting,
980        enabled: bool,
981    ) -> Result<(), ControlError> {
982        if setting.is_empty() || !setting.without(V2ipDeviceSetting::SWITCHES).is_empty() {
983            return Err(ControlError::InvalidRequest(
984                "only on/off device settings are switched",
985            ));
986        }
987        self.set_v2ip_device_settings(
988            device,
989            V2ipDeviceSettings {
990                valid: setting,
991                flags: if enabled {
992                    setting
993                } else {
994                    V2ipDeviceSetting::NONE
995                },
996                ..V2ipDeviceSettings::default()
997            },
998        )
999    }
1000
1001    /// Sets the infrared profile of a V2IP device's global infrared port.
1002    ///
1003    /// `profile` is below [`V2IP_IR_PROFILE_MAX`], and is checked here because
1004    /// a device ignores one out of range. The terms of
1005    /// [`Remote::set_v2ip_device_setting`] apply.
1006    pub fn set_v2ip_ir_profile(&self, device: DeviceUid, profile: i8) -> Result<(), ControlError> {
1007        if !(0..V2IP_IR_PROFILE_MAX).contains(&profile) {
1008            return Err(ControlError::InvalidRequest("no such infrared profile"));
1009        }
1010        self.set_v2ip_device_settings(
1011            device,
1012            V2ipDeviceSettings {
1013                valid: V2ipDeviceSetting::IR_PROFILE,
1014                ir_profile: profile,
1015                ..V2ipDeviceSettings::default()
1016            },
1017        )
1018    }
1019
1020    /// Sets the infrared profile of a V2IP device's output infrared port.
1021    ///
1022    /// [`V2IP_IR_PROFILE_NOT_SET`] makes the port follow the global one.
1023    /// Otherwise as [`Remote::set_v2ip_ir_profile`].
1024    pub fn set_v2ip_sink_ir_profile(
1025        &self,
1026        device: DeviceUid,
1027        profile: i8,
1028    ) -> Result<(), ControlError> {
1029        if !(V2IP_IR_PROFILE_NOT_SET..V2IP_IR_PROFILE_MAX).contains(&profile) {
1030            return Err(ControlError::InvalidRequest("no such infrared profile"));
1031        }
1032        self.set_v2ip_device_settings(
1033            device,
1034            V2ipDeviceSettings {
1035                valid: V2ipDeviceSetting::IR_PROFILE_SINK,
1036                ir_profile_sink: profile,
1037                ..V2ipDeviceSettings::default()
1038            },
1039        )
1040    }
1041
1042    /// The one send behind the device settings methods.
1043    fn set_v2ip_device_settings(
1044        &self,
1045        device: DeviceUid,
1046        settings: V2ipDeviceSettings,
1047    ) -> Result<(), ControlError> {
1048        self.shared.command(move |state| {
1049            let d = device_of(state, device)?;
1050            let Some(reported) = d.v2ip_settings else {
1051                return Err(ControlError::NotReported("the device's settings"));
1052            };
1053            if !reported.valid.has(settings.valid) {
1054                return Err(ControlError::Unsupported(
1055                    "the device does not have this setting",
1056                ));
1057            }
1058            Ok(Command::new(
1059                Addressee::device(d),
1060                op::V2IP_DEVICE_CFG,
1061                build_v2ip_device_settings(d.uid, &settings),
1062            )
1063            .then(move |state, ev| {
1064                if let Some(d) = state.device_mut(device) {
1065                    d.merge_v2ip_settings(settings, ev);
1066                }
1067            }))
1068        })
1069    }
1070
1071    // ---- video wall ----
1072
1073    /// Shows a window on a sink's video wall without persisting it.
1074    ///
1075    /// The window survives until the sink is told otherwise or restarts.
1076    /// [`Remote::revert_video_wall`] puts back whatever was stored.
1077    ///
1078    /// Pass [`crate::VIDEO_WALL_CLEARED`] to show the whole frame again.
1079    pub fn preview_video_wall(
1080        &self,
1081        sink: DeviceUid,
1082        window: VideoWallWindow,
1083    ) -> Result<(), ControlError> {
1084        self.set_video_wall(sink, window, VideoWallOp::PREVIEW)
1085    }
1086
1087    /// Persists a window as a sink's video wall.
1088    ///
1089    /// The geometry is checked here, before anything is sent, because the sink
1090    /// is not guaranteed to check it. A sink running a video-wall module older
1091    /// than 2026083100 writes the window to its configuration *before* asking
1092    /// its video processor to apply it, and the processor's refusal does not
1093    /// undo that write - so an out-of-spec window survives a reboot and is
1094    /// re-offered on every stream restart until something else replaces it. A
1095    /// power cycle does not clear it.
1096    ///
1097    /// Nothing acknowledges this frame either way, so an `Ok` says only that
1098    /// it was sent. Read the sink's state back to learn what it did.
1099    ///
1100    /// Pass [`crate::VIDEO_WALL_CLEARED`] to store "show the whole frame".
1101    pub fn store_video_wall(
1102        &self,
1103        sink: DeviceUid,
1104        window: VideoWallWindow,
1105    ) -> Result<(), ControlError> {
1106        self.set_video_wall(sink, window, VideoWallOp::STORE)
1107    }
1108
1109    /// Restores the window a sink has stored, discarding a preview.
1110    ///
1111    /// Carries no window of its own: the sink already holds the one this puts
1112    /// back.
1113    pub fn revert_video_wall(&self, sink: DeviceUid) -> Result<(), ControlError> {
1114        self.set_video_wall(sink, VIDEO_WALL_CLEARED, VideoWallOp::REVERT)
1115    }
1116
1117    /// The one send behind the three video-wall methods.
1118    ///
1119    /// Validation sits here rather than in each of them, so an operation added
1120    /// later cannot reach the wire without it, and is skipped for a revert
1121    /// because the sink ignores the window on that operation rather than
1122    /// checking it.
1123    ///
1124    /// Passing it is not proof a wall appeared. Two things the sink refuses
1125    /// afterwards are equally silent: a window it will not draw, which it logs
1126    /// and drops, and a sink whose image has no tiling support at all, which
1127    /// takes the window into its own state and then fails to push it to the
1128    /// hardware. Neither reaches the wire, so read the sink back over HTTP to
1129    /// learn a window landed.
1130    fn set_video_wall(
1131        &self,
1132        sink: DeviceUid,
1133        window: VideoWallWindow,
1134        op: VideoWallOp,
1135    ) -> Result<(), ControlError> {
1136        if op != VideoWallOp::REVERT {
1137            window.validate().map_err(ControlError::InvalidRequest)?;
1138        }
1139        self.shared.command(move |state| {
1140            let device = device_of(state, sink)?;
1141            Ok(Command::new(
1142                Addressee::device(device),
1143                op::V2IP_VIDEO_WALL,
1144                build_video_wall(device.uid, window, op),
1145            ))
1146        })
1147    }
1148
1149    // ---- multiviewer ----
1150
1151    /// Sets the window layout.
1152    pub fn set_multiviewer_view_mode(
1153        &self,
1154        device: DeviceUid,
1155        mode: MultiviewerViewMode,
1156    ) -> Result<(), ControlError> {
1157        let mode = mv_setting(mode.to_wire(), 8, "the multiviewer has no such view mode")?;
1158        self.multiviewer(device, mv_sub::VIEW_MODE, &[mode])
1159    }
1160
1161    /// Assigns a source to one window, counting windows from zero.
1162    ///
1163    /// A window index the multiviewer is not currently showing is refused
1164    /// rather than sent: firmware accepts an index one past the last window
1165    /// and writes through the end of the array it indexes, so the frame that
1166    /// would carry it is the one frame this library must never put on the
1167    /// wire. The bound comes from the layout in the multiviewer's last status
1168    /// report, so a multiviewer that has reported none can only be given
1169    /// window zero, which every layout has.
1170    pub fn set_multiviewer_video_source(
1171        &self,
1172        device: DeviceUid,
1173        screen: u8,
1174        source: MultiviewerSource,
1175    ) -> Result<(), ControlError> {
1176        let source = source_index(source, "the source names no multiviewer input")?;
1177        self.shared.command(|state| {
1178            let target = multiviewer_of(state, device)?;
1179            let windows = target
1180                .multiviewer
1181                .as_ref()
1182                .and_then(MultiviewerStatus::window_count)
1183                .unwrap_or(1);
1184            if screen >= windows {
1185                return Err(ControlError::InvalidRequest(
1186                    "the window is not one the multiviewer is showing",
1187                ));
1188            }
1189            Ok(mv_command(target, mv_sub::VIDEO_SOURCE, &[screen, source]))
1190        })
1191    }
1192
1193    /// Selects which window's audio is output.
1194    pub fn set_multiviewer_audio_source(
1195        &self,
1196        device: DeviceUid,
1197        source: MultiviewerSource,
1198    ) -> Result<(), ControlError> {
1199        let source = source_index(source, "the audio source names no multiviewer input")?;
1200        self.multiviewer(device, mv_sub::AUDIO_SOURCE, &[source])
1201    }
1202
1203    /// Sets the output volume, as a percentage, and the mute state.
1204    ///
1205    /// A volume above 100 is refused rather than sent. What a multiviewer does
1206    /// with one depends on its module version: from 2026083100 it drops the
1207    /// whole frame, and before that it dropped the volume alone and still
1208    /// acted on the mute beside it. Neither is what the caller asked for, and
1209    /// neither is reported back.
1210    pub fn set_multiviewer_audio_volume(
1211        &self,
1212        device: DeviceUid,
1213        volume: u8,
1214        muted: bool,
1215    ) -> Result<(), ControlError> {
1216        if volume > 100 {
1217            return Err(ControlError::InvalidRequest(
1218                "a multiviewer volume is a percentage",
1219            ));
1220        }
1221        self.multiviewer(device, mv_sub::AUDIO_VOLUME, &[volume, u8::from(muted)])
1222    }
1223
1224    /// Sets the EDID template presented to the sources.
1225    pub fn set_multiviewer_edid_template(
1226        &self,
1227        device: DeviceUid,
1228        template: MultiviewerEdidTemplate,
1229    ) -> Result<(), ControlError> {
1230        let template = mv_setting(
1231            template.to_wire(),
1232            19,
1233            "the multiviewer has no such EDID template",
1234        )?;
1235        self.multiviewer(device, mv_sub::EDID_TEMPLATE, &[template])
1236    }
1237
1238    /// Selects which window receives remote-control passthrough.
1239    pub fn set_multiviewer_remote_control(
1240        &self,
1241        device: DeviceUid,
1242        source: MultiviewerSource,
1243    ) -> Result<(), ControlError> {
1244        let source = source_index(
1245            source,
1246            "the remote-control source names no multiviewer input",
1247        )?;
1248        self.multiviewer(device, mv_sub::ROUTE_RC, &[source])
1249    }
1250
1251    /// Sets how large the picture-in-picture window is.
1252    pub fn set_multiviewer_pip_size(
1253        &self,
1254        device: DeviceUid,
1255        size: MultiviewerPipSize,
1256    ) -> Result<(), ControlError> {
1257        let size = mv_setting(
1258            size.to_wire(),
1259            3,
1260            "the multiviewer has no such picture-in-picture size",
1261        )?;
1262        self.multiviewer(device, mv_sub::PIP_SIZE, &[size])
1263    }
1264
1265    /// Sets which corner the picture-in-picture window sits in.
1266    pub fn set_multiviewer_pip_position(
1267        &self,
1268        device: DeviceUid,
1269        position: MultiviewerPipPosition,
1270    ) -> Result<(), ControlError> {
1271        let position = mv_setting(
1272            position.to_wire(),
1273            4,
1274            "the multiviewer has no such picture-in-picture position",
1275        )?;
1276        self.multiviewer(device, mv_sub::PIP_POSITION, &[position])
1277    }
1278
1279    /// Sets the aspect ratio the windows are scaled to.
1280    pub fn set_multiviewer_aspect_ratio(
1281        &self,
1282        device: DeviceUid,
1283        aspect: MultiviewerAspectRatio,
1284    ) -> Result<(), ControlError> {
1285        let aspect = mv_setting(
1286            aspect.to_wire(),
1287            2,
1288            "the multiviewer has no such aspect ratio",
1289        )?;
1290        self.multiviewer(device, mv_sub::ASPECT, &[aspect])
1291    }
1292
1293    /// Enables or disables switching windows on its own.
1294    pub fn set_multiviewer_auto_switch(
1295        &self,
1296        device: DeviceUid,
1297        enable: bool,
1298    ) -> Result<(), ControlError> {
1299        self.multiviewer(device, mv_sub::AUTO_SWITCH, &[u8::from(enable)])
1300    }
1301
1302    /// Sets the output resolution and refresh rate.
1303    pub fn set_multiviewer_output_mode(
1304        &self,
1305        device: DeviceUid,
1306        mode: MultiviewerOutputMode,
1307    ) -> Result<(), ControlError> {
1308        let mode = mv_setting(
1309            mode.to_wire(),
1310            14,
1311            "the multiviewer has no such output mode",
1312        )?;
1313        self.multiviewer(device, mv_sub::OUTPUT_MODE, &[mode])
1314    }
1315
1316    /// Sets the IT-content flag on the output.
1317    pub fn set_multiviewer_output_itc(
1318        &self,
1319        device: DeviceUid,
1320        mode: MultiviewerItcMode,
1321    ) -> Result<(), ControlError> {
1322        let mode = mv_setting(
1323            mode.to_wire(),
1324            2,
1325            "the multiviewer has no such IT-content mode",
1326        )?;
1327        self.multiviewer(device, mv_sub::OUTPUT_ITC_MODE, &[mode])
1328    }
1329
1330    /// Sets the HDCP version negotiated on the output.
1331    pub fn set_multiviewer_hdcp_mode(
1332        &self,
1333        device: DeviceUid,
1334        mode: MultiviewerHdcpMode,
1335    ) -> Result<(), ControlError> {
1336        let mode = mv_setting(mode.to_wire(), 3, "the multiviewer has no such HDCP mode")?;
1337        self.multiviewer(device, mv_sub::HDCP_MODE, &[mode])
1338    }
1339
1340    /// Maps a source device onto one of the multiviewer's inputs, counting
1341    /// inputs from zero.
1342    ///
1343    /// [`DeviceUid::ZERO`] clears the mapping on a multiviewer running module
1344    /// version 2026083100 or newer, and is stored as a mapping like any other
1345    /// on anything older. No version checks that a mapping names a device on
1346    /// the mesh.
1347    ///
1348    /// Which of the two happened shows in `mappings` on a later status report,
1349    /// where a cleared input reads as [`DeviceUid::ZERO`] only from that same
1350    /// version. It will not be the next frame this multiviewer sends: this is
1351    /// one of the two settings that schedule no status broadcast of their own,
1352    /// so the answer arrives whenever something else prompts one.
1353    pub fn set_multiviewer_input_source(
1354        &self,
1355        device: DeviceUid,
1356        input: u8,
1357        source: DeviceUid,
1358    ) -> Result<(), ControlError> {
1359        if usize::from(input) >= MULTIVIEWER_INPUTS {
1360            return Err(ControlError::InvalidRequest(
1361                "the multiviewer has no such input",
1362            ));
1363        }
1364        let mut args = Vec::with_capacity(24);
1365        args.extend_from_slice(source.as_bytes());
1366        args.push(input);
1367        // mv_config_source_t is 4-aligned behind its uid, so seven bytes of
1368        // padding follow the input index.
1369        args.extend_from_slice(&[0; 7]);
1370        self.multiviewer(device, mv_sub::CONFIG_SOURCE, &args)
1371    }
1372
1373    /// Asks the multiviewer to route its sources itself.
1374    pub fn multiviewer_auto_route(&self, device: DeviceUid) -> Result<(), ControlError> {
1375        self.multiviewer(device, mv_sub::AUTO_ROUTE, &[])
1376    }
1377
1378    fn multiviewer(&self, device: DeviceUid, sub: u8, args: &[u8]) -> Result<(), ControlError> {
1379        self.shared
1380            .command(|state| Ok(mv_command(multiviewer_of(state, device)?, sub, args)))
1381    }
1382}