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,
44    build_privacy_request, build_rc_action, build_rc_key, build_set_bay_name, build_set_volume,
45    build_sink_sdp_command, build_sink_sdp_pages, build_stats_request, build_target_only,
46    build_time_zone, build_unit_command, build_v2ip_device_settings,
47    build_v2ip_manual_source_switch, build_v2ip_scaling, build_v2ip_settings_all,
48    build_v2ip_source_switch, build_v2ip_testcard, build_v2ip_vlan, build_video_wall, mesh_op,
49    mv_cmd_payload, mv_sub, op, sdp_type, testcard_part, testcard_type, Addressee, BayUid,
50    DeviceFeature, DeviceUid, EdidProfile, MultiviewerAspectRatio, MultiviewerEdidTemplate,
51    MultiviewerHdcpMode, MultiviewerItcMode, MultiviewerOutputMode, MultiviewerPipPosition,
52    MultiviewerPipSize, MultiviewerSource, MultiviewerViewMode, MxrSignalType, Opcode, RcAction,
53    RcKey, SendError, StreamAddr, UnitCommandFlag, UnitCommandKind, V2ipDeviceSetting,
54    V2ipFpgaFeature, V2ipPowerSaveLevel, V2ipPtpMode, V2ipStreams, V2ipTestPattern, V2ipToneMode,
55    V2ipVlanFlag, DEVICE_NAME_LEN, TIME_ZONE_NAME_LEN, TIME_ZONE_RULE_LEN, V2IP_DSCP_MAX,
56    V2IP_IR_PROFILE_MAX, 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    /// Asks a V2IP source unit how its streams are encrypted now, rather than
1052    /// at its next report, which comes at least once a minute and whenever
1053    /// they change. [`Remote::v2ip_privacy`] reads the answer.
1054    ///
1055    /// Refused for a device that is not a V2IP source, and for one below
1056    /// protocol 0x30, which predates the report.
1057    pub fn request_v2ip_privacy(&self, device: DeviceUid) -> Result<(), ControlError> {
1058        self.shared.command(move |state| {
1059            let d = device_of(state, device)?;
1060            if !d.hello.features.has(DeviceFeature::V2IP_SOURCE) {
1061                return Err(ControlError::Unsupported("the device is not a V2IP source"));
1062            }
1063            Ok(Command::new(
1064                Addressee::device(d),
1065                op::V2IP_PRIVACY,
1066                build_privacy_request(d.uid),
1067            ))
1068        })
1069    }
1070
1071    /// Asks every V2IP source unit of the mesh how its streams are encrypted,
1072    /// as [`Remote::request_v2ip_privacy`] asks one. A unit below protocol
1073    /// 0x30 ignores it.
1074    pub fn request_all_v2ip_privacy(&self) -> Result<(), ControlError> {
1075        self.shared.send(
1076            &Addressee::Broadcast,
1077            op::V2IP_PRIVACY,
1078            &build_privacy_request(DeviceUid::ZERO),
1079        )?;
1080        Ok(())
1081    }
1082
1083    /// The one send behind the session description commands.
1084    fn sink_sdp_command(&self, device: DeviceUid, kind: u8, value: u8) -> Result<(), ControlError> {
1085        self.shared.command(move |state| {
1086            let d = sdp_sink_of(state, device)?;
1087            Ok(Command::new(
1088                Addressee::device(d),
1089                op::V2IP_SINK_SDP,
1090                build_sink_sdp_command(d.uid, kind, value),
1091            ))
1092        })
1093    }
1094
1095    // ---- units ----
1096
1097    /// Clears a unit's current system status.
1098    ///
1099    /// The terms of [`Remote::upgrade_unit_fpga`] apply.
1100    pub fn clear_unit_status(&self, device: DeviceUid) -> Result<(), ControlError> {
1101        self.unit_command(
1102            device,
1103            UnitCommandKind::CLEAR_STATUS,
1104            UnitCommandFlag::NONE,
1105            None,
1106        )
1107    }
1108
1109    /// Upgrades a unit's video processor to the latest image it holds, or
1110    /// flashes that image again when `force` is set and it already runs it.
1111    ///
1112    /// The unit takes a command only from its mesh controller or a management
1113    /// application, which this client announces itself as. Nothing
1114    /// acknowledges it: [`Remote::unit_status`] reads what the unit reports
1115    /// next. Refused for a device that is not a V2IP unit, and for one below
1116    /// protocol 0x2C, which predates the command.
1117    pub fn upgrade_unit_fpga(&self, device: DeviceUid, force: bool) -> Result<(), ControlError> {
1118        let flags = if force {
1119            UnitCommandFlag::FORCE
1120        } else {
1121            UnitCommandFlag::NONE
1122        };
1123        self.unit_command(device, UnitCommandKind::FPGA_UPGRADE, flags, None)
1124    }
1125
1126    /// Switches a unit to an IP configuration from DHCP.
1127    ///
1128    /// The terms of [`Remote::set_unit_network`] apply.
1129    pub fn set_unit_dhcp(&self, device: DeviceUid) -> Result<(), ControlError> {
1130        self.unit_command(
1131            device,
1132            UnitCommandKind::NET_CONFIG,
1133            UnitCommandFlag::DHCP,
1134            Some(UnitNetConfig::default()),
1135        )
1136    }
1137
1138    /// Gives a unit a static IP configuration.
1139    ///
1140    /// The address must be one a unit can hold and the netmask a run of ones
1141    /// from the top; anything else is refused before it is sent.
1142    ///
1143    /// The unit applies the change at once and reverts it unless its mesh
1144    /// controller, hearing the unit report it, confirms it in time.
1145    /// [`UnitStatus::network_pending`](crate::UnitStatus::network_pending) says
1146    /// whether it still waits, and
1147    /// [`Remote::confirm_unit_network`] confirms it. Otherwise as
1148    /// [`Remote::upgrade_unit_fpga`].
1149    pub fn set_unit_network(
1150        &self,
1151        device: DeviceUid,
1152        network: UnitNetConfig,
1153    ) -> Result<(), ControlError> {
1154        if !network.is_valid() {
1155            return Err(ControlError::InvalidRequest(
1156                "not an address and netmask a unit can hold",
1157            ));
1158        }
1159        self.unit_command(
1160            device,
1161            UnitCommandKind::NET_CONFIG,
1162            UnitCommandFlag::NONE,
1163            Some(network),
1164        )
1165    }
1166
1167    /// Keeps a unit's pending IP configuration, as its mesh controller does
1168    /// when it hears the unit at its new address.
1169    ///
1170    /// The terms of [`Remote::upgrade_unit_fpga`] apply.
1171    pub fn confirm_unit_network(&self, device: DeviceUid) -> Result<(), ControlError> {
1172        self.unit_command(
1173            device,
1174            UnitCommandKind::NET_CONFIRM,
1175            UnitCommandFlag::NONE,
1176            None,
1177        )
1178    }
1179
1180    /// The one send behind the unit command methods.
1181    fn unit_command(
1182        &self,
1183        device: DeviceUid,
1184        command: UnitCommandKind,
1185        flags: UnitCommandFlag,
1186        network: Option<UnitNetConfig>,
1187    ) -> Result<(), ControlError> {
1188        self.shared.command(move |state| {
1189            let d = device_of(state, device)?;
1190            if !d.is_v2ip() {
1191                return Err(ControlError::Unsupported("the device is not a V2IP unit"));
1192            }
1193            Ok(Command::new(
1194                Addressee::device(d),
1195                op::SYS_UNIT_COMMAND,
1196                build_unit_command(d.uid, command, flags, network.as_ref()),
1197            ))
1198        })
1199    }
1200
1201    /// Sets the time zone of every device that hears it.
1202    ///
1203    /// `zone` is an IANA name such as `Europe/Amsterdam` and `rule` the POSIX
1204    /// TZ rule the devices keep time by, such as `CET-1CEST,M3.5.0,M10.5.0/3`.
1205    /// A device applies the rule and shows the name.
1206    /// Neither may be empty, hold a NUL, or be longer than its field on the
1207    /// wire leaves room for; [`Remote::clear_mesh_time_zone`] sends both empty.
1208    ///
1209    /// The mesh controller takes it too, and announces it from then on with
1210    /// every periodic broadcast. A device takes it only from a management
1211    /// application or the controller, and this client announces itself as a
1212    /// management application.
1213    pub fn set_mesh_time_zone(&self, zone: &str, rule: &str) -> Result<(), ControlError> {
1214        let fits = |s: &str, len: usize| !s.is_empty() && s.len() < len && !s.contains('\0');
1215        if !fits(zone, TIME_ZONE_NAME_LEN) || !fits(rule, TIME_ZONE_RULE_LEN) {
1216            return Err(ControlError::InvalidRequest(
1217                "a time zone name or rule is empty, holds a NUL, or is too long",
1218            ));
1219        }
1220        self.shared.send(
1221            &Addressee::Broadcast,
1222            op::TIME_ZONE,
1223            &build_time_zone(zone, rule),
1224        )?;
1225        Ok(())
1226    }
1227
1228    /// Clears the time zone of every device that hears it, which then keeps
1229    /// UTC. Sent, received and announced as [`Remote::set_mesh_time_zone`]
1230    /// with an empty name and rule.
1231    pub fn clear_mesh_time_zone(&self) -> Result<(), ControlError> {
1232        self.shared.send(
1233            &Addressee::Broadcast,
1234            op::TIME_ZONE,
1235            &build_time_zone("", ""),
1236        )?;
1237        Ok(())
1238    }
1239
1240    /// Sets the clock of every device that hears it to `time`.
1241    ///
1242    /// A device keeps its own clock where that is within 2s of `time`. It takes
1243    /// the time only from a management application or the controller; see
1244    /// [`Remote::set_mesh_time_zone`]. A time before 1970 or past what 32 bits
1245    /// of seconds hold, in 2106, is refused.
1246    pub fn set_mesh_time(&self, time: SystemTime) -> Result<(), ControlError> {
1247        let utc = time
1248            .duration_since(UNIX_EPOCH)
1249            .ok()
1250            .and_then(|d| u32::try_from(d.as_secs()).ok())
1251            .ok_or(ControlError::InvalidRequest(
1252                "the time does not fit the frame",
1253            ))?;
1254        self.shared
1255            .send(&Addressee::Broadcast, op::TIME, &utc.to_le_bytes())?;
1256        Ok(())
1257    }
1258
1259    /// Tells every unit to check its update server for new firmware now.
1260    pub fn send_monitoring_pulse(&self) -> Result<(), ControlError> {
1261        self.shared
1262            .send(&Addressee::Broadcast, op::SYS_MONITORING_PULSE, &[])?;
1263        Ok(())
1264    }
1265
1266    // ---- V2IP scaling ----
1267
1268    /// Turns a V2IP sink's automatic scaling on or off.
1269    ///
1270    /// Refused for a unit below protocol 0x2F: released firmware drops a
1271    /// scaling write from management.
1272    ///
1273    /// Automatic scaling and a configured output mode are separate reasons for
1274    /// a sink to scale, and this moves only the first: a sink with a mode
1275    /// configured goes on scaling to it with automatic scaling off. Turning
1276    /// both off is this call plus [`Remote::clear_v2ip_output_mode`].
1277    ///
1278    /// Nothing acknowledges the frame. Read the sink back through
1279    /// [`Remote::v2ip_details`] to learn what it did, and treat the block as
1280    /// meaningful only where [`crate::DeviceInfo::config_initialised`] is set.
1281    /// **Read any route you still need before writing.** The sink rebuilds
1282    /// and rebroadcasts its subscription in response, and that report can
1283    /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
1284    /// and why.
1285    pub fn set_v2ip_auto_scaling(
1286        &self,
1287        device: DeviceUid,
1288        enabled: bool,
1289    ) -> Result<(), ControlError> {
1290        let written = if enabled {
1291            SCALING_FLAG_OPTIONS_VALID | SCALING_FLAG_AUTO_SCALING
1292        } else {
1293            SCALING_FLAG_OPTIONS_VALID
1294        };
1295        self.set_v2ip_scaling(device, MxrSignalType::NONE, 0, written, move |cached| {
1296            V2ipScalingSettings {
1297                flags: (cached.flags & !SCALING_FLAG_AUTO_SCALING)
1298                    | SCALING_FLAG_OPTIONS_VALID
1299                    | (written & SCALING_FLAG_AUTO_SCALING),
1300                ..cached
1301            }
1302        })
1303    }
1304
1305    /// Sets the output format a V2IP sink scales to.
1306    ///
1307    /// Refused for a unit below protocol 0x2F: released firmware drops a
1308    /// scaling write from management.
1309    ///
1310    /// The mode is checked here and nothing is sent if it fails, because every
1311    /// value a sink refuses it refuses in silence. Passing that check is not a
1312    /// guarantee: the sink also weighs the format against the display's EDID
1313    /// and against what its own output stage can produce.
1314    ///
1315    /// **Turn automatic scaling off first if it is on.** A sink refuses a mode
1316    /// whose format the attached display does not list while it is scaling
1317    /// automatically, and refuses it silently. Setting a mode and then turning
1318    /// automatic scaling back on is the order that survives, because the mode
1319    /// is checked while automatic scaling is still off.
1320    ///
1321    /// Configuring a mode is itself a reason to scale, so a sink with one
1322    /// scales whether or not automatic scaling is on.
1323    ///
1324    /// **Pass a descriptor and a refresh rate that agree.** A sink stores both
1325    /// halves and, with its match-source setting on as it ships, reports back
1326    /// the descriptor matching the refresh it holds: a 60Hz descriptor written
1327    /// with a refresh of 50 reads back as that descriptor's 50Hz sibling, once,
1328    /// and stays there. A sink with match-source off reports the descriptor it
1329    /// was given. Either way a pair that agrees reads back unchanged, and the
1330    /// format driven is the same - so this costs a caller nothing except a
1331    /// descriptor it did not write. That substitution shows up on the sink's
1332    /// next report rather than immediately, because
1333    /// [`crate::V2ipScalingSettings`] holds what was written until then.
1334    ///
1335    /// A mode read from the sink's own web interface is not interchangeable
1336    /// with this pair. That interface reports the descriptor's 60Hz sibling and
1337    /// carries the refresh in a field of its own, so writing back what it shows
1338    /// as the mode, on its own, changes the setting rather than restoring it.
1339    /// **Read any route you still need before writing.** The sink rebuilds
1340    /// and rebroadcasts its subscription in response, and that report can
1341    /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
1342    /// and why.
1343    pub fn set_v2ip_output_mode(
1344        &self,
1345        device: DeviceUid,
1346        mode: V2ipOutputMode,
1347    ) -> Result<(), ControlError> {
1348        mode.validate().map_err(ControlError::InvalidRequest)?;
1349        let signal = mode.to_signal_type();
1350        let refresh = mode.refresh;
1351        self.set_v2ip_scaling(
1352            device,
1353            signal,
1354            refresh,
1355            SCALING_FLAG_MODE_VALID,
1356            move |cached| V2ipScalingSettings {
1357                mode: signal,
1358                refresh,
1359                flags: cached.flags | SCALING_FLAG_MODE_VALID,
1360            },
1361        )
1362    }
1363
1364    /// Clears the output format a V2IP sink is configured to scale to.
1365    ///
1366    /// Refused for a unit below protocol 0x2F: released firmware drops a
1367    /// scaling write from management.
1368    ///
1369    /// The sink stops scaling for that reason and keeps its automatic scaling
1370    /// setting, so a sink scaling for both reasons goes on scaling until
1371    /// [`Remote::set_v2ip_auto_scaling`] turns the other one off.
1372    ///
1373    /// This is the only way to express "no mode configured", and it is what a
1374    /// caller restoring a sink that had none has to send: a sink reports no
1375    /// mode by leaving the mode's valid bit clear, which is not something a
1376    /// write can say.
1377    /// **Read any route you still need before writing.** The sink rebuilds
1378    /// and rebroadcasts its subscription in response, and that report can
1379    /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
1380    /// and why.
1381    pub fn clear_v2ip_output_mode(&self, device: DeviceUid) -> Result<(), ControlError> {
1382        // The valid bit with a zero descriptor is the clear. The receiver takes
1383        // that branch ahead of validating anything, and ignores the depth,
1384        // colour space and refresh rate beside it.
1385        self.set_v2ip_scaling(
1386            device,
1387            MxrSignalType::NONE,
1388            0,
1389            SCALING_FLAG_MODE_VALID,
1390            |cached| V2ipScalingSettings {
1391                mode: MxrSignalType::NONE,
1392                refresh: 0,
1393                flags: cached.flags & !SCALING_FLAG_MODE_VALID,
1394            },
1395        )
1396    }
1397
1398    /// The one send behind the scaling methods.
1399    ///
1400    /// `written` is the flag byte that goes out, and `applied` says what the
1401    /// sink will report afterwards. The two differ where the wire spells a
1402    /// write differently from the state it produces - clearing a mode is sent
1403    /// as the valid bit over a zero mode and read back as the valid bit clear -
1404    /// so predicting the cached value from the frame alone would leave a
1405    /// caller reading a state no device ever broadcasts.
1406    fn set_v2ip_scaling(
1407        &self,
1408        device: DeviceUid,
1409        mode: MxrSignalType,
1410        refresh: u16,
1411        written: u8,
1412        applied: impl FnOnce(V2ipScalingSettings) -> V2ipScalingSettings + Send + 'static,
1413    ) -> Result<(), ControlError> {
1414        self.shared.command(move |state| {
1415            let d = device_of(state, device)?;
1416            if !d.is_v2ip_sink() {
1417                return Err(ControlError::Unsupported(
1418                    "scaling settings need a V2IP sink",
1419                ));
1420            }
1421            // A unit below this protocol drops a scaling write from
1422            // management.
1423            if d.hello.supported_protocol < SCALING_WRITE_PROTOCOL {
1424                return Err(ControlError::Unsupported(
1425                    "the unit's firmware ignores scaling writes",
1426                ));
1427            }
1428            Ok(Command::new(
1429                Addressee::device(d),
1430                op::V2IP_DEVICE_CFG,
1431                build_v2ip_scaling(d.uid, mode, refresh, written),
1432            )
1433            .then(move |state, ev| {
1434                if let Some(d) = state.device_mut(device) {
1435                    let cached = d.v2ip_scaling();
1436                    d.set_v2ip_scaling(applied(cached), ev);
1437                }
1438            }))
1439        })
1440    }
1441
1442    // ---- V2IP device settings ----
1443
1444    /// Switches on/off settings of a V2IP device, all to the same value.
1445    ///
1446    /// `setting` names one or more of [`V2ipDeviceSetting::SWITCHES`], and
1447    /// each must be one the device has reported: a device ignores a setting it
1448    /// does not have, so a write for one would read back as applied here and
1449    /// change nothing there.
1450    ///
1451    /// Nothing acknowledges the frame. The device answers by reporting its
1452    /// settings, and until then [`Remote::v2ip_device_settings`] reads back
1453    /// what was written.
1454    pub fn set_v2ip_device_setting(
1455        &self,
1456        device: DeviceUid,
1457        setting: V2ipDeviceSetting,
1458        enabled: bool,
1459    ) -> Result<(), ControlError> {
1460        if setting.is_empty() || !setting.without(V2ipDeviceSetting::SWITCHES).is_empty() {
1461            return Err(ControlError::InvalidRequest(
1462                "only on/off device settings are switched",
1463            ));
1464        }
1465        self.set_v2ip_device_settings(
1466            device,
1467            V2ipDeviceSettings {
1468                valid: setting,
1469                flags: if enabled {
1470                    setting
1471                } else {
1472                    V2ipDeviceSetting::NONE
1473                },
1474                ..V2ipDeviceSettings::default()
1475            },
1476        )
1477    }
1478
1479    /// Sets the infrared profile of a V2IP device's global infrared port.
1480    ///
1481    /// `profile` is below [`V2IP_IR_PROFILE_MAX`], and is checked here because
1482    /// a device ignores one out of range. The terms of
1483    /// [`Remote::set_v2ip_device_setting`] apply.
1484    pub fn set_v2ip_ir_profile(&self, device: DeviceUid, profile: i8) -> Result<(), ControlError> {
1485        if !(0..V2IP_IR_PROFILE_MAX).contains(&profile) {
1486            return Err(ControlError::InvalidRequest("no such infrared profile"));
1487        }
1488        self.set_v2ip_device_settings(
1489            device,
1490            V2ipDeviceSettings {
1491                valid: V2ipDeviceSetting::IR_PROFILE,
1492                ir_profile: profile,
1493                ..V2ipDeviceSettings::default()
1494            },
1495        )
1496    }
1497
1498    /// Sets the infrared profile of a V2IP device's output infrared port.
1499    ///
1500    /// [`V2IP_IR_PROFILE_NOT_SET`] makes the port follow the global one.
1501    /// Otherwise as [`Remote::set_v2ip_ir_profile`].
1502    pub fn set_v2ip_sink_ir_profile(
1503        &self,
1504        device: DeviceUid,
1505        profile: i8,
1506    ) -> Result<(), ControlError> {
1507        if !(V2IP_IR_PROFILE_NOT_SET..V2IP_IR_PROFILE_MAX).contains(&profile) {
1508            return Err(ControlError::InvalidRequest("no such infrared profile"));
1509        }
1510        self.set_v2ip_device_settings(
1511            device,
1512            V2ipDeviceSettings {
1513                valid: V2ipDeviceSetting::IR_PROFILE_SINK,
1514                ir_profile_sink: profile,
1515                ..V2ipDeviceSettings::default()
1516            },
1517        )
1518    }
1519
1520    /// Sets how many idle minutes a V2IP device waits before it powers down by
1521    /// itself, 0 for never. The terms of [`Remote::set_v2ip_device_setting`]
1522    /// apply.
1523    pub fn set_v2ip_auto_power_save(
1524        &self,
1525        device: DeviceUid,
1526        minutes: u16,
1527    ) -> Result<(), ControlError> {
1528        self.set_v2ip_device_settings(
1529            device,
1530            V2ipDeviceSettings {
1531                valid: V2ipDeviceSetting::AUTO_POWER_SAVE,
1532                auto_power_save: minutes,
1533                ..V2ipDeviceSettings::default()
1534            },
1535        )
1536    }
1537
1538    /// Sets a V2IP device's daily power save windows.
1539    ///
1540    /// Every time is below [`V2IP_MINUTES_PER_DAY`](crate::V2IP_MINUTES_PER_DAY),
1541    /// and is checked here.
1542    /// The windows are kept in the device's own time zone. The terms of
1543    /// [`Remote::set_v2ip_device_setting`] apply.
1544    pub fn set_v2ip_power_save_schedule(
1545        &self,
1546        device: DeviceUid,
1547        schedule: V2ipPowerSaveSchedule,
1548    ) -> Result<(), ControlError> {
1549        if !schedule.is_valid() {
1550            return Err(ControlError::InvalidRequest(
1551                "a power save time is not a time of day",
1552            ));
1553        }
1554        self.set_v2ip_device_settings(
1555            device,
1556            V2ipDeviceSettings {
1557                valid: V2ipDeviceSetting::POWER_SAVE_SCHEDULE,
1558                power_save: schedule,
1559                ..V2ipDeviceSettings::default()
1560            },
1561        )
1562    }
1563
1564    /// Sets the level a V2IP device powers down to in power save. A level the
1565    /// unit does not offer is one its [`UnitStatus`](crate::UnitStatus)
1566    /// reports without [`SUPPORTED`](crate::UnitPowerSave::SUPPORTED). It also
1567    /// switches [`POWER_SAVE_DISABLED`](V2ipDeviceSetting::POWER_SAVE_DISABLED)
1568    /// off. The terms of [`Remote::set_v2ip_device_setting`] apply.
1569    pub fn set_v2ip_power_save_level(
1570        &self,
1571        device: DeviceUid,
1572        level: V2ipPowerSaveLevel,
1573    ) -> Result<(), ControlError> {
1574        if level > V2ipPowerSaveLevel::OFF {
1575            return Err(ControlError::InvalidRequest("no such power save level"));
1576        }
1577        self.set_v2ip_device_settings(
1578            device,
1579            V2ipDeviceSettings {
1580                valid: V2ipDeviceSetting::POWER_SAVE_LEVEL,
1581                power_save_level: level,
1582                ..V2ipDeviceSettings::default()
1583            },
1584        )
1585    }
1586
1587    /// Sets how a V2IP device paces its PTP messages, and its SAP timings: the
1588    /// values of `messages` behind the bits of `which`, each one of
1589    /// [`SETTINGS_BLOCK`](V2ipDeviceSetting::SETTINGS_BLOCK). A DSCP above
1590    /// [`V2IP_DSCP_MAX`](crate::V2IP_DSCP_MAX) and a stream TTL of 0 are
1591    /// refused. The terms of [`Remote::set_v2ip_device_setting`] apply.
1592    pub fn set_v2ip_ptp_messages(
1593        &self,
1594        device: DeviceUid,
1595        which: V2ipDeviceSetting,
1596        messages: V2ipPtpMessages,
1597    ) -> Result<(), ControlError> {
1598        if which.is_empty() || !V2ipDeviceSetting::SETTINGS_BLOCK.has(which) {
1599            return Err(ControlError::InvalidRequest(
1600                "only PTP message, SAP timing and stream TTL settings are carried here",
1601            ));
1602        }
1603        if which.has(V2ipDeviceSetting::PTP_DSCP) && messages.dscp > V2IP_DSCP_MAX {
1604            return Err(ControlError::InvalidRequest("a DSCP above 63"));
1605        }
1606        if which.has(V2ipDeviceSetting::STREAM_TTL) && messages.stream_ttl == 0 {
1607            return Err(ControlError::InvalidRequest("a stream TTL of 0"));
1608        }
1609        self.set_v2ip_device_settings(
1610            device,
1611            V2ipDeviceSettings {
1612                valid: which,
1613                ptp_messages: messages,
1614                ..V2ipDeviceSettings::default()
1615            },
1616        )
1617    }
1618
1619    /// Sets the PTP mode a V2IP device runs. The terms of
1620    /// [`Remote::set_v2ip_device_setting`] apply.
1621    pub fn set_v2ip_ptp_mode(
1622        &self,
1623        device: DeviceUid,
1624        mode: V2ipPtpMode,
1625    ) -> Result<(), ControlError> {
1626        if mode > V2ipPtpMode::AUTO {
1627            return Err(ControlError::InvalidRequest("no such PTP mode"));
1628        }
1629        self.set_v2ip_device_settings(
1630            device,
1631            V2ipDeviceSettings {
1632                valid: V2ipDeviceSetting::PTP_MODE,
1633                ptp_mode: mode,
1634                ..V2ipDeviceSettings::default()
1635            },
1636        )
1637    }
1638
1639    /// Sets the PTP domain a V2IP device runs in, at most
1640    /// [`V2IP_PTP_DOMAIN_MAX`](crate::V2IP_PTP_DOMAIN_MAX). The terms of
1641    /// [`Remote::set_v2ip_device_setting`] apply.
1642    pub fn set_v2ip_ptp_domain(&self, device: DeviceUid, domain: u8) -> Result<(), ControlError> {
1643        if domain > V2IP_PTP_DOMAIN_MAX {
1644            return Err(ControlError::InvalidRequest("no such PTP domain"));
1645        }
1646        self.set_v2ip_device_settings(
1647            device,
1648            V2ipDeviceSettings {
1649                valid: V2ipDeviceSetting::PTP_DOMAIN,
1650                ptp_domain: domain,
1651                ..V2ipDeviceSettings::default()
1652            },
1653        )
1654    }
1655
1656    /// Sets the priority1 a V2IP device announces as a PTP grandmaster, lower
1657    /// winning. The terms of [`Remote::set_v2ip_device_setting`] apply.
1658    pub fn set_v2ip_ptp_priority1(
1659        &self,
1660        device: DeviceUid,
1661        priority1: u8,
1662    ) -> Result<(), ControlError> {
1663        self.set_v2ip_device_settings(
1664            device,
1665            V2ipDeviceSettings {
1666                valid: V2ipDeviceSetting::PTP_PRIORITY1,
1667                ptp_priority1: priority1,
1668                ..V2ipDeviceSettings::default()
1669            },
1670        )
1671    }
1672
1673    /// Changes settings on every V2IP device of the mesh with one frame.
1674    ///
1675    /// `settings` carries the settings behind their bits in
1676    /// [`valid`](V2ipDeviceSettings::valid), as a device reports them. Each
1677    /// device applies those it has and ignores the rest, and none that
1678    /// predates the frame applies any. A setting only a device reports about
1679    /// itself, a profile, PTP mode, PTP domain, power save level or PTP DSCP
1680    /// out of range, a stream TTL of 0 and a schedule time that is not a time
1681    /// of day are refused
1682    /// here, since every device would ignore them.
1683    ///
1684    /// Nothing is cached: each device that applies a change reports its
1685    /// settings, and [`Remote::v2ip_device_settings`] reads that.
1686    pub fn set_all_v2ip_device_settings(
1687        &self,
1688        settings: V2ipDeviceSettings,
1689    ) -> Result<(), ControlError> {
1690        let valid = settings.valid;
1691        if valid.is_empty() {
1692            return Err(ControlError::InvalidRequest("no setting is carried"));
1693        }
1694        if !(valid & V2ipDeviceSetting::REPORTED_ONLY).is_empty() {
1695            return Err(ControlError::InvalidRequest(
1696                "a setting only a device reports is carried",
1697            ));
1698        }
1699        if valid.has(V2ipDeviceSetting::IR_PROFILE)
1700            && !(0..V2IP_IR_PROFILE_MAX).contains(&settings.ir_profile)
1701        {
1702            return Err(ControlError::InvalidRequest("no such infrared profile"));
1703        }
1704        if valid.has(V2ipDeviceSetting::IR_PROFILE_SINK)
1705            && !(V2IP_IR_PROFILE_NOT_SET..V2IP_IR_PROFILE_MAX).contains(&settings.ir_profile_sink)
1706        {
1707            return Err(ControlError::InvalidRequest("no such infrared profile"));
1708        }
1709        if valid.has(V2ipDeviceSetting::POWER_SAVE_SCHEDULE) && !settings.power_save.is_valid() {
1710            return Err(ControlError::InvalidRequest(
1711                "a power save time is not a time of day",
1712            ));
1713        }
1714        if valid.has(V2ipDeviceSetting::PTP_MODE) && settings.ptp_mode > V2ipPtpMode::AUTO {
1715            return Err(ControlError::InvalidRequest("no such PTP mode"));
1716        }
1717        if valid.has(V2ipDeviceSetting::PTP_DOMAIN) && settings.ptp_domain > V2IP_PTP_DOMAIN_MAX {
1718            return Err(ControlError::InvalidRequest("no such PTP domain"));
1719        }
1720        if valid.has(V2ipDeviceSetting::POWER_SAVE_LEVEL)
1721            && settings.power_save_level > V2ipPowerSaveLevel::OFF
1722        {
1723            return Err(ControlError::InvalidRequest("no such power save level"));
1724        }
1725        if valid.has(V2ipDeviceSetting::PTP_DSCP) && settings.ptp_messages.dscp > V2IP_DSCP_MAX {
1726            return Err(ControlError::InvalidRequest("a DSCP above 63"));
1727        }
1728        if valid.has(V2ipDeviceSetting::STREAM_TTL) && settings.ptp_messages.stream_ttl == 0 {
1729            return Err(ControlError::InvalidRequest("a stream TTL of 0"));
1730        }
1731        self.shared.send(
1732            &Addressee::Broadcast,
1733            op::V2IP_SETTINGS_ALL,
1734            &build_v2ip_settings_all(&settings),
1735        )?;
1736        Ok(())
1737    }
1738
1739    /// The one send behind the device settings methods.
1740    fn set_v2ip_device_settings(
1741        &self,
1742        device: DeviceUid,
1743        settings: V2ipDeviceSettings,
1744    ) -> Result<(), ControlError> {
1745        self.shared.command(move |state| {
1746            let d = device_of(state, device)?;
1747            let Some(reported) = d.v2ip_settings else {
1748                return Err(ControlError::NotReported("the device's settings"));
1749            };
1750            if !reported.valid.has(settings.valid) {
1751                return Err(ControlError::Unsupported(
1752                    "the device does not have this setting",
1753                ));
1754            }
1755            Ok(Command::new(
1756                Addressee::device(d),
1757                op::V2IP_DEVICE_CFG,
1758                build_v2ip_device_settings(d.uid, &settings),
1759            )
1760            .then(move |state, ev| {
1761                if let Some(d) = state.device_mut(device) {
1762                    d.merge_v2ip_settings(settings, ev);
1763                }
1764            }))
1765        })
1766    }
1767
1768    // ---- V2IP VLAN ----
1769
1770    /// Changes a V2IP device's VLAN configuration.
1771    ///
1772    /// Writes the VLAN ids, the pinned uplink and the
1773    /// [`TRUNK`](V2ipVlanFlag::TRUNK) flag of `vlan`; its other flags and the
1774    /// fields only the device reports are not sent. Refused before anything is
1775    /// sent unless the device announces [`DeviceFeature::VLAN`] and has
1776    /// reported its configuration, every id is at most
1777    /// [`V2IP_VLAN_ID_MAX`](crate::V2IP_VLAN_ID_MAX), and the uplink is
1778    /// detected or names a port the device has.
1779    ///
1780    /// The device applies the change at once and reverts it unless the mesh
1781    /// controller, hearing the device report it, confirms it. Nothing is
1782    /// cached here: [`Remote::v2ip_vlan`] reads what the device reports, and
1783    /// [`V2ipVlan::is_pending`] whether it is still to be confirmed.
1784    pub fn set_v2ip_vlan(&self, device: DeviceUid, vlan: V2ipVlan) -> Result<(), ControlError> {
1785        if !vlan.is_valid() {
1786            return Err(ControlError::InvalidRequest(
1787                "a VLAN id is out of range or the uplink names no port",
1788            ));
1789        }
1790        let written = V2ipVlan {
1791            flags: V2ipVlanFlag::VALID | (vlan.flags & V2ipVlanFlag::TRUNK),
1792            device: vlan.device,
1793            port: vlan.port,
1794            uplink: vlan.uplink,
1795            active_uplink: 0,
1796            revert_s: 0,
1797        };
1798        self.shared.command(move |state| {
1799            let d = device_of(state, device)?;
1800            if !d.hello.features.has(DeviceFeature::VLAN) {
1801                return Err(ControlError::Unsupported("the device does not take VLANs"));
1802            }
1803            let Some(reported) = d.v2ip_vlan else {
1804                return Err(ControlError::NotReported("the device's VLAN configuration"));
1805            };
1806            if written.pinned_uplink() == Some(V2IP_VLAN_PORT_SFP) && !reported.has_sfp() {
1807                return Err(ControlError::Unsupported("the device has no SFP port"));
1808            }
1809            Ok(Command::new(
1810                Addressee::device(d),
1811                op::V2IP_DEVICE_CFG,
1812                build_v2ip_vlan(d.uid, &written),
1813            ))
1814        })
1815    }
1816
1817    // ---- V2IP test card ----
1818
1819    /// Asks a V2IP sink for its test card, which it reports straight back;
1820    /// [`Remote::v2ip_testcard`] reads it.
1821    ///
1822    /// Refused unless the sink's video processor has reported
1823    /// [`SINK_TEST_PATTERN`](V2ipFpgaFeature::SINK_TEST_PATTERN). A sink with
1824    /// that feature but without the module that draws the test card does not
1825    /// answer.
1826    pub fn request_v2ip_testcard(&self, device: DeviceUid) -> Result<(), ControlError> {
1827        self.send_v2ip_testcard(device, testcard_type::REQUEST, 0, V2ipTestcard::default())
1828    }
1829
1830    /// Shows a test pattern on a V2IP sink's output, or none for
1831    /// [`V2ipTestPattern::OFF`]. `colour` is `0xRRGGBB`, used by
1832    /// [`V2ipTestPattern::FLAT`].
1833    ///
1834    /// A pattern runs until it is turned off, and holds the output on while it
1835    /// does. The sink reports its test card in answer. Refused as
1836    /// [`Remote::request_v2ip_testcard`] is, and for a pattern this library
1837    /// does not name or a colour above `0xFFFFFF`.
1838    pub fn set_v2ip_test_pattern(
1839        &self,
1840        device: DeviceUid,
1841        pattern: V2ipTestPattern,
1842        colour: u32,
1843    ) -> Result<(), ControlError> {
1844        if pattern.to_wire() > V2ipTestPattern::CARD.to_wire() || colour > 0x00FF_FFFF {
1845            return Err(ControlError::InvalidRequest(
1846                "no such test pattern, or a colour wider than 24 bits",
1847            ));
1848        }
1849        self.send_v2ip_testcard(
1850            device,
1851            testcard_type::SET,
1852            testcard_part::PATTERN,
1853            V2ipTestcard {
1854                pattern,
1855                colour,
1856                ..V2ipTestcard::default()
1857            },
1858        )
1859    }
1860
1861    /// Plays a test tone on a V2IP sink's output.
1862    ///
1863    /// Every value is checked here, as the sink ignores a tone that is not
1864    /// [`V2ipTestTone::is_valid`]; [`V2ipToneMode::OFF`] stops it whatever the
1865    /// rest holds, and the sink keeps those values as its last ones. Otherwise
1866    /// as [`Remote::set_v2ip_test_pattern`].
1867    pub fn set_v2ip_test_tone(
1868        &self,
1869        device: DeviceUid,
1870        tone: V2ipTestTone,
1871    ) -> Result<(), ControlError> {
1872        if tone.mode != V2ipToneMode::OFF && !tone.is_valid() {
1873            return Err(ControlError::InvalidRequest(
1874                "a test tone value is out of range",
1875            ));
1876        }
1877        self.send_v2ip_testcard(
1878            device,
1879            testcard_type::SET,
1880            testcard_part::TONE,
1881            V2ipTestcard {
1882                tone,
1883                ..V2ipTestcard::default()
1884            },
1885        )
1886    }
1887
1888    /// Sets a V2IP sink's lip-sync flash.
1889    ///
1890    /// Checked here as [`V2ipTestSync::is_valid`], since the sink ignores
1891    /// settings that are not. Otherwise as [`Remote::set_v2ip_test_pattern`].
1892    pub fn set_v2ip_test_sync(
1893        &self,
1894        device: DeviceUid,
1895        sync: V2ipTestSync,
1896    ) -> Result<(), ControlError> {
1897        if !sync.is_valid() {
1898            return Err(ControlError::InvalidRequest(
1899                "a lip-sync value is out of range",
1900            ));
1901        }
1902        self.send_v2ip_testcard(
1903            device,
1904            testcard_type::SET,
1905            testcard_part::SYNC,
1906            V2ipTestcard {
1907                sync,
1908                ..V2ipTestcard::default()
1909            },
1910        )
1911    }
1912
1913    /// The one send behind the test card methods. Nothing is cached: the sink
1914    /// answers every frame with its test card.
1915    fn send_v2ip_testcard(
1916        &self,
1917        device: DeviceUid,
1918        kind: u8,
1919        parts: u8,
1920        testcard: V2ipTestcard,
1921    ) -> Result<(), ControlError> {
1922        self.shared.command(move |state| {
1923            let d = device_of(state, device)?;
1924            let Some(features) = d.v2ip_features else {
1925                return Err(ControlError::NotReported(
1926                    "the sink's video processor features",
1927                ));
1928            };
1929            if !features.has(V2ipFpgaFeature::SINK_TEST_PATTERN) {
1930                return Err(ControlError::Unsupported(
1931                    "the sink cannot draw a test card",
1932                ));
1933            }
1934            Ok(Command::new(
1935                Addressee::device(d),
1936                op::V2IP_TESTCARD,
1937                build_v2ip_testcard(d.uid, kind, parts, &testcard),
1938            ))
1939        })
1940    }
1941
1942    // ---- video wall ----
1943
1944    /// Shows a window on a sink's video wall without persisting it.
1945    ///
1946    /// The window survives until the sink is told otherwise or restarts.
1947    /// [`Remote::revert_video_wall`] puts back whatever was stored.
1948    ///
1949    /// Pass [`crate::VIDEO_WALL_CLEARED`] to show the whole frame again.
1950    pub fn preview_video_wall(
1951        &self,
1952        sink: DeviceUid,
1953        window: VideoWallWindow,
1954    ) -> Result<(), ControlError> {
1955        self.set_video_wall(sink, window, VideoWallOp::PREVIEW)
1956    }
1957
1958    /// Persists a window as a sink's video wall.
1959    ///
1960    /// The geometry is checked here, before anything is sent, because the sink
1961    /// is not guaranteed to check it. A sink running a video-wall module older
1962    /// than 2026083100 writes the window to its configuration *before* asking
1963    /// its video processor to apply it, and the processor's refusal does not
1964    /// undo that write - so an out-of-spec window survives a reboot and is
1965    /// re-offered on every stream restart until something else replaces it. A
1966    /// power cycle does not clear it.
1967    ///
1968    /// Nothing acknowledges this frame either way, so an `Ok` says only that
1969    /// it was sent. Read the sink's state back to learn what it did.
1970    ///
1971    /// Pass [`crate::VIDEO_WALL_CLEARED`] to store "show the whole frame".
1972    pub fn store_video_wall(
1973        &self,
1974        sink: DeviceUid,
1975        window: VideoWallWindow,
1976    ) -> Result<(), ControlError> {
1977        self.set_video_wall(sink, window, VideoWallOp::STORE)
1978    }
1979
1980    /// Restores the window a sink has stored, discarding a preview.
1981    ///
1982    /// Carries no window of its own: the sink already holds the one this puts
1983    /// back.
1984    pub fn revert_video_wall(&self, sink: DeviceUid) -> Result<(), ControlError> {
1985        self.set_video_wall(sink, VIDEO_WALL_CLEARED, VideoWallOp::REVERT)
1986    }
1987
1988    /// The one send behind the three video-wall methods.
1989    ///
1990    /// Validation sits here rather than in each of them, so an operation added
1991    /// later cannot reach the wire without it, and is skipped for a revert
1992    /// because the sink ignores the window on that operation rather than
1993    /// checking it.
1994    ///
1995    /// Refused for a unit that does not report
1996    /// [`VIDEO_WALL`](DeviceFeature::VIDEO_WALL), which drops the frame
1997    /// unanswered.
1998    ///
1999    /// Passing it is not proof a wall appeared. Two things the sink refuses
2000    /// afterwards are equally silent: a window it will not draw, which it logs
2001    /// and drops, and a sink whose image has no tiling support at all, which
2002    /// takes the window into its own state and then fails to push it to the
2003    /// hardware. Neither reaches the wire, so read the sink back over HTTP to
2004    /// learn a window landed.
2005    fn set_video_wall(
2006        &self,
2007        sink: DeviceUid,
2008        window: VideoWallWindow,
2009        op: VideoWallOp,
2010    ) -> Result<(), ControlError> {
2011        if op != VideoWallOp::REVERT {
2012            window.validate().map_err(ControlError::InvalidRequest)?;
2013        }
2014        self.shared.command(move |state| {
2015            let device = device_of(state, sink)?;
2016            // A unit without the module drops the frame unanswered.
2017            if !device.hello.features.has(DeviceFeature::VIDEO_WALL) {
2018                return Err(ControlError::Unsupported("the unit has no video wall"));
2019            }
2020            Ok(Command::new(
2021                Addressee::device(device),
2022                op::V2IP_VIDEO_WALL,
2023                build_video_wall(device.uid, window, op),
2024            ))
2025        })
2026    }
2027
2028    // ---- multiviewer ----
2029
2030    /// Sets the window layout.
2031    pub fn set_multiviewer_view_mode(
2032        &self,
2033        device: DeviceUid,
2034        mode: MultiviewerViewMode,
2035    ) -> Result<(), ControlError> {
2036        let mode = mv_setting(mode.to_wire(), 8, "the multiviewer has no such view mode")?;
2037        self.multiviewer(device, mv_sub::VIEW_MODE, &[mode])
2038    }
2039
2040    /// Assigns a source to one window, counting windows from zero.
2041    ///
2042    /// A window index the multiviewer is not currently showing is refused
2043    /// rather than sent: firmware accepts an index one past the last window
2044    /// and writes through the end of the array it indexes, so the frame that
2045    /// would carry it is the one frame this library must never put on the
2046    /// wire. The bound comes from the layout in the multiviewer's last status
2047    /// report, so a multiviewer that has reported none can only be given
2048    /// window zero, which every layout has.
2049    pub fn set_multiviewer_video_source(
2050        &self,
2051        device: DeviceUid,
2052        screen: u8,
2053        source: MultiviewerSource,
2054    ) -> Result<(), ControlError> {
2055        let source = source_index(source, "the source names no multiviewer input")?;
2056        self.shared.command(|state| {
2057            let target = multiviewer_of(state, device)?;
2058            let windows = target
2059                .multiviewer
2060                .as_ref()
2061                .and_then(MultiviewerStatus::window_count)
2062                .unwrap_or(1);
2063            if screen >= windows {
2064                return Err(ControlError::InvalidRequest(
2065                    "the window is not one the multiviewer is showing",
2066                ));
2067            }
2068            Ok(mv_command(target, mv_sub::VIDEO_SOURCE, &[screen, source]))
2069        })
2070    }
2071
2072    /// Selects which window's audio is output.
2073    pub fn set_multiviewer_audio_source(
2074        &self,
2075        device: DeviceUid,
2076        source: MultiviewerSource,
2077    ) -> Result<(), ControlError> {
2078        let source = source_index(source, "the audio source names no multiviewer input")?;
2079        self.multiviewer(device, mv_sub::AUDIO_SOURCE, &[source])
2080    }
2081
2082    /// Sets the output volume, as a percentage, and the mute state.
2083    ///
2084    /// A volume above 100 is refused rather than sent. What a multiviewer does
2085    /// with one depends on its module version: from 2026083100 it drops the
2086    /// whole frame, and before that it dropped the volume alone and still
2087    /// acted on the mute beside it. Neither is what the caller asked for, and
2088    /// neither is reported back.
2089    pub fn set_multiviewer_audio_volume(
2090        &self,
2091        device: DeviceUid,
2092        volume: u8,
2093        muted: bool,
2094    ) -> Result<(), ControlError> {
2095        if volume > 100 {
2096            return Err(ControlError::InvalidRequest(
2097                "a multiviewer volume is a percentage",
2098            ));
2099        }
2100        self.multiviewer(device, mv_sub::AUDIO_VOLUME, &[volume, u8::from(muted)])
2101    }
2102
2103    /// Sets the EDID template presented to the sources.
2104    pub fn set_multiviewer_edid_template(
2105        &self,
2106        device: DeviceUid,
2107        template: MultiviewerEdidTemplate,
2108    ) -> Result<(), ControlError> {
2109        let template = mv_setting(
2110            template.to_wire(),
2111            19,
2112            "the multiviewer has no such EDID template",
2113        )?;
2114        self.multiviewer(device, mv_sub::EDID_TEMPLATE, &[template])
2115    }
2116
2117    /// Selects which window receives remote-control passthrough.
2118    pub fn set_multiviewer_remote_control(
2119        &self,
2120        device: DeviceUid,
2121        source: MultiviewerSource,
2122    ) -> Result<(), ControlError> {
2123        let source = source_index(
2124            source,
2125            "the remote-control source names no multiviewer input",
2126        )?;
2127        self.multiviewer(device, mv_sub::ROUTE_RC, &[source])
2128    }
2129
2130    /// Sets how large the picture-in-picture window is.
2131    pub fn set_multiviewer_pip_size(
2132        &self,
2133        device: DeviceUid,
2134        size: MultiviewerPipSize,
2135    ) -> Result<(), ControlError> {
2136        let size = mv_setting(
2137            size.to_wire(),
2138            3,
2139            "the multiviewer has no such picture-in-picture size",
2140        )?;
2141        self.multiviewer(device, mv_sub::PIP_SIZE, &[size])
2142    }
2143
2144    /// Sets which corner the picture-in-picture window sits in.
2145    pub fn set_multiviewer_pip_position(
2146        &self,
2147        device: DeviceUid,
2148        position: MultiviewerPipPosition,
2149    ) -> Result<(), ControlError> {
2150        let position = mv_setting(
2151            position.to_wire(),
2152            4,
2153            "the multiviewer has no such picture-in-picture position",
2154        )?;
2155        self.multiviewer(device, mv_sub::PIP_POSITION, &[position])
2156    }
2157
2158    /// Sets the aspect ratio the windows are scaled to.
2159    pub fn set_multiviewer_aspect_ratio(
2160        &self,
2161        device: DeviceUid,
2162        aspect: MultiviewerAspectRatio,
2163    ) -> Result<(), ControlError> {
2164        let aspect = mv_setting(
2165            aspect.to_wire(),
2166            2,
2167            "the multiviewer has no such aspect ratio",
2168        )?;
2169        self.multiviewer(device, mv_sub::ASPECT, &[aspect])
2170    }
2171
2172    /// Enables or disables switching windows on its own.
2173    pub fn set_multiviewer_auto_switch(
2174        &self,
2175        device: DeviceUid,
2176        enable: bool,
2177    ) -> Result<(), ControlError> {
2178        self.multiviewer(device, mv_sub::AUTO_SWITCH, &[u8::from(enable)])
2179    }
2180
2181    /// Sets the output resolution and refresh rate.
2182    pub fn set_multiviewer_output_mode(
2183        &self,
2184        device: DeviceUid,
2185        mode: MultiviewerOutputMode,
2186    ) -> Result<(), ControlError> {
2187        let mode = mv_setting(
2188            mode.to_wire(),
2189            14,
2190            "the multiviewer has no such output mode",
2191        )?;
2192        self.multiviewer(device, mv_sub::OUTPUT_MODE, &[mode])
2193    }
2194
2195    /// Sets the IT-content flag on the output.
2196    pub fn set_multiviewer_output_itc(
2197        &self,
2198        device: DeviceUid,
2199        mode: MultiviewerItcMode,
2200    ) -> Result<(), ControlError> {
2201        let mode = mv_setting(
2202            mode.to_wire(),
2203            2,
2204            "the multiviewer has no such IT-content mode",
2205        )?;
2206        self.multiviewer(device, mv_sub::OUTPUT_ITC_MODE, &[mode])
2207    }
2208
2209    /// Sets the HDCP version negotiated on the output.
2210    pub fn set_multiviewer_hdcp_mode(
2211        &self,
2212        device: DeviceUid,
2213        mode: MultiviewerHdcpMode,
2214    ) -> Result<(), ControlError> {
2215        let mode = mv_setting(mode.to_wire(), 3, "the multiviewer has no such HDCP mode")?;
2216        self.multiviewer(device, mv_sub::HDCP_MODE, &[mode])
2217    }
2218
2219    /// Maps a source device onto one of the multiviewer's inputs, counting
2220    /// inputs from zero.
2221    ///
2222    /// [`DeviceUid::ZERO`] clears the mapping on a multiviewer running module
2223    /// version 2026083100 or newer, and is stored as a mapping like any other
2224    /// on anything older. No version checks that a mapping names a device on
2225    /// the mesh.
2226    ///
2227    /// Which of the two happened shows in `mappings` on a later status report,
2228    /// where a cleared input reads as [`DeviceUid::ZERO`] only from that same
2229    /// version. It will not be the next frame this multiviewer sends: this is
2230    /// one of the two settings that schedule no status broadcast of their own,
2231    /// so the answer arrives whenever something else prompts one.
2232    pub fn set_multiviewer_input_source(
2233        &self,
2234        device: DeviceUid,
2235        input: u8,
2236        source: DeviceUid,
2237    ) -> Result<(), ControlError> {
2238        if usize::from(input) >= MULTIVIEWER_INPUTS {
2239            return Err(ControlError::InvalidRequest(
2240                "the multiviewer has no such input",
2241            ));
2242        }
2243        let mut args = Vec::with_capacity(24);
2244        args.extend_from_slice(source.as_bytes());
2245        args.push(input);
2246        // The body is 8-aligned, so seven bytes of padding round the uid and
2247        // the input index up to 24.
2248        args.extend_from_slice(&[0; 7]);
2249        self.multiviewer(device, mv_sub::CONFIG_SOURCE, &args)
2250    }
2251
2252    /// Asks the multiviewer to route its sources itself.
2253    pub fn multiviewer_auto_route(&self, device: DeviceUid) -> Result<(), ControlError> {
2254        self.multiviewer(device, mv_sub::AUTO_ROUTE, &[])
2255    }
2256
2257    fn multiviewer(&self, device: DeviceUid, sub: u8, args: &[u8]) -> Result<(), ControlError> {
2258        self.shared
2259            .command(|state| Ok(mv_command(multiviewer_of(state, device)?, sub, args)))
2260    }
2261}