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