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