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;
29
30use crate::event::Event;
31use crate::state::{Bay, Device, State};
32use crate::types::{
33 AmpZoneSettings, HiddenStatus, MultiviewerStatus, PowerStatus, V2ipAudioFormat, V2ipOutputMode,
34 V2ipRoute, V2ipRouteTarget, V2ipScalingSettings, V2ipStreamSources, VideoWallOp,
35 VideoWallWindow, VolumeMuteStatus, MULTIVIEWER_INPUTS, SCALING_FLAG_AUTO_SCALING,
36 SCALING_FLAG_MODE_VALID, SCALING_FLAG_OPTIONS_VALID, VIDEO_WALL_CLEARED,
37};
38use crate::wire::{
39 audio_cmd_header, audio_param, audio_sub, build_amp_zone_settings, build_audio_select_input,
40 build_bay_hide, build_edid_profile, build_edid_request, build_rc_action, build_rc_key,
41 build_set_bay_name, build_set_volume, build_stats_request, build_target_only,
42 build_v2ip_manual_source_switch, build_v2ip_scaling, build_v2ip_source_switch,
43 build_video_wall, mv_cmd_payload, mv_sub, op, Addressee, BayUid, DeviceUid, EdidProfile,
44 MultiviewerAspectRatio, MultiviewerEdidTemplate, MultiviewerHdcpMode, MultiviewerItcMode,
45 MultiviewerOutputMode, MultiviewerPipPosition, MultiviewerPipSize, MultiviewerSource,
46 MultiviewerViewMode, MxrSignalType, Opcode, RcAction, RcKey, SendError, StreamAddr,
47 V2ipStreams, DEVICE_NAME_LEN, V2IP_PORT_ANC, V2IP_PORT_AUDIO, V2IP_PORT_VIDEO,
48};
49
50use super::{Remote, Shared};
51
52/// Why a control method did nothing.
53#[derive(Debug)]
54#[non_exhaustive]
55pub enum ControlError {
56 /// No device with this identifier has been heard from.
57 UnknownDevice(DeviceUid),
58 /// The device has reported no bay on this port.
59 UnknownBay(BayUid),
60 /// No input bay on the device carries this user-assigned name.
61 UnknownSource(String),
62 /// The addressee does not do what was asked of it.
63 Unsupported(&'static str),
64 /// The request breaks a rule the device is not guaranteed to check.
65 ///
66 /// Nothing was sent. This is the caller's to fix, and it is separate from
67 /// [`ControlError::Unsupported`] because the device would have taken the
68 /// frame: refusing here is this library declining to let a bad value
69 /// reach hardware that may store it rather than reject it.
70 InvalidRequest(&'static str),
71 /// The device has not reported something the request is assembled from.
72 ///
73 /// Unlike [`ControlError::Unsupported`], the same call may succeed once it
74 /// has: this says the value is missing, not that it cannot exist.
75 NotReported(&'static str),
76 /// The frame could not be sent.
77 Send(SendError),
78}
79
80impl fmt::Display for ControlError {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 match self {
83 Self::UnknownDevice(uid) => write!(f, "no device {uid}"),
84 Self::UnknownBay(uid) => write!(f, "no bay {uid}"),
85 Self::UnknownSource(name) => write!(f, "no source named {name:?}"),
86 Self::Unsupported(what) => f.write_str(what),
87 Self::InvalidRequest(what) => f.write_str(what),
88 Self::NotReported(what) => write!(f, "{what} has not been reported"),
89 Self::Send(e) => write!(f, "{e}"),
90 }
91 }
92}
93
94impl std::error::Error for ControlError {
95 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
96 match self {
97 Self::Send(e) => Some(e),
98 _ => None,
99 }
100 }
101}
102
103impl From<SendError> for ControlError {
104 fn from(e: SendError) -> Self {
105 Self::Send(e)
106 }
107}
108
109/// What a command does to this client's copy of the registry once its frame is
110/// away.
111///
112/// A device does not acknowledge a command, so without this a caller that read
113/// back what it just wrote would see the old value until some unrelated report
114/// happened to carry the new one.
115type WriteBack = Box<dyn FnOnce(&mut State, &mut Vec<Event>) + Send>;
116
117/// One command: the frame to send, and what the addressee will do with it.
118struct Command {
119 to: Addressee,
120 opcode: Opcode,
121 payload: Vec<u8>,
122 write_back: Option<WriteBack>,
123}
124
125impl Command {
126 fn new(to: Addressee, opcode: Opcode, payload: Vec<u8>) -> Self {
127 Self {
128 to,
129 opcode,
130 payload,
131 write_back: None,
132 }
133 }
134
135 /// Records what to apply locally once the frame is away.
136 fn then(mut self, f: impl FnOnce(&mut State, &mut Vec<Event>) + Send + 'static) -> Self {
137 self.write_back = Some(Box::new(f));
138 self
139 }
140}
141
142impl Shared {
143 /// Runs one command: prepare under the registry lock, send without it,
144 /// then write back.
145 fn command(
146 &self,
147 prepare: impl FnOnce(&State) -> Result<Command, ControlError>,
148 ) -> Result<(), ControlError> {
149 let command = self.read(prepare)?;
150 self.send(&command.to, command.opcode, &command.payload)?;
151 if let Some(write_back) = command.write_back {
152 self.mutate(|state, ev| write_back(state, ev));
153 }
154 Ok(())
155 }
156}
157
158fn device_of(state: &State, uid: DeviceUid) -> Result<&Device, ControlError> {
159 state.device(uid).ok_or(ControlError::UnknownDevice(uid))
160}
161
162/// The device behind `uid`, once it is known to be a multiviewer.
163fn multiviewer_of(state: &State, uid: DeviceUid) -> Result<&Device, ControlError> {
164 let device = device_of(state, uid)?;
165 if !device.is_multiviewer() {
166 return Err(ControlError::Unsupported("the device is not a multiviewer"));
167 }
168 Ok(device)
169}
170
171/// Wraps one multiviewer sub-command in the envelope every one of them shares.
172fn mv_command(device: &Device, sub: u8, args: &[u8]) -> Command {
173 Command::new(
174 Addressee::device(device),
175 op::V2IP_MULTIVIEWER,
176 mv_cmd_payload(device.uid, sub, args),
177 )
178}
179
180/// The zero-based input a source names, refused when it names none.
181///
182/// A multiviewer reads zero as its first input, so there is no value that says
183/// "no input": a source that names none would arrive as a request to switch to
184/// input 1.
185fn source_index(source: MultiviewerSource, what: &'static str) -> Result<u8, ControlError> {
186 source
187 .to_zero_based()
188 .ok_or(ControlError::InvalidRequest(what))
189}
190
191/// A multiviewer setting within the range its firmware accepts.
192///
193/// Every one of these settings is numbered from one, with zero reserved for
194/// "the device has reported nothing". The device drops a value it does not
195/// know without answering, so a caller sending one would see a send succeed
196/// and the setting stay as it was; this is what turns that into an error.
197fn mv_setting(value: u8, highest: u8, what: &'static str) -> Result<u8, ControlError> {
198 if (1..=highest).contains(&value) {
199 Ok(value)
200 } else {
201 Err(ControlError::InvalidRequest(what))
202 }
203}
204
205fn bay_of(state: &State, uid: BayUid) -> Result<(&Device, &Bay), ControlError> {
206 let device = device_of(state, uid.device)?;
207 let bay = device.bay(uid.port).ok_or(ControlError::UnknownBay(uid))?;
208 Ok((device, bay))
209}
210
211/// The streams the source bay on `port` advertises.
212fn source_streams(device: &Device, port: u16) -> Result<&V2ipStreamSources, ControlError> {
213 let source = device
214 .bay(port)
215 .ok_or(ControlError::UnknownBay(BayUid::new(device.uid, port)))?;
216 device
217 .v2ip_source_for(source)
218 .ok_or(ControlError::NotReported("the source's stream addresses"))
219}
220
221/// A sink bay, or the reason it cannot be routed.
222fn v2ip_sink(state: &State, uid: BayUid) -> Result<(&Device, &Bay), ControlError> {
223 let (device, bay) = bay_of(state, uid)?;
224 if !bay.is_v2ip_sink() {
225 return Err(ControlError::Unsupported("routing needs a V2IP sink"));
226 }
227 Ok((device, bay))
228}
229
230/// One route slot as the wire carries it, substituting the stream's standard
231/// port for an unset one.
232///
233/// An unset address sends the slot zeroed, port included: the firmware reads
234/// the pair together, and a port beside 0.0.0.0 describes nothing.
235fn stream_addr(target: V2ipRouteTarget, standard_port: u16) -> StreamAddr {
236 if target.ip.is_unspecified() {
237 return StreamAddr::default();
238 }
239 StreamAddr {
240 ip: target.ip,
241 port: target.port_or(standard_port),
242 }
243}
244
245/// The name as the device will store it: the field is
246/// [`DEVICE_NAME_LEN`] bytes wide, so a longer one is cut there.
247fn stored_name(name: &str) -> String {
248 let bytes = name.as_bytes();
249 String::from_utf8_lossy(bytes.get(..DEVICE_NAME_LEN).unwrap_or(bytes)).into_owned()
250}
251
252impl Remote {
253 // ---- routing ----
254
255 /// Routes this V2IP sink's video to the stream a source port advertises.
256 pub fn select_video_source(&self, sink: BayUid, source_port: u16) -> Result<(), ControlError> {
257 self.shared.command(|state| {
258 let (device, bay) = v2ip_sink(state, sink)?;
259 if !bay.is_output() {
260 return Err(ControlError::Unsupported("not an output bay"));
261 }
262 let streams = source_streams(device, source_port)?;
263 Ok(Command::new(
264 Addressee::device(device),
265 op::V2IP_SOURCE_SWITCH,
266 build_v2ip_source_switch(device.uid, streams.video.ip, Ipv4Addr::UNSPECIFIED),
267 ))
268 })
269 }
270
271 /// Routes this V2IP sink's audio to the stream a source port advertises.
272 pub fn select_audio_source(&self, sink: BayUid, source_port: u16) -> Result<(), ControlError> {
273 self.shared.command(|state| {
274 let (device, _) = v2ip_sink(state, sink)?;
275 let streams = source_streams(device, source_port)?;
276 Ok(Command::new(
277 Addressee::device(device),
278 op::V2IP_SOURCE_SWITCH,
279 build_v2ip_source_switch(device.uid, Ipv4Addr::UNSPECIFIED, streams.audio.ip),
280 ))
281 })
282 }
283
284 /// Routes this V2IP sink's video to the input bay with the given
285 /// user-assigned name.
286 pub fn select_video_source_by_name(
287 &self,
288 sink: BayUid,
289 name: &str,
290 ) -> Result<(), ControlError> {
291 self.select_video_source(sink, self.source_port(sink, name)?)
292 }
293
294 /// Routes this V2IP sink's audio to a multicast address directly, leaving
295 /// its video and ancillary streams alone.
296 ///
297 /// An unset port is the standard V2IP audio port. A format overrides the
298 /// sample rate and channel count the receiver would otherwise assume.
299 pub fn select_audio_source_addr(
300 &self,
301 sink: BayUid,
302 audio_ip: Ipv4Addr,
303 audio_port: Option<u16>,
304 format: Option<V2ipAudioFormat>,
305 ) -> Result<(), ControlError> {
306 self.shared.command(move |state| {
307 let (device, _) = v2ip_sink(state, sink)?;
308 let streams = V2ipStreams {
309 audio: StreamAddr {
310 ip: audio_ip,
311 port: audio_port.unwrap_or(V2IP_PORT_AUDIO),
312 },
313 ..V2ipStreams::default()
314 };
315 Ok(Command::new(
316 Addressee::device(device),
317 op::V2IP_MANUAL_SRC_SWITCH,
318 build_v2ip_manual_source_switch(device.uid, streams, format),
319 ))
320 })
321 }
322
323 /// Routes this V2IP sink's video, audio and ancillary streams to
324 /// multicast groups the caller names.
325 ///
326 /// This is the only way to reach a stream no device on the mesh
327 /// advertises, such as one the host is transmitting itself; a route by
328 /// source port can only name a stream some bay has announced.
329 ///
330 /// Set all three groups. The firmware decides whether a sink has a manual
331 /// route by looking at the video and ancillary groups, so a route that
332 /// leaves either unset does not register as one and the sink falls back to
333 /// the audio source its mesh picks.
334 ///
335 /// An unset `format` sends [`V2ipAudioFormat::STANDARD`] rather than
336 /// omitting the trailer. The firmware stores whatever this frame carries
337 /// and hands it to the FPGA unexamined, so a frame without one leaves a
338 /// zero rate and zero channel count there, which the FPGA rejects and
339 /// which takes the switch down with it.
340 pub fn select_source_addr(
341 &self,
342 sink: BayUid,
343 route: V2ipRoute,
344 format: Option<V2ipAudioFormat>,
345 ) -> Result<(), ControlError> {
346 let streams = V2ipStreams {
347 video: stream_addr(route.video, V2IP_PORT_VIDEO),
348 audio: stream_addr(route.audio, V2IP_PORT_AUDIO),
349 anc: stream_addr(route.anc, V2IP_PORT_ANC),
350 };
351 let format = format.unwrap_or(V2ipAudioFormat::STANDARD);
352 self.shared.command(move |state| {
353 let (device, _) = v2ip_sink(state, sink)?;
354 Ok(Command::new(
355 Addressee::device(device),
356 op::V2IP_MANUAL_SRC_SWITCH,
357 build_v2ip_manual_source_switch(device.uid, streams, Some(format)),
358 ))
359 })
360 }
361
362 /// Routes this V2IP sink's audio from the input bay with the given
363 /// user-assigned name.
364 ///
365 /// A format is carried on the manual switch frame, which is the only form
366 /// that can override the receiver's sample rate and channel count.
367 pub fn select_audio_source_by_name(
368 &self,
369 sink: BayUid,
370 name: &str,
371 format: Option<V2ipAudioFormat>,
372 ) -> Result<(), ControlError> {
373 let port = self.source_port(sink, name)?;
374 let Some(format) = format else {
375 return self.select_audio_source(sink, port);
376 };
377 self.shared.command(move |state| {
378 let (device, _) = v2ip_sink(state, sink)?;
379 let audio = source_streams(device, port)?.audio;
380 let streams = V2ipStreams {
381 audio: StreamAddr {
382 ip: audio.ip,
383 port: audio.port,
384 },
385 ..V2ipStreams::default()
386 };
387 Ok(Command::new(
388 Addressee::device(device),
389 op::V2IP_MANUAL_SRC_SWITCH,
390 build_v2ip_manual_source_switch(device.uid, streams, Some(format)),
391 ))
392 })
393 }
394
395 /// The port of the input bay on `sink`'s device carrying `name`.
396 fn source_port(&self, sink: BayUid, name: &str) -> Result<u16, ControlError> {
397 self.shared.read(|state| {
398 let (device, _) = bay_of(state, sink)?;
399 device
400 .bay_by_user_name(name)
401 .map(|b| b.port)
402 .ok_or_else(|| ControlError::UnknownSource(name.to_owned()))
403 })
404 }
405
406 // ---- bay settings ----
407
408 /// Renames a bay.
409 pub fn set_bay_name(&self, bay: BayUid, name: &str) -> Result<(), ControlError> {
410 let name = stored_name(name);
411 self.shared.command(move |state| {
412 let (device, _) = bay_of(state, bay)?;
413 let payload = build_set_bay_name(device.uid, bay.port, &name);
414 Ok(
415 Command::new(Addressee::device(device), op::CHANGE_BAY_NAME, payload).then(
416 move |state, ev| {
417 if let Some(b) = state.bay_mut(bay) {
418 b.set_user_name(name, ev);
419 }
420 },
421 ),
422 )
423 })
424 }
425
426 /// Hides a bay from the pickers that list it, or shows it again.
427 pub fn set_bay_hidden(&self, bay: BayUid, hidden: bool) -> Result<(), ControlError> {
428 self.shared.command(move |state| {
429 let (device, _) = bay_of(state, bay)?;
430 Ok(Command::new(
431 Addressee::device(device),
432 op::BAY_HIDE,
433 build_bay_hide(device.uid, bay.port, hidden),
434 )
435 .then(move |state, ev| {
436 if let Some(b) = state.bay_mut(bay) {
437 let status = if hidden {
438 HiddenStatus::Hidden
439 } else {
440 HiddenStatus::Visible
441 };
442 b.apply_hidden(status, ev);
443 }
444 }))
445 })
446 }
447
448 /// Sets the EDID profile an input presents to the source attached to it.
449 pub fn select_edid_profile(
450 &self,
451 bay: BayUid,
452 profile: EdidProfile,
453 ) -> Result<(), ControlError> {
454 self.shared.command(move |state| {
455 let (device, _) = bay_of(state, bay)?;
456 Ok(Command::new(
457 Addressee::device(device),
458 op::BAY_EDID_PROFILE,
459 build_edid_profile(device.uid, profile),
460 )
461 .then(move |state, ev| {
462 if let Some(b) = state.bay_mut(bay) {
463 b.set_edid_profile(profile, ev);
464 }
465 }))
466 })
467 }
468
469 /// Sends a remote-control action to whatever is attached to a bay.
470 pub fn send_action(&self, bay: BayUid, action: RcAction) -> Result<(), ControlError> {
471 self.shared.command(move |state| {
472 let (device, _) = bay_of(state, bay)?;
473 Ok(Command::new(
474 Addressee::device(device),
475 op::RC_TX_ACTION,
476 build_rc_action(device.uid, bay.port, action),
477 ))
478 })
479 }
480
481 /// Sends a remote-control key press to whatever is attached to a bay.
482 ///
483 /// The device forwards it over CEC, infrared or IP, whichever that bay is
484 /// configured for; the caller does not choose. An action from
485 /// [`Remote::send_action`] names an outcome instead, and the device
486 /// decides which keys reach it.
487 pub fn send_key(&self, bay: BayUid, key: RcKey) -> Result<(), ControlError> {
488 self.shared.command(move |state| {
489 let (device, _) = bay_of(state, bay)?;
490 Ok(Command::new(
491 Addressee::device(device),
492 op::RC_TX_KEY,
493 build_rc_key(device.uid, bay.port, key),
494 ))
495 })
496 }
497
498 /// Powers on the device attached to a bay.
499 pub fn power_on(&self, bay: BayUid) -> Result<(), ControlError> {
500 self.set_power(bay, RcAction::POWER_ON, PowerStatus::On)
501 }
502
503 /// Powers off the device attached to a bay.
504 pub fn power_off(&self, bay: BayUid) -> Result<(), ControlError> {
505 self.set_power(bay, RcAction::POWER_OFF, PowerStatus::Off)
506 }
507
508 fn set_power(
509 &self,
510 bay: BayUid,
511 action: RcAction,
512 power: PowerStatus,
513 ) -> Result<(), ControlError> {
514 self.shared.command(move |state| {
515 let (device, _) = bay_of(state, bay)?;
516 Ok(Command::new(
517 Addressee::device(device),
518 op::RC_TX_ACTION,
519 build_rc_action(device.uid, bay.port, action),
520 )
521 .then(move |state, ev| {
522 if let Some(b) = state.bay_mut(bay) {
523 b.set_power_status(power, ev);
524 }
525 }))
526 })
527 }
528
529 /// Sets a bay's volume, as a percentage, and optionally its mute state.
530 ///
531 /// Both channels are set together: the wire carries them separately, but
532 /// nothing on this surface splits them.
533 ///
534 /// A bay with no volume control of its own is set through its
535 /// [`linked_bay`](crate::BayInfo::linked_bay), so an output wired to an
536 /// amplifier zone reaches that zone. [`volume_up`](Self::volume_up),
537 /// [`volume_down`](Self::volume_down) and [`set_muted`](Self::set_muted)
538 /// follow the same link, and read the volume they step from through it.
539 pub fn set_volume(
540 &self,
541 bay: BayUid,
542 volume: u8,
543 muted: Option<bool>,
544 ) -> Result<(), ControlError> {
545 let volume = volume.min(100);
546 let wanted = VolumeMuteStatus {
547 volume_left: Some(volume),
548 volume_right: Some(volume),
549 muted_left: muted,
550 muted_right: muted,
551 };
552 self.shared.command(move |state| {
553 // The mesh may put this bay's volume control on another device, and
554 // the command belongs where the volume lives, not where it was
555 // addressed.
556 let target = state.volume_bay(bay);
557 let (device, b) = bay_of(state, target)?;
558 if !b.has_volume_control() {
559 return Err(ControlError::Unsupported("the bay has no volume control"));
560 }
561 Ok(Command::new(
562 Addressee::device(device),
563 op::AUDIO_SET_VOLUME,
564 build_set_volume(device.uid, target.port, wanted),
565 )
566 .then(move |state, ev| {
567 if let Some(device) = state.device_mut(target.device) {
568 device.apply_bay_volume(target.port, wanted, ev);
569 }
570 }))
571 })
572 }
573
574 /// Raises a bay's volume by one percent.
575 pub fn volume_up(&self, bay: BayUid) -> Result<(), ControlError> {
576 self.set_volume(bay, self.current_volume(bay)?.saturating_add(1), None)
577 }
578
579 /// Lowers a bay's volume by one percent.
580 pub fn volume_down(&self, bay: BayUid) -> Result<(), ControlError> {
581 self.set_volume(bay, self.current_volume(bay)?.saturating_sub(1), None)
582 }
583
584 /// Mutes or unmutes a bay, keeping the volume it is set to.
585 pub fn set_muted(&self, bay: BayUid, muted: bool) -> Result<(), ControlError> {
586 self.set_volume(bay, self.current_volume(bay)?, Some(muted))
587 }
588
589 /// The volume a step or a mute is relative to.
590 fn current_volume(&self, bay: BayUid) -> Result<u8, ControlError> {
591 self.shared.read(|state| {
592 let (_, b) = bay_of(state, state.volume_bay(bay))?;
593 b.audio_volume
594 .map(|v| v.volume())
595 .ok_or(ControlError::NotReported("the bay's volume"))
596 })
597 }
598
599 /// Applies amplifier settings to a zone.
600 pub fn set_amp_zone_settings(
601 &self,
602 bay: BayUid,
603 settings: AmpZoneSettings,
604 ) -> Result<(), ControlError> {
605 self.shared.command(move |state| {
606 let (device, _) = bay_of(state, bay)?;
607 Ok(Command::new(
608 Addressee::device(device),
609 op::AMP_ZONE_SETTINGS,
610 build_amp_zone_settings(device.uid, bay.port, &settings),
611 )
612 .then(move |state, ev| {
613 if let Some(b) = state.bay_mut(bay) {
614 b.set_amp_settings(settings, ev);
615 }
616 }))
617 })
618 }
619
620 // ---- audio endpoints ----
621
622 /// Mutes or unmutes an audio endpoint.
623 pub fn set_audio_endpoint_muted(
624 &self,
625 device: DeviceUid,
626 endpoint: u16,
627 muted: bool,
628 ) -> Result<(), ControlError> {
629 self.audio_endpoint(device, audio_sub::MUTE, endpoint, u32::from(muted))
630 }
631
632 /// Sets an audio endpoint's trigger output.
633 pub fn set_audio_endpoint_trigger(
634 &self,
635 device: DeviceUid,
636 endpoint: u16,
637 active: bool,
638 ) -> Result<(), ControlError> {
639 self.audio_endpoint(device, audio_sub::TRIGGER, endpoint, u32::from(active))
640 }
641
642 /// Sets an audio endpoint's volume.
643 ///
644 /// **The audio module has no receiver for this command and ignores it.**
645 /// It builds and sends the same shape as
646 /// [`Self::set_audio_endpoint_muted`], and the send succeeds, because
647 /// nothing on these paths is acknowledged - so a caller sees success and no
648 /// change. The module dispatches this sub-command to the branch it uses for
649 /// one it does not recognise.
650 ///
651 /// It is kept because the command is defined and the module transmits it
652 /// itself, so a receiver may appear; read the endpoint back rather than
653 /// assuming either way.
654 pub fn set_audio_endpoint_volume(
655 &self,
656 device: DeviceUid,
657 endpoint: u16,
658 volume: u32,
659 ) -> Result<(), ControlError> {
660 self.audio_endpoint(device, audio_sub::VOLUME, endpoint, volume)
661 }
662
663 fn audio_endpoint(
664 &self,
665 device: DeviceUid,
666 sub: u16,
667 endpoint: u16,
668 value: u32,
669 ) -> Result<(), ControlError> {
670 self.shared.command(move |state| {
671 let device = device_of(state, device)?;
672 let mut payload = audio_cmd_header(sub, device.uid);
673 payload.extend_from_slice(&audio_param(endpoint, value));
674 Ok(Command::new(
675 Addressee::device(device),
676 op::V2IP_AUDIO,
677 payload,
678 ))
679 })
680 }
681
682 /// Routes a source endpoint on one device to a sink endpoint on another.
683 pub fn select_audio_endpoint_input(
684 &self,
685 sink: DeviceUid,
686 sink_endpoint: u16,
687 source: DeviceUid,
688 source_endpoint: u16,
689 ) -> Result<(), ControlError> {
690 self.shared.command(move |state| {
691 let device = device_of(state, sink)?;
692 Ok(Command::new(
693 Addressee::device(device),
694 op::V2IP_AUDIO,
695 build_audio_select_input(sink, sink_endpoint, source, source_endpoint),
696 ))
697 })
698 }
699
700 // ---- the whole device ----
701
702 /// Starts or stops a V2IP device reporting its transport statistics.
703 ///
704 /// There is no free-running mode: a device reports only while a
705 /// subscription is live, at 1Hz, and the subscription lapses after a
706 /// minute. A caller that wants a continuous feed re-sends inside the
707 /// minute; nothing here re-arms it.
708 ///
709 /// Reports reach [`crate::EventHandler::on_v2ip_stats_changed`] and read
710 /// back through [`Remote::v2ip_stats`]. A device new enough to send it also
711 /// carries what the sink's decoder recovered, in
712 /// [`crate::V2ipDeviceStats::decoder`].
713 pub fn subscribe_v2ip_stats(
714 &self,
715 device: DeviceUid,
716 subscribe: bool,
717 ) -> Result<(), ControlError> {
718 self.shared.command(move |state| {
719 let device = device_of(state, device)?;
720 Ok(Command::new(
721 Addressee::device(device),
722 op::V2IP_STATS,
723 build_stats_request(device.uid, subscribe),
724 ))
725 })
726 }
727
728 /// Asks a device for an EDID: the one the display on its output
729 /// publishes, or the one it presents to the source on its input.
730 ///
731 /// The device answers with a frame the receive path decodes, so the bytes
732 /// arrive at [`crate::EventHandler::on_edid_received`] and stay readable
733 /// through [`Remote::edid`].
734 ///
735 /// Only V2IP hardware handles this opcode. A matrix or an amplifier
736 /// accepts the frame and answers nothing, at any protocol version, so the
737 /// silence that follows is permanent rather than a reply still to come.
738 /// This call cannot tell the two apart and does not try: it reports what
739 /// was sent, and a caller polling for an EDID should ask a device that can
740 /// answer rather than wait on one that cannot.
741 pub fn request_edid(&self, device: DeviceUid, output: bool) -> Result<(), ControlError> {
742 self.shared.command(move |state| {
743 let device = device_of(state, device)?;
744 Ok(Command::new(
745 Addressee::device(device),
746 op::DEV_EDID,
747 build_edid_request(device.uid, output),
748 ))
749 })
750 }
751
752 /// Asks for a detailed signal report from every bay of one device, or -
753 /// with no device named - from every bay on the network.
754 ///
755 /// Devices report on their own when a signal changes, so this is what a
756 /// client that has just started needs: without it, a bay that has been
757 /// showing the same picture for an hour says nothing until it changes.
758 pub fn request_signal_status(&self, device: Option<DeviceUid>) -> Result<(), ControlError> {
759 let Some(device) = device else {
760 self.shared
761 .send(&Addressee::Broadcast, op::BAY_SIGNAL_STATUS, &[])?;
762 return Ok(());
763 };
764 self.shared.command(move |state| {
765 let device = device_of(state, device)?;
766 Ok(Command::new(
767 Addressee::device(device),
768 op::BAY_SIGNAL_STATUS,
769 build_target_only(device.uid),
770 ))
771 })
772 }
773
774 /// Reboots a device.
775 ///
776 /// The device is marked as rebooting once the frame is away, so the
777 /// silence that follows does not read as one that went offline.
778 pub fn reboot(&self, device: DeviceUid) -> Result<(), ControlError> {
779 self.shared.command(move |state| {
780 let d = device_of(state, device)?;
781 Ok(Command::new(
782 Addressee::device(d),
783 op::SYS_REBOOT,
784 build_target_only(d.uid),
785 )
786 .then(move |state, _| {
787 if let Some(d) = state.device_mut(device) {
788 d.rebooting = true;
789 }
790 }))
791 })
792 }
793
794 /// Asks every peer to report its monitoring data now rather than on its own
795 /// schedule.
796 pub fn send_monitoring_pulse(&self) -> Result<(), ControlError> {
797 self.shared
798 .send(&Addressee::Broadcast, op::SYS_MONITORING_PULSE, &[])?;
799 Ok(())
800 }
801
802 // ---- V2IP scaling ----
803
804 /// Turns a V2IP sink's automatic scaling on or off.
805 ///
806 /// Automatic scaling and a configured output mode are separate reasons for
807 /// a sink to scale, and this moves only the first: a sink with a mode
808 /// configured goes on scaling to it with automatic scaling off. Turning
809 /// both off is this call plus [`Remote::clear_v2ip_output_mode`].
810 ///
811 /// Nothing acknowledges the frame. Read the sink back through
812 /// [`Remote::v2ip_details`] to learn what it did, and treat the block as
813 /// meaningful only where [`crate::DeviceInfo::config_initialised`] is set.
814 /// **Read any route you still need before writing.** The sink rebuilds
815 /// and rebroadcasts its subscription in response, and that report can
816 /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
817 /// and why.
818 pub fn set_v2ip_auto_scaling(
819 &self,
820 device: DeviceUid,
821 enabled: bool,
822 ) -> Result<(), ControlError> {
823 let written = if enabled {
824 SCALING_FLAG_OPTIONS_VALID | SCALING_FLAG_AUTO_SCALING
825 } else {
826 SCALING_FLAG_OPTIONS_VALID
827 };
828 self.set_v2ip_scaling(device, MxrSignalType::NONE, 0, written, move |cached| {
829 V2ipScalingSettings {
830 flags: (cached.flags & !SCALING_FLAG_AUTO_SCALING)
831 | SCALING_FLAG_OPTIONS_VALID
832 | (written & SCALING_FLAG_AUTO_SCALING),
833 ..cached
834 }
835 })
836 }
837
838 /// Sets the output format a V2IP sink scales to.
839 ///
840 /// The mode is checked here and nothing is sent if it fails, because every
841 /// value a sink refuses it refuses in silence. Passing that check is not a
842 /// guarantee: the sink also weighs the format against the display's EDID
843 /// and against what its own output stage can produce.
844 ///
845 /// **Turn automatic scaling off first if it is on.** A sink refuses a mode
846 /// whose format the attached display does not list while it is scaling
847 /// automatically, and refuses it silently. Setting a mode and then turning
848 /// automatic scaling back on is the order that survives, because the mode
849 /// is checked while automatic scaling is still off.
850 ///
851 /// Configuring a mode is itself a reason to scale, so a sink with one
852 /// scales whether or not automatic scaling is on.
853 ///
854 /// **Pass a descriptor and a refresh rate that agree.** A sink stores both
855 /// halves and, with its match-source setting on as it ships, reports back
856 /// the descriptor matching the refresh it holds: a 60Hz descriptor written
857 /// with a refresh of 50 reads back as that descriptor's 50Hz sibling, once,
858 /// and stays there. A sink with match-source off reports the descriptor it
859 /// was given. Either way a pair that agrees reads back unchanged, and the
860 /// format driven is the same - so this costs a caller nothing except a
861 /// descriptor it did not write. That substitution shows up on the sink's
862 /// next report rather than immediately, because
863 /// [`crate::V2ipScalingSettings`] holds what was written until then.
864 ///
865 /// A mode read from the sink's own web interface is not interchangeable
866 /// with this pair. That interface reports the descriptor's 60Hz sibling and
867 /// carries the refresh in a field of its own, so writing back what it shows
868 /// as the mode, on its own, changes the setting rather than restoring it.
869 /// **Read any route you still need before writing.** The sink rebuilds
870 /// and rebroadcasts its subscription in response, and that report can
871 /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
872 /// and why.
873 pub fn set_v2ip_output_mode(
874 &self,
875 device: DeviceUid,
876 mode: V2ipOutputMode,
877 ) -> Result<(), ControlError> {
878 mode.validate().map_err(ControlError::InvalidRequest)?;
879 let signal = mode.to_signal_type();
880 let refresh = mode.refresh;
881 self.set_v2ip_scaling(
882 device,
883 signal,
884 refresh,
885 SCALING_FLAG_MODE_VALID,
886 move |cached| V2ipScalingSettings {
887 mode: signal,
888 refresh,
889 flags: cached.flags | SCALING_FLAG_MODE_VALID,
890 },
891 )
892 }
893
894 /// Clears the output format a V2IP sink is configured to scale to.
895 ///
896 /// The sink stops scaling for that reason and keeps its automatic scaling
897 /// setting, so a sink scaling for both reasons goes on scaling until
898 /// [`Remote::set_v2ip_auto_scaling`] turns the other one off.
899 ///
900 /// This is the only way to express "no mode configured", and it is what a
901 /// caller restoring a sink that had none has to send: a sink reports no
902 /// mode by leaving the mode's valid bit clear, which is not something a
903 /// write can say.
904 /// **Read any route you still need before writing.** The sink rebuilds
905 /// and rebroadcasts its subscription in response, and that report can
906 /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
907 /// and why.
908 pub fn clear_v2ip_output_mode(&self, device: DeviceUid) -> Result<(), ControlError> {
909 // The valid bit with a zero descriptor is the clear. The receiver takes
910 // that branch ahead of validating anything, and ignores the depth,
911 // colour space and refresh rate beside it.
912 self.set_v2ip_scaling(
913 device,
914 MxrSignalType::NONE,
915 0,
916 SCALING_FLAG_MODE_VALID,
917 |cached| V2ipScalingSettings {
918 mode: MxrSignalType::NONE,
919 refresh: 0,
920 flags: cached.flags & !SCALING_FLAG_MODE_VALID,
921 },
922 )
923 }
924
925 /// The one send behind the scaling methods.
926 ///
927 /// `written` is the flag byte that goes out, and `applied` says what the
928 /// sink will report afterwards. The two differ where the wire spells a
929 /// write differently from the state it produces - clearing a mode is sent
930 /// as the valid bit over a zero mode and read back as the valid bit clear -
931 /// so predicting the cached value from the frame alone would leave a
932 /// caller reading a state no device ever broadcasts.
933 fn set_v2ip_scaling(
934 &self,
935 device: DeviceUid,
936 mode: MxrSignalType,
937 refresh: u16,
938 written: u8,
939 applied: impl FnOnce(V2ipScalingSettings) -> V2ipScalingSettings + Send + 'static,
940 ) -> Result<(), ControlError> {
941 self.shared.command(move |state| {
942 let d = device_of(state, device)?;
943 if !d.is_v2ip_sink() {
944 return Err(ControlError::Unsupported(
945 "scaling settings need a V2IP sink",
946 ));
947 }
948 Ok(Command::new(
949 Addressee::device(d),
950 op::V2IP_DEVICE_CFG,
951 build_v2ip_scaling(d.uid, mode, refresh, written),
952 )
953 .then(move |state, ev| {
954 if let Some(d) = state.device_mut(device) {
955 let cached = d.v2ip_scaling();
956 d.set_v2ip_scaling(applied(cached), ev);
957 }
958 }))
959 })
960 }
961
962 // ---- video wall ----
963
964 /// Shows a window on a sink's video wall without persisting it.
965 ///
966 /// The window survives until the sink is told otherwise or restarts.
967 /// [`Remote::revert_video_wall`] puts back whatever was stored.
968 ///
969 /// Pass [`crate::VIDEO_WALL_CLEARED`] to show the whole frame again.
970 pub fn preview_video_wall(
971 &self,
972 sink: DeviceUid,
973 window: VideoWallWindow,
974 ) -> Result<(), ControlError> {
975 self.set_video_wall(sink, window, VideoWallOp::PREVIEW)
976 }
977
978 /// Persists a window as a sink's video wall.
979 ///
980 /// The geometry is checked here, before anything is sent, because the sink
981 /// is not guaranteed to check it. A sink running a video-wall module older
982 /// than 2026083100 writes the window to its configuration *before* asking
983 /// its video processor to apply it, and the processor's refusal does not
984 /// undo that write - so an out-of-spec window survives a reboot and is
985 /// re-offered on every stream restart until something else replaces it. A
986 /// power cycle does not clear it.
987 ///
988 /// Nothing acknowledges this frame either way, so an `Ok` says only that
989 /// it was sent. Read the sink's state back to learn what it did.
990 ///
991 /// Pass [`crate::VIDEO_WALL_CLEARED`] to store "show the whole frame".
992 pub fn store_video_wall(
993 &self,
994 sink: DeviceUid,
995 window: VideoWallWindow,
996 ) -> Result<(), ControlError> {
997 self.set_video_wall(sink, window, VideoWallOp::STORE)
998 }
999
1000 /// Restores the window a sink has stored, discarding a preview.
1001 ///
1002 /// Carries no window of its own: the sink already holds the one this puts
1003 /// back.
1004 pub fn revert_video_wall(&self, sink: DeviceUid) -> Result<(), ControlError> {
1005 self.set_video_wall(sink, VIDEO_WALL_CLEARED, VideoWallOp::REVERT)
1006 }
1007
1008 /// The one send behind the three video-wall methods.
1009 ///
1010 /// Validation sits here rather than in each of them, so an operation added
1011 /// later cannot reach the wire without it, and is skipped for a revert
1012 /// because the sink ignores the window on that operation rather than
1013 /// checking it.
1014 ///
1015 /// Passing it is not proof a wall appeared. Two things the sink refuses
1016 /// afterwards are equally silent: a window it will not draw, which it logs
1017 /// and drops, and a sink whose image has no tiling support at all, which
1018 /// takes the window into its own state and then fails to push it to the
1019 /// hardware. Neither reaches the wire, so read the sink back over HTTP to
1020 /// learn a window landed.
1021 fn set_video_wall(
1022 &self,
1023 sink: DeviceUid,
1024 window: VideoWallWindow,
1025 op: VideoWallOp,
1026 ) -> Result<(), ControlError> {
1027 if op != VideoWallOp::REVERT {
1028 window.validate().map_err(ControlError::InvalidRequest)?;
1029 }
1030 self.shared.command(move |state| {
1031 let device = device_of(state, sink)?;
1032 Ok(Command::new(
1033 Addressee::device(device),
1034 op::V2IP_VIDEO_WALL,
1035 build_video_wall(device.uid, window, op),
1036 ))
1037 })
1038 }
1039
1040 // ---- multiviewer ----
1041
1042 /// Sets the window layout.
1043 pub fn set_multiviewer_view_mode(
1044 &self,
1045 device: DeviceUid,
1046 mode: MultiviewerViewMode,
1047 ) -> Result<(), ControlError> {
1048 let mode = mv_setting(mode.to_wire(), 8, "the multiviewer has no such view mode")?;
1049 self.multiviewer(device, mv_sub::VIEW_MODE, &[mode])
1050 }
1051
1052 /// Assigns a source to one window, counting windows from zero.
1053 ///
1054 /// A window index the multiviewer is not currently showing is refused
1055 /// rather than sent: firmware accepts an index one past the last window
1056 /// and writes through the end of the array it indexes, so the frame that
1057 /// would carry it is the one frame this library must never put on the
1058 /// wire. The bound comes from the layout in the multiviewer's last status
1059 /// report, so a multiviewer that has reported none can only be given
1060 /// window zero, which every layout has.
1061 pub fn set_multiviewer_video_source(
1062 &self,
1063 device: DeviceUid,
1064 screen: u8,
1065 source: MultiviewerSource,
1066 ) -> Result<(), ControlError> {
1067 let source = source_index(source, "the source names no multiviewer input")?;
1068 self.shared.command(|state| {
1069 let target = multiviewer_of(state, device)?;
1070 let windows = target
1071 .multiviewer
1072 .as_ref()
1073 .and_then(MultiviewerStatus::window_count)
1074 .unwrap_or(1);
1075 if screen >= windows {
1076 return Err(ControlError::InvalidRequest(
1077 "the window is not one the multiviewer is showing",
1078 ));
1079 }
1080 Ok(mv_command(target, mv_sub::VIDEO_SOURCE, &[screen, source]))
1081 })
1082 }
1083
1084 /// Selects which window's audio is output.
1085 pub fn set_multiviewer_audio_source(
1086 &self,
1087 device: DeviceUid,
1088 source: MultiviewerSource,
1089 ) -> Result<(), ControlError> {
1090 let source = source_index(source, "the audio source names no multiviewer input")?;
1091 self.multiviewer(device, mv_sub::AUDIO_SOURCE, &[source])
1092 }
1093
1094 /// Sets the output volume, as a percentage, and the mute state.
1095 ///
1096 /// A volume above 100 is refused rather than sent. What a multiviewer does
1097 /// with one depends on its module version: from 2026083100 it drops the
1098 /// whole frame, and before that it dropped the volume alone and still
1099 /// acted on the mute beside it. Neither is what the caller asked for, and
1100 /// neither is reported back.
1101 pub fn set_multiviewer_audio_volume(
1102 &self,
1103 device: DeviceUid,
1104 volume: u8,
1105 muted: bool,
1106 ) -> Result<(), ControlError> {
1107 if volume > 100 {
1108 return Err(ControlError::InvalidRequest(
1109 "a multiviewer volume is a percentage",
1110 ));
1111 }
1112 self.multiviewer(device, mv_sub::AUDIO_VOLUME, &[volume, u8::from(muted)])
1113 }
1114
1115 /// Sets the EDID template presented to the sources.
1116 pub fn set_multiviewer_edid_template(
1117 &self,
1118 device: DeviceUid,
1119 template: MultiviewerEdidTemplate,
1120 ) -> Result<(), ControlError> {
1121 let template = mv_setting(
1122 template.to_wire(),
1123 19,
1124 "the multiviewer has no such EDID template",
1125 )?;
1126 self.multiviewer(device, mv_sub::EDID_TEMPLATE, &[template])
1127 }
1128
1129 /// Selects which window receives remote-control passthrough.
1130 pub fn set_multiviewer_remote_control(
1131 &self,
1132 device: DeviceUid,
1133 source: MultiviewerSource,
1134 ) -> Result<(), ControlError> {
1135 let source = source_index(
1136 source,
1137 "the remote-control source names no multiviewer input",
1138 )?;
1139 self.multiviewer(device, mv_sub::ROUTE_RC, &[source])
1140 }
1141
1142 /// Sets how large the picture-in-picture window is.
1143 pub fn set_multiviewer_pip_size(
1144 &self,
1145 device: DeviceUid,
1146 size: MultiviewerPipSize,
1147 ) -> Result<(), ControlError> {
1148 let size = mv_setting(
1149 size.to_wire(),
1150 3,
1151 "the multiviewer has no such picture-in-picture size",
1152 )?;
1153 self.multiviewer(device, mv_sub::PIP_SIZE, &[size])
1154 }
1155
1156 /// Sets which corner the picture-in-picture window sits in.
1157 pub fn set_multiviewer_pip_position(
1158 &self,
1159 device: DeviceUid,
1160 position: MultiviewerPipPosition,
1161 ) -> Result<(), ControlError> {
1162 let position = mv_setting(
1163 position.to_wire(),
1164 4,
1165 "the multiviewer has no such picture-in-picture position",
1166 )?;
1167 self.multiviewer(device, mv_sub::PIP_POSITION, &[position])
1168 }
1169
1170 /// Sets the aspect ratio the windows are scaled to.
1171 pub fn set_multiviewer_aspect_ratio(
1172 &self,
1173 device: DeviceUid,
1174 aspect: MultiviewerAspectRatio,
1175 ) -> Result<(), ControlError> {
1176 let aspect = mv_setting(
1177 aspect.to_wire(),
1178 2,
1179 "the multiviewer has no such aspect ratio",
1180 )?;
1181 self.multiviewer(device, mv_sub::ASPECT, &[aspect])
1182 }
1183
1184 /// Enables or disables switching windows on its own.
1185 pub fn set_multiviewer_auto_switch(
1186 &self,
1187 device: DeviceUid,
1188 enable: bool,
1189 ) -> Result<(), ControlError> {
1190 self.multiviewer(device, mv_sub::AUTO_SWITCH, &[u8::from(enable)])
1191 }
1192
1193 /// Sets the output resolution and refresh rate.
1194 pub fn set_multiviewer_output_mode(
1195 &self,
1196 device: DeviceUid,
1197 mode: MultiviewerOutputMode,
1198 ) -> Result<(), ControlError> {
1199 let mode = mv_setting(
1200 mode.to_wire(),
1201 14,
1202 "the multiviewer has no such output mode",
1203 )?;
1204 self.multiviewer(device, mv_sub::OUTPUT_MODE, &[mode])
1205 }
1206
1207 /// Sets the IT-content flag on the output.
1208 pub fn set_multiviewer_output_itc(
1209 &self,
1210 device: DeviceUid,
1211 mode: MultiviewerItcMode,
1212 ) -> Result<(), ControlError> {
1213 let mode = mv_setting(
1214 mode.to_wire(),
1215 2,
1216 "the multiviewer has no such IT-content mode",
1217 )?;
1218 self.multiviewer(device, mv_sub::OUTPUT_ITC_MODE, &[mode])
1219 }
1220
1221 /// Sets the HDCP version negotiated on the output.
1222 pub fn set_multiviewer_hdcp_mode(
1223 &self,
1224 device: DeviceUid,
1225 mode: MultiviewerHdcpMode,
1226 ) -> Result<(), ControlError> {
1227 let mode = mv_setting(mode.to_wire(), 3, "the multiviewer has no such HDCP mode")?;
1228 self.multiviewer(device, mv_sub::HDCP_MODE, &[mode])
1229 }
1230
1231 /// Maps a source device onto one of the multiviewer's inputs, counting
1232 /// inputs from zero.
1233 ///
1234 /// [`DeviceUid::ZERO`] clears the mapping on a multiviewer running module
1235 /// version 2026083100 or newer, and is stored as a mapping like any other
1236 /// on anything older. No version checks that a mapping names a device on
1237 /// the mesh.
1238 ///
1239 /// Which of the two happened shows in `mappings` on a later status report,
1240 /// where a cleared input reads as [`DeviceUid::ZERO`] only from that same
1241 /// version. It will not be the next frame this multiviewer sends: this is
1242 /// one of the two settings that schedule no status broadcast of their own,
1243 /// so the answer arrives whenever something else prompts one.
1244 pub fn set_multiviewer_input_source(
1245 &self,
1246 device: DeviceUid,
1247 input: u8,
1248 source: DeviceUid,
1249 ) -> Result<(), ControlError> {
1250 if usize::from(input) >= MULTIVIEWER_INPUTS {
1251 return Err(ControlError::InvalidRequest(
1252 "the multiviewer has no such input",
1253 ));
1254 }
1255 let mut args = Vec::with_capacity(24);
1256 args.extend_from_slice(source.as_bytes());
1257 args.push(input);
1258 // mv_config_source_t is 4-aligned behind its uid, so seven bytes of
1259 // padding follow the input index.
1260 args.extend_from_slice(&[0; 7]);
1261 self.multiviewer(device, mv_sub::CONFIG_SOURCE, &args)
1262 }
1263
1264 /// Asks the multiviewer to route its sources itself.
1265 pub fn multiviewer_auto_route(&self, device: DeviceUid) -> Result<(), ControlError> {
1266 self.multiviewer(device, mv_sub::AUTO_ROUTE, &[])
1267 }
1268
1269 fn multiviewer(&self, device: DeviceUid, sub: u8, args: &[u8]) -> Result<(), ControlError> {
1270 self.shared
1271 .command(|state| Ok(mv_command(multiviewer_of(state, device)?, sub, args)))
1272 }
1273}