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