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