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