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