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