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
16use std::fmt;
17use std::net::Ipv4Addr;
18
19use crate::event::Event;
20use crate::state::{Bay, Device, State};
21use crate::types::{
22    AmpZoneSettings, HiddenStatus, PowerStatus, V2ipAudioFormat, V2ipStreamSources,
23    VolumeMuteStatus,
24};
25use crate::wire::{
26    audio_cmd_header, audio_param, audio_sub, build_amp_zone_settings, build_audio_select_input,
27    build_bay_hide, build_edid_profile, build_rc_action, build_set_bay_name, build_set_volume,
28    build_stats_request, build_target_only, build_v2ip_manual_source_switch,
29    build_v2ip_source_switch, mv_cmd_payload, mv_sub, op, Addressee, BayUid, DeviceUid,
30    EdidProfile, MultiviewerAspectRatio, MultiviewerEdidTemplate, MultiviewerHdcpMode,
31    MultiviewerItcMode, MultiviewerOutputMode, MultiviewerPipPosition, MultiviewerPipSize,
32    MultiviewerSource, MultiviewerViewMode, Opcode, RcAction, SendError, StreamAddr, V2ipStreams,
33    DEVICE_NAME_LEN, V2IP_PORT_AUDIO,
34};
35
36use super::{Remote, Shared};
37
38/// Why a control method did nothing.
39#[derive(Debug)]
40#[non_exhaustive]
41pub enum ControlError {
42    /// No device with this identifier has been heard from.
43    UnknownDevice(DeviceUid),
44    /// The device has reported no bay on this port.
45    UnknownBay(BayUid),
46    /// No input bay on the device carries this user-assigned name.
47    UnknownSource(String),
48    /// The addressee does not do what was asked of it.
49    Unsupported(&'static str),
50    /// The device has not reported something the request is assembled from.
51    ///
52    /// Unlike [`ControlError::Unsupported`], the same call may succeed once it
53    /// has: this says the value is missing, not that it cannot exist.
54    NotReported(&'static str),
55    /// The frame could not be sent.
56    Send(SendError),
57}
58
59impl fmt::Display for ControlError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::UnknownDevice(uid) => write!(f, "no device {uid}"),
63            Self::UnknownBay(uid) => write!(f, "no bay {uid}"),
64            Self::UnknownSource(name) => write!(f, "no source named {name:?}"),
65            Self::Unsupported(what) => f.write_str(what),
66            Self::NotReported(what) => write!(f, "{what} has not been reported"),
67            Self::Send(e) => write!(f, "{e}"),
68        }
69    }
70}
71
72impl std::error::Error for ControlError {
73    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
74        match self {
75            Self::Send(e) => Some(e),
76            _ => None,
77        }
78    }
79}
80
81impl From<SendError> for ControlError {
82    fn from(e: SendError) -> Self {
83        Self::Send(e)
84    }
85}
86
87/// What a command does to this client's copy of the registry once its frame is
88/// away.
89///
90/// A device does not acknowledge a command, so without this a caller that read
91/// back what it just wrote would see the old value until some unrelated report
92/// happened to carry the new one.
93type WriteBack = Box<dyn FnOnce(&mut State, &mut Vec<Event>) + Send>;
94
95/// One command: the frame to send, and what the addressee will do with it.
96struct Command {
97    to: Addressee,
98    opcode: Opcode,
99    payload: Vec<u8>,
100    write_back: Option<WriteBack>,
101}
102
103impl Command {
104    fn new(to: Addressee, opcode: Opcode, payload: Vec<u8>) -> Self {
105        Self {
106            to,
107            opcode,
108            payload,
109            write_back: None,
110        }
111    }
112
113    /// Records what to apply locally once the frame is away.
114    fn then(mut self, f: impl FnOnce(&mut State, &mut Vec<Event>) + Send + 'static) -> Self {
115        self.write_back = Some(Box::new(f));
116        self
117    }
118}
119
120impl Shared {
121    /// Runs one command: prepare under the registry lock, send without it,
122    /// then write back.
123    fn command(
124        &self,
125        prepare: impl FnOnce(&State) -> Result<Command, ControlError>,
126    ) -> Result<(), ControlError> {
127        let command = self.read(prepare)?;
128        self.send(&command.to, command.opcode, &command.payload)?;
129        if let Some(write_back) = command.write_back {
130            self.mutate(|state, ev| write_back(state, ev));
131        }
132        Ok(())
133    }
134}
135
136fn device_of(state: &State, uid: DeviceUid) -> Result<&Device, ControlError> {
137    state.device(uid).ok_or(ControlError::UnknownDevice(uid))
138}
139
140fn bay_of(state: &State, uid: BayUid) -> Result<(&Device, &Bay), ControlError> {
141    let device = device_of(state, uid.device)?;
142    let bay = device.bay(uid.port).ok_or(ControlError::UnknownBay(uid))?;
143    Ok((device, bay))
144}
145
146/// The streams the source bay on `port` advertises.
147fn source_streams(device: &Device, port: u16) -> Result<&V2ipStreamSources, ControlError> {
148    let source = device
149        .bay(port)
150        .ok_or(ControlError::UnknownBay(BayUid::new(device.uid, port)))?;
151    device
152        .v2ip_source_for(source)
153        .ok_or(ControlError::NotReported("the source's stream addresses"))
154}
155
156/// A sink bay, or the reason it cannot be routed.
157fn v2ip_sink(state: &State, uid: BayUid) -> Result<(&Device, &Bay), ControlError> {
158    let (device, bay) = bay_of(state, uid)?;
159    if !bay.is_v2ip_sink() {
160        return Err(ControlError::Unsupported("routing needs a V2IP sink"));
161    }
162    Ok((device, bay))
163}
164
165/// The name as the device will store it: the field is
166/// [`DEVICE_NAME_LEN`] bytes wide, so a longer one is cut there.
167fn stored_name(name: &str) -> String {
168    let bytes = name.as_bytes();
169    String::from_utf8_lossy(bytes.get(..DEVICE_NAME_LEN).unwrap_or(bytes)).into_owned()
170}
171
172impl Remote {
173    // ---- routing ----
174
175    /// Routes this V2IP sink's video to the stream a source port advertises.
176    pub fn select_video_source(&self, sink: BayUid, source_port: u16) -> Result<(), ControlError> {
177        self.shared.command(|state| {
178            let (device, bay) = v2ip_sink(state, sink)?;
179            if !bay.is_output() {
180                return Err(ControlError::Unsupported("not an output bay"));
181            }
182            let streams = source_streams(device, source_port)?;
183            Ok(Command::new(
184                Addressee::device(device),
185                op::V2IP_SOURCE_SWITCH,
186                build_v2ip_source_switch(device.uid, streams.video.ip, Ipv4Addr::UNSPECIFIED),
187            ))
188        })
189    }
190
191    /// Routes this V2IP sink's audio to the stream a source port advertises.
192    pub fn select_audio_source(&self, sink: BayUid, source_port: u16) -> Result<(), ControlError> {
193        self.shared.command(|state| {
194            let (device, _) = v2ip_sink(state, sink)?;
195            let streams = source_streams(device, source_port)?;
196            Ok(Command::new(
197                Addressee::device(device),
198                op::V2IP_SOURCE_SWITCH,
199                build_v2ip_source_switch(device.uid, Ipv4Addr::UNSPECIFIED, streams.audio.ip),
200            ))
201        })
202    }
203
204    /// Routes this V2IP sink's video to the input bay with the given
205    /// user-assigned name.
206    pub fn select_video_source_by_name(
207        &self,
208        sink: BayUid,
209        name: &str,
210    ) -> Result<(), ControlError> {
211        self.select_video_source(sink, self.source_port(sink, name)?)
212    }
213
214    /// Routes this V2IP sink's audio to a multicast address directly, leaving
215    /// its video and ancillary streams alone.
216    ///
217    /// An unset port is the standard V2IP audio port. A format overrides the
218    /// sample rate and channel count the receiver would otherwise assume.
219    pub fn select_audio_source_addr(
220        &self,
221        sink: BayUid,
222        audio_ip: Ipv4Addr,
223        audio_port: Option<u16>,
224        format: Option<V2ipAudioFormat>,
225    ) -> Result<(), ControlError> {
226        self.shared.command(move |state| {
227            let (device, _) = v2ip_sink(state, sink)?;
228            let streams = V2ipStreams {
229                audio: StreamAddr {
230                    ip: audio_ip,
231                    port: audio_port.unwrap_or(V2IP_PORT_AUDIO),
232                },
233                ..V2ipStreams::default()
234            };
235            Ok(Command::new(
236                Addressee::device(device),
237                op::V2IP_MANUAL_SRC_SWITCH,
238                build_v2ip_manual_source_switch(device.uid, streams, format),
239            ))
240        })
241    }
242
243    /// Routes this V2IP sink's audio from the input bay with the given
244    /// user-assigned name.
245    ///
246    /// A format is carried on the manual switch frame, which is the only form
247    /// that can override the receiver's sample rate and channel count.
248    pub fn select_audio_source_by_name(
249        &self,
250        sink: BayUid,
251        name: &str,
252        format: Option<V2ipAudioFormat>,
253    ) -> Result<(), ControlError> {
254        let port = self.source_port(sink, name)?;
255        let Some(format) = format else {
256            return self.select_audio_source(sink, port);
257        };
258        self.shared.command(move |state| {
259            let (device, _) = v2ip_sink(state, sink)?;
260            let audio = source_streams(device, port)?.audio;
261            let streams = V2ipStreams {
262                audio: StreamAddr {
263                    ip: audio.ip,
264                    port: audio.port,
265                },
266                ..V2ipStreams::default()
267            };
268            Ok(Command::new(
269                Addressee::device(device),
270                op::V2IP_MANUAL_SRC_SWITCH,
271                build_v2ip_manual_source_switch(device.uid, streams, Some(format)),
272            ))
273        })
274    }
275
276    /// The port of the input bay on `sink`'s device carrying `name`.
277    fn source_port(&self, sink: BayUid, name: &str) -> Result<u16, ControlError> {
278        self.shared.read(|state| {
279            let (device, _) = bay_of(state, sink)?;
280            device
281                .bay_by_user_name(name)
282                .map(|b| b.port)
283                .ok_or_else(|| ControlError::UnknownSource(name.to_owned()))
284        })
285    }
286
287    // ---- bay settings ----
288
289    /// Renames a bay.
290    pub fn set_bay_name(&self, bay: BayUid, name: &str) -> Result<(), ControlError> {
291        let name = stored_name(name);
292        self.shared.command(move |state| {
293            let (device, _) = bay_of(state, bay)?;
294            let payload = build_set_bay_name(device.uid, bay.port, &name);
295            Ok(
296                Command::new(Addressee::device(device), op::CHANGE_BAY_NAME, payload).then(
297                    move |state, ev| {
298                        if let Some(b) = state.bay_mut(bay) {
299                            b.set_user_name(name, ev);
300                        }
301                    },
302                ),
303            )
304        })
305    }
306
307    /// Hides a bay from the pickers that list it, or shows it again.
308    pub fn set_bay_hidden(&self, bay: BayUid, hidden: bool) -> Result<(), ControlError> {
309        self.shared.command(move |state| {
310            let (device, _) = bay_of(state, bay)?;
311            Ok(Command::new(
312                Addressee::device(device),
313                op::BAY_HIDE,
314                build_bay_hide(device.uid, bay.port, hidden),
315            )
316            .then(move |state, ev| {
317                if let Some(b) = state.bay_mut(bay) {
318                    let status = if hidden {
319                        HiddenStatus::Hidden
320                    } else {
321                        HiddenStatus::Visible
322                    };
323                    b.apply_hidden(status, ev);
324                }
325            }))
326        })
327    }
328
329    /// Sets the EDID profile an input presents to the source attached to it.
330    pub fn select_edid_profile(
331        &self,
332        bay: BayUid,
333        profile: EdidProfile,
334    ) -> Result<(), ControlError> {
335        self.shared.command(move |state| {
336            let (device, _) = bay_of(state, bay)?;
337            Ok(Command::new(
338                Addressee::device(device),
339                op::BAY_EDID_PROFILE,
340                build_edid_profile(device.uid, profile),
341            )
342            .then(move |state, ev| {
343                if let Some(b) = state.bay_mut(bay) {
344                    b.set_edid_profile(profile, ev);
345                }
346            }))
347        })
348    }
349
350    /// Sends a remote-control action to whatever is attached to a bay.
351    pub fn send_action(&self, bay: BayUid, action: RcAction) -> Result<(), ControlError> {
352        self.shared.command(move |state| {
353            let (device, _) = bay_of(state, bay)?;
354            Ok(Command::new(
355                Addressee::device(device),
356                op::RC_TX_ACTION,
357                build_rc_action(device.uid, bay.port, action),
358            ))
359        })
360    }
361
362    /// Powers on the device attached to a bay.
363    pub fn power_on(&self, bay: BayUid) -> Result<(), ControlError> {
364        self.set_power(bay, RcAction::POWER_ON, PowerStatus::On)
365    }
366
367    /// Powers off the device attached to a bay.
368    pub fn power_off(&self, bay: BayUid) -> Result<(), ControlError> {
369        self.set_power(bay, RcAction::POWER_OFF, PowerStatus::Off)
370    }
371
372    fn set_power(
373        &self,
374        bay: BayUid,
375        action: RcAction,
376        power: PowerStatus,
377    ) -> Result<(), ControlError> {
378        self.shared.command(move |state| {
379            let (device, _) = bay_of(state, bay)?;
380            Ok(Command::new(
381                Addressee::device(device),
382                op::RC_TX_ACTION,
383                build_rc_action(device.uid, bay.port, action),
384            )
385            .then(move |state, ev| {
386                if let Some(b) = state.bay_mut(bay) {
387                    b.set_power_status(power, ev);
388                }
389            }))
390        })
391    }
392
393    /// Sets a bay's volume, as a percentage, and optionally its mute state.
394    ///
395    /// Both channels are set together: the wire carries them separately, but
396    /// nothing on this surface splits them.
397    ///
398    /// A bay with no volume control of its own is set through its
399    /// [`linked_bay`](crate::BayInfo::linked_bay), so an output wired to an
400    /// amplifier zone reaches that zone. [`volume_up`](Self::volume_up),
401    /// [`volume_down`](Self::volume_down) and [`set_muted`](Self::set_muted)
402    /// follow the same link, and read the volume they step from through it.
403    pub fn set_volume(
404        &self,
405        bay: BayUid,
406        volume: u8,
407        muted: Option<bool>,
408    ) -> Result<(), ControlError> {
409        let volume = volume.min(100);
410        let wanted = VolumeMuteStatus {
411            volume_left: Some(volume),
412            volume_right: Some(volume),
413            muted_left: muted,
414            muted_right: muted,
415        };
416        self.shared.command(move |state| {
417            // The mesh may put this bay's volume control on another device, and
418            // the command belongs where the volume lives, not where it was
419            // addressed.
420            let target = state.volume_bay(bay);
421            let (device, b) = bay_of(state, target)?;
422            if !b.has_volume_control() {
423                return Err(ControlError::Unsupported("the bay has no volume control"));
424            }
425            Ok(Command::new(
426                Addressee::device(device),
427                op::AUDIO_SET_VOLUME,
428                build_set_volume(device.uid, target.port, wanted),
429            )
430            .then(move |state, ev| {
431                if let Some(device) = state.device_mut(target.device) {
432                    device.apply_bay_volume(target.port, wanted, ev);
433                }
434            }))
435        })
436    }
437
438    /// Raises a bay's volume by one percent.
439    pub fn volume_up(&self, bay: BayUid) -> Result<(), ControlError> {
440        self.set_volume(bay, self.current_volume(bay)?.saturating_add(1), None)
441    }
442
443    /// Lowers a bay's volume by one percent.
444    pub fn volume_down(&self, bay: BayUid) -> Result<(), ControlError> {
445        self.set_volume(bay, self.current_volume(bay)?.saturating_sub(1), None)
446    }
447
448    /// Mutes or unmutes a bay, keeping the volume it is set to.
449    pub fn set_muted(&self, bay: BayUid, muted: bool) -> Result<(), ControlError> {
450        self.set_volume(bay, self.current_volume(bay)?, Some(muted))
451    }
452
453    /// The volume a step or a mute is relative to.
454    fn current_volume(&self, bay: BayUid) -> Result<u8, ControlError> {
455        self.shared.read(|state| {
456            let (_, b) = bay_of(state, state.volume_bay(bay))?;
457            b.audio_volume
458                .map(|v| v.volume())
459                .ok_or(ControlError::NotReported("the bay's volume"))
460        })
461    }
462
463    /// Applies amplifier settings to a zone.
464    pub fn set_amp_zone_settings(
465        &self,
466        bay: BayUid,
467        settings: AmpZoneSettings,
468    ) -> Result<(), ControlError> {
469        self.shared.command(move |state| {
470            let (device, _) = bay_of(state, bay)?;
471            Ok(Command::new(
472                Addressee::device(device),
473                op::AMP_ZONE_SETTINGS,
474                build_amp_zone_settings(device.uid, bay.port, &settings),
475            )
476            .then(move |state, ev| {
477                if let Some(b) = state.bay_mut(bay) {
478                    b.set_amp_settings(settings, ev);
479                }
480            }))
481        })
482    }
483
484    // ---- audio endpoints ----
485
486    /// Mutes or unmutes an audio endpoint.
487    pub fn set_audio_endpoint_muted(
488        &self,
489        device: DeviceUid,
490        endpoint: u16,
491        muted: bool,
492    ) -> Result<(), ControlError> {
493        self.audio_endpoint(device, audio_sub::MUTE, endpoint, u32::from(muted))
494    }
495
496    /// Sets an audio endpoint's trigger output.
497    pub fn set_audio_endpoint_trigger(
498        &self,
499        device: DeviceUid,
500        endpoint: u16,
501        active: bool,
502    ) -> Result<(), ControlError> {
503        self.audio_endpoint(device, audio_sub::TRIGGER, endpoint, u32::from(active))
504    }
505
506    /// Sets an audio endpoint's volume.
507    pub fn set_audio_endpoint_volume(
508        &self,
509        device: DeviceUid,
510        endpoint: u16,
511        volume: u32,
512    ) -> Result<(), ControlError> {
513        self.audio_endpoint(device, audio_sub::VOLUME, endpoint, volume)
514    }
515
516    fn audio_endpoint(
517        &self,
518        device: DeviceUid,
519        sub: u16,
520        endpoint: u16,
521        value: u32,
522    ) -> Result<(), ControlError> {
523        self.shared.command(move |state| {
524            let device = device_of(state, device)?;
525            let mut payload = audio_cmd_header(sub, device.uid);
526            payload.extend_from_slice(&audio_param(endpoint, value));
527            Ok(Command::new(
528                Addressee::device(device),
529                op::V2IP_AUDIO,
530                payload,
531            ))
532        })
533    }
534
535    /// Routes a source endpoint on one device to a sink endpoint on another.
536    pub fn select_audio_endpoint_input(
537        &self,
538        sink: DeviceUid,
539        sink_endpoint: u16,
540        source: DeviceUid,
541        source_endpoint: u16,
542    ) -> Result<(), ControlError> {
543        self.shared.command(move |state| {
544            let device = device_of(state, sink)?;
545            Ok(Command::new(
546                Addressee::device(device),
547                op::V2IP_AUDIO,
548                build_audio_select_input(sink, sink_endpoint, source, source_endpoint),
549            ))
550        })
551    }
552
553    // ---- the whole device ----
554
555    /// Starts or stops a V2IP device reporting its transport statistics.
556    pub fn subscribe_v2ip_stats(
557        &self,
558        device: DeviceUid,
559        subscribe: bool,
560    ) -> Result<(), ControlError> {
561        self.shared.command(move |state| {
562            let device = device_of(state, device)?;
563            Ok(Command::new(
564                Addressee::device(device),
565                op::V2IP_STATS,
566                build_stats_request(device.uid, subscribe),
567            ))
568        })
569    }
570
571    /// Reboots a device.
572    ///
573    /// The device is marked as rebooting once the frame is away, so the
574    /// silence that follows does not read as one that went offline.
575    pub fn reboot(&self, device: DeviceUid) -> Result<(), ControlError> {
576        self.shared.command(move |state| {
577            let d = device_of(state, device)?;
578            Ok(Command::new(
579                Addressee::device(d),
580                op::SYS_REBOOT,
581                build_target_only(d.uid),
582            )
583            .then(move |state, _| {
584                if let Some(d) = state.device_mut(device) {
585                    d.rebooting = true;
586                }
587            }))
588        })
589    }
590
591    /// Asks every peer to report its monitoring data now rather than on its own
592    /// schedule.
593    pub fn send_monitoring_pulse(&self) -> Result<(), ControlError> {
594        self.shared
595            .send(&Addressee::Broadcast, op::SYS_MONITORING_PULSE, &[])?;
596        Ok(())
597    }
598
599    // ---- multiviewer ----
600
601    /// Sets the window layout.
602    pub fn set_multiviewer_view_mode(
603        &self,
604        device: DeviceUid,
605        mode: MultiviewerViewMode,
606    ) -> Result<(), ControlError> {
607        self.multiviewer(device, mv_sub::VIEW_MODE, &[mode.to_wire()])
608    }
609
610    /// Assigns a source to one window.
611    pub fn set_multiviewer_video_source(
612        &self,
613        device: DeviceUid,
614        screen: u8,
615        source: MultiviewerSource,
616    ) -> Result<(), ControlError> {
617        self.multiviewer(device, mv_sub::VIDEO_SOURCE, &[screen, source.to_wire()])
618    }
619
620    /// Selects which window's audio is output.
621    pub fn set_multiviewer_audio_source(
622        &self,
623        device: DeviceUid,
624        source: MultiviewerSource,
625    ) -> Result<(), ControlError> {
626        // The status report numbers the windows from one and this command from
627        // zero.
628        self.multiviewer(
629            device,
630            mv_sub::AUDIO_SOURCE,
631            &[source.to_wire().saturating_sub(1)],
632        )
633    }
634
635    /// Sets the output volume and mute state.
636    pub fn set_multiviewer_audio_volume(
637        &self,
638        device: DeviceUid,
639        volume: u8,
640        muted: bool,
641    ) -> Result<(), ControlError> {
642        self.multiviewer(device, mv_sub::AUDIO_VOLUME, &[volume, u8::from(muted)])
643    }
644
645    /// Sets the EDID template presented to the sources.
646    pub fn set_multiviewer_edid_template(
647        &self,
648        device: DeviceUid,
649        template: MultiviewerEdidTemplate,
650    ) -> Result<(), ControlError> {
651        self.multiviewer(device, mv_sub::EDID_TEMPLATE, &[template.to_wire()])
652    }
653
654    /// Selects which window receives remote-control passthrough.
655    pub fn set_multiviewer_remote_control(
656        &self,
657        device: DeviceUid,
658        source: MultiviewerSource,
659    ) -> Result<(), ControlError> {
660        // Numbered from zero here, as on
661        // [`Remote::set_multiviewer_audio_source`].
662        self.multiviewer(
663            device,
664            mv_sub::ROUTE_RC,
665            &[source.to_wire().saturating_sub(1)],
666        )
667    }
668
669    /// Sets how large the picture-in-picture window is.
670    pub fn set_multiviewer_pip_size(
671        &self,
672        device: DeviceUid,
673        size: MultiviewerPipSize,
674    ) -> Result<(), ControlError> {
675        self.multiviewer(device, mv_sub::PIP_SIZE, &[size.to_wire()])
676    }
677
678    /// Sets which corner the picture-in-picture window sits in.
679    pub fn set_multiviewer_pip_position(
680        &self,
681        device: DeviceUid,
682        position: MultiviewerPipPosition,
683    ) -> Result<(), ControlError> {
684        self.multiviewer(device, mv_sub::PIP_POSITION, &[position.to_wire()])
685    }
686
687    /// Sets the aspect ratio the windows are scaled to.
688    pub fn set_multiviewer_aspect_ratio(
689        &self,
690        device: DeviceUid,
691        aspect: MultiviewerAspectRatio,
692    ) -> Result<(), ControlError> {
693        self.multiviewer(device, mv_sub::ASPECT, &[aspect.to_wire()])
694    }
695
696    /// Enables or disables switching windows on its own.
697    pub fn set_multiviewer_auto_switch(
698        &self,
699        device: DeviceUid,
700        enable: bool,
701    ) -> Result<(), ControlError> {
702        self.multiviewer(device, mv_sub::AUTO_SWITCH, &[u8::from(enable)])
703    }
704
705    /// Sets the output resolution and refresh rate.
706    pub fn set_multiviewer_output_mode(
707        &self,
708        device: DeviceUid,
709        mode: MultiviewerOutputMode,
710    ) -> Result<(), ControlError> {
711        self.multiviewer(device, mv_sub::OUTPUT_MODE, &[mode.to_wire()])
712    }
713
714    /// Sets the IT-content flag on the output.
715    pub fn set_multiviewer_output_itc(
716        &self,
717        device: DeviceUid,
718        mode: MultiviewerItcMode,
719    ) -> Result<(), ControlError> {
720        self.multiviewer(device, mv_sub::OUTPUT_ITC_MODE, &[mode.to_wire()])
721    }
722
723    /// Sets the HDCP version negotiated on the output.
724    pub fn set_multiviewer_hdcp_mode(
725        &self,
726        device: DeviceUid,
727        mode: MultiviewerHdcpMode,
728    ) -> Result<(), ControlError> {
729        self.multiviewer(device, mv_sub::HDCP_MODE, &[mode.to_wire()])
730    }
731
732    /// Maps a source device onto one of the multiviewer's inputs.
733    ///
734    /// The zero identifier clears the mapping.
735    pub fn set_multiviewer_input_source(
736        &self,
737        device: DeviceUid,
738        input: u8,
739        source: DeviceUid,
740    ) -> Result<(), ControlError> {
741        let mut args = Vec::with_capacity(24);
742        args.extend_from_slice(source.as_bytes());
743        args.push(input);
744        // mv_config_source_t is 4-aligned behind its uid, so seven bytes of
745        // padding follow the input index.
746        args.extend_from_slice(&[0; 7]);
747        self.multiviewer(device, mv_sub::CONFIG_SOURCE, &args)
748    }
749
750    /// Asks the multiviewer to route its sources itself.
751    pub fn multiviewer_auto_route(&self, device: DeviceUid) -> Result<(), ControlError> {
752        self.multiviewer(device, mv_sub::AUTO_ROUTE, &[])
753    }
754
755    fn multiviewer(&self, device: DeviceUid, sub: u8, args: &[u8]) -> Result<(), ControlError> {
756        self.shared.command(|state| {
757            let device = device_of(state, device)?;
758            if !device.is_multiviewer() {
759                return Err(ControlError::Unsupported("the device is not a multiviewer"));
760            }
761            Ok(Command::new(
762                Addressee::device(device),
763                op::V2IP_MULTIVIEWER,
764                mv_cmd_payload(device.uid, sub, args),
765            ))
766        })
767    }
768}