Skip to main content

mx_remote/runtime/
control.rs

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