mx_remote/runtime/control.rs
1// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
2// Copyright (c) 2026 Op den Kamp IT Solutions
3
4//! The control surface: what a caller can ask a device to do.
5//!
6//! Every method here has the same shape. It reads the registry to decide what
7//! to send, releases that lock, transmits, and only then writes back what the
8//! device will have done. The order is what makes a handler woken by the
9//! write-back free to call in again, and it keeps the receive thread from
10//! waiting on a socket write for the lock it needs to decode.
11//!
12//! Nothing here reaches the wire on its own: a payload is bytes until the
13//! single transmit path stamps and writes it, which is where the addressee's
14//! protocol version is checked.
15//!
16//! The multiviewer and audio-endpoint methods are served by loadable modules
17//! rather than by the device firmware, and a model may not load modules at
18//! all, may not ship that one, or may not support it. Those modules answer
19//! nothing either way, so an `Ok` from one of those methods says a frame left
20//! the socket and no more: "the device did it" and "nothing on the device
21//! handles this" are the same observation from here. Read the state back to
22//! tell them apart. A multiviewer broadcasts its whole status shortly after a
23//! setting it accepted, which serves as that read for every one of its methods
24//! but [`Remote::set_multiviewer_remote_control`] and
25//! [`Remote::set_multiviewer_input_source`], which broadcast nothing.
26
27use std::fmt;
28use std::net::Ipv4Addr;
29use std::time::{SystemTime, UNIX_EPOCH};
30
31use crate::event::Event;
32use crate::state::{Bay, Device, State};
33use crate::types::{
34 AmpZoneSettings, AudioFeatures, HiddenStatus, MultiviewerStatus, PowerStatus, UnitNetConfig,
35 V2ipAudioFormat, V2ipDeviceSettings, V2ipOutputMode, V2ipPowerSaveSchedule, V2ipRoute,
36 V2ipRouteTarget, V2ipScalingSettings, V2ipStreamSources, V2ipTestSync, V2ipTestTone,
37 V2ipTestcard, V2ipVlan, VideoWallOp, VideoWallWindow, VolumeMuteStatus, MULTIVIEWER_INPUTS,
38 SCALING_FLAG_AUTO_SCALING, SCALING_FLAG_MODE_VALID, SCALING_FLAG_OPTIONS_VALID,
39 V2IP_PTP_DOMAIN_MAX, V2IP_VLAN_PORT_SFP, VIDEO_WALL_CLEARED,
40};
41use crate::wire::{
42 audio_cmd_header, audio_param, audio_sub, build_amp_zone_settings, build_audio_select_input,
43 build_bay_hide, build_edid_profile, build_edid_request, build_mesh_operation, build_rc_action,
44 build_rc_key, build_set_bay_name, build_set_volume, build_stats_request, build_target_only,
45 build_time_zone, build_unit_command, build_v2ip_device_settings,
46 build_v2ip_manual_source_switch, build_v2ip_scaling, build_v2ip_settings_all,
47 build_v2ip_source_switch, build_v2ip_testcard, build_v2ip_vlan, build_video_wall, mesh_op,
48 mv_cmd_payload, mv_sub, op, testcard_part, testcard_type, Addressee, BayUid, DeviceFeature,
49 DeviceUid, EdidProfile, MultiviewerAspectRatio, MultiviewerEdidTemplate, MultiviewerHdcpMode,
50 MultiviewerItcMode, MultiviewerOutputMode, MultiviewerPipPosition, MultiviewerPipSize,
51 MultiviewerSource, MultiviewerViewMode, MxrSignalType, Opcode, RcAction, RcKey, SendError,
52 StreamAddr, UnitCommandFlag, UnitCommandKind, V2ipDeviceSetting, V2ipFpgaFeature, V2ipPtpMode,
53 V2ipStreams, V2ipTestPattern, V2ipToneMode, V2ipVlanFlag, DEVICE_NAME_LEN, TIME_ZONE_NAME_LEN,
54 TIME_ZONE_RULE_LEN, V2IP_IR_PROFILE_MAX, V2IP_IR_PROFILE_NOT_SET, V2IP_PORT_ANC,
55 V2IP_PORT_AUDIO, V2IP_PORT_VIDEO,
56};
57
58use super::{Remote, Shared};
59
60/// Why a control method did nothing.
61#[derive(Debug)]
62#[non_exhaustive]
63pub enum ControlError {
64 /// No device with this identifier has been heard from.
65 UnknownDevice(DeviceUid),
66 /// The device has reported no bay on this port.
67 UnknownBay(BayUid),
68 /// No input bay on the device carries this user-assigned name.
69 UnknownSource(String),
70 /// The addressee does not do what was asked of it.
71 Unsupported(&'static str),
72 /// The request breaks a rule the device is not guaranteed to check.
73 ///
74 /// Nothing was sent. This is the caller's to fix, and it is separate from
75 /// [`ControlError::Unsupported`] because the device would have taken the
76 /// frame: refusing here is this library declining to let a bad value
77 /// reach hardware that may store it rather than reject it.
78 InvalidRequest(&'static str),
79 /// The device has not reported something the request is assembled from.
80 ///
81 /// Unlike [`ControlError::Unsupported`], the same call may succeed once it
82 /// has: this says the value is missing, not that it cannot exist.
83 NotReported(&'static str),
84 /// The frame could not be sent.
85 Send(SendError),
86}
87
88impl fmt::Display for ControlError {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 match self {
91 Self::UnknownDevice(uid) => write!(f, "no device {uid}"),
92 Self::UnknownBay(uid) => write!(f, "no bay {uid}"),
93 Self::UnknownSource(name) => write!(f, "no source named {name:?}"),
94 Self::Unsupported(what) => f.write_str(what),
95 Self::InvalidRequest(what) => f.write_str(what),
96 Self::NotReported(what) => write!(f, "{what} has not been reported"),
97 Self::Send(e) => write!(f, "{e}"),
98 }
99 }
100}
101
102impl std::error::Error for ControlError {
103 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
104 match self {
105 Self::Send(e) => Some(e),
106 _ => None,
107 }
108 }
109}
110
111impl From<SendError> for ControlError {
112 fn from(e: SendError) -> Self {
113 Self::Send(e)
114 }
115}
116
117/// What a command does to this client's copy of the registry once its frame is
118/// away.
119///
120/// A device does not acknowledge a command, so without this a caller that read
121/// back what it just wrote would see the old value until some unrelated report
122/// happened to carry the new one.
123type WriteBack = Box<dyn FnOnce(&mut State, &mut Vec<Event>) + Send>;
124
125/// One command: the frame to send, and what the addressee will do with it.
126struct Command {
127 to: Addressee,
128 opcode: Opcode,
129 payload: Vec<u8>,
130 write_back: Option<WriteBack>,
131}
132
133impl Command {
134 fn new(to: Addressee, opcode: Opcode, payload: Vec<u8>) -> Self {
135 Self {
136 to,
137 opcode,
138 payload,
139 write_back: None,
140 }
141 }
142
143 /// Records what to apply locally once the frame is away.
144 fn then(mut self, f: impl FnOnce(&mut State, &mut Vec<Event>) + Send + 'static) -> Self {
145 self.write_back = Some(Box::new(f));
146 self
147 }
148}
149
150impl Shared {
151 /// Runs one command: prepare under the registry lock, send without it,
152 /// then write back.
153 fn command(
154 &self,
155 prepare: impl FnOnce(&State) -> Result<Command, ControlError>,
156 ) -> Result<(), ControlError> {
157 let command = self.read(prepare)?;
158 self.send(&command.to, command.opcode, &command.payload)?;
159 if let Some(write_back) = command.write_back {
160 self.mutate(|state, ev| write_back(state, ev));
161 }
162 Ok(())
163 }
164}
165
166/// The first protocol version whose devices can act on the automatic address
167/// operation.
168///
169/// Not the stamp: that is the mesh operation opcode's, and a device between
170/// the two takes the frame and ignores an operation it does not know. Not
171/// every device on this version has it either, but none below it does.
172const AUTO_ADDRESSES_PROTOCOL: u16 = 0x2B;
173
174fn device_of(state: &State, uid: DeviceUid) -> Result<&Device, ControlError> {
175 state.device(uid).ok_or(ControlError::UnknownDevice(uid))
176}
177
178/// The device behind `uid`, once it is known to be a multiviewer.
179fn multiviewer_of(state: &State, uid: DeviceUid) -> Result<&Device, ControlError> {
180 let device = device_of(state, uid)?;
181 if !device.is_multiviewer() {
182 return Err(ControlError::Unsupported("the device is not a multiviewer"));
183 }
184 Ok(device)
185}
186
187/// Wraps one multiviewer sub-command in the envelope every one of them shares.
188fn mv_command(device: &Device, sub: u8, args: &[u8]) -> Command {
189 Command::new(
190 Addressee::device(device),
191 op::V2IP_MULTIVIEWER,
192 mv_cmd_payload(device.uid, sub, args),
193 )
194}
195
196/// The zero-based input a source names, refused when it names none.
197///
198/// A multiviewer reads zero as its first input, so there is no value that says
199/// "no input": a source that names none would arrive as a request to switch to
200/// input 1.
201fn source_index(source: MultiviewerSource, what: &'static str) -> Result<u8, ControlError> {
202 source
203 .to_zero_based()
204 .ok_or(ControlError::InvalidRequest(what))
205}
206
207/// A multiviewer setting within the range its firmware accepts.
208///
209/// Every one of these settings is numbered from one, with zero reserved for
210/// "the device has reported nothing". The device drops a value it does not
211/// know without answering, so a caller sending one would see a send succeed
212/// and the setting stay as it was; this is what turns that into an error.
213fn mv_setting(value: u8, highest: u8, what: &'static str) -> Result<u8, ControlError> {
214 if (1..=highest).contains(&value) {
215 Ok(value)
216 } else {
217 Err(ControlError::InvalidRequest(what))
218 }
219}
220
221fn bay_of(state: &State, uid: BayUid) -> Result<(&Device, &Bay), ControlError> {
222 let device = device_of(state, uid.device)?;
223 let bay = device.bay(uid.port).ok_or(ControlError::UnknownBay(uid))?;
224 Ok((device, bay))
225}
226
227/// The streams the source bay on `port` advertises.
228fn source_streams(device: &Device, port: u16) -> Result<&V2ipStreamSources, ControlError> {
229 let source = device
230 .bay(port)
231 .ok_or(ControlError::UnknownBay(BayUid::new(device.uid, port)))?;
232 device
233 .v2ip_source_for(source)
234 .ok_or(ControlError::NotReported("the source's stream addresses"))
235}
236
237/// A sink bay, or the reason it cannot be routed.
238fn v2ip_sink(state: &State, uid: BayUid) -> Result<(&Device, &Bay), ControlError> {
239 let (device, bay) = bay_of(state, uid)?;
240 if !bay.is_v2ip_sink() {
241 return Err(ControlError::Unsupported("routing needs a V2IP sink"));
242 }
243 Ok((device, bay))
244}
245
246/// One route slot as the wire carries it, substituting the stream's standard
247/// port for an unset one.
248///
249/// An unset address sends the slot zeroed, port included: the firmware reads
250/// the pair together, and a port beside 0.0.0.0 describes nothing.
251fn stream_addr(target: V2ipRouteTarget, standard_port: u16) -> StreamAddr {
252 if target.ip.is_unspecified() {
253 return StreamAddr::default();
254 }
255 StreamAddr {
256 ip: target.ip,
257 port: target.port_or(standard_port),
258 }
259}
260
261/// The name as the device will store it: the field is
262/// [`DEVICE_NAME_LEN`] bytes wide, so a longer one is cut there.
263fn stored_name(name: &str) -> String {
264 let bytes = name.as_bytes();
265 String::from_utf8_lossy(bytes.get(..DEVICE_NAME_LEN).unwrap_or(bytes)).into_owned()
266}
267
268impl Remote {
269 // ---- routing ----
270
271 /// Routes this V2IP sink's video to the stream a source port advertises.
272 pub fn select_video_source(&self, sink: BayUid, source_port: u16) -> Result<(), ControlError> {
273 self.shared.command(|state| {
274 let (device, bay) = v2ip_sink(state, sink)?;
275 if !bay.is_output() {
276 return Err(ControlError::Unsupported("not an output bay"));
277 }
278 let streams = source_streams(device, source_port)?;
279 Ok(Command::new(
280 Addressee::device(device),
281 op::V2IP_SOURCE_SWITCH,
282 build_v2ip_source_switch(device.uid, streams.video.ip, Ipv4Addr::UNSPECIFIED),
283 ))
284 })
285 }
286
287 /// Routes this V2IP sink's audio to the stream a source port advertises.
288 pub fn select_audio_source(&self, sink: BayUid, source_port: u16) -> Result<(), ControlError> {
289 self.shared.command(|state| {
290 let (device, _) = v2ip_sink(state, sink)?;
291 let streams = source_streams(device, source_port)?;
292 Ok(Command::new(
293 Addressee::device(device),
294 op::V2IP_SOURCE_SWITCH,
295 build_v2ip_source_switch(device.uid, Ipv4Addr::UNSPECIFIED, streams.audio.ip),
296 ))
297 })
298 }
299
300 /// Routes this V2IP sink's video to the input bay with the given
301 /// user-assigned name.
302 pub fn select_video_source_by_name(
303 &self,
304 sink: BayUid,
305 name: &str,
306 ) -> Result<(), ControlError> {
307 self.select_video_source(sink, self.source_port(sink, name)?)
308 }
309
310 /// Routes this V2IP sink's audio to a multicast address directly, leaving
311 /// its video and ancillary streams alone.
312 ///
313 /// An unset port is the standard V2IP audio port. A format overrides the
314 /// sample rate and channel count the receiver would otherwise assume.
315 pub fn select_audio_source_addr(
316 &self,
317 sink: BayUid,
318 audio_ip: Ipv4Addr,
319 audio_port: Option<u16>,
320 format: Option<V2ipAudioFormat>,
321 ) -> Result<(), ControlError> {
322 self.shared.command(move |state| {
323 let (device, _) = v2ip_sink(state, sink)?;
324 let streams = V2ipStreams {
325 audio: StreamAddr {
326 ip: audio_ip,
327 port: audio_port.unwrap_or(V2IP_PORT_AUDIO),
328 },
329 ..V2ipStreams::default()
330 };
331 Ok(Command::new(
332 Addressee::device(device),
333 op::V2IP_MANUAL_SRC_SWITCH,
334 build_v2ip_manual_source_switch(device.uid, streams, format),
335 ))
336 })
337 }
338
339 /// Routes this V2IP sink's video, audio and ancillary streams to
340 /// multicast groups the caller names.
341 ///
342 /// This is the only way to reach a stream no device on the mesh
343 /// advertises, such as one the host is transmitting itself; a route by
344 /// source port can only name a stream some bay has announced.
345 ///
346 /// Set all three groups. The firmware decides whether a sink has a manual
347 /// route by looking at the video and ancillary groups, so a route that
348 /// leaves either unset does not register as one and the sink falls back to
349 /// the audio source its mesh picks.
350 ///
351 /// An unset `format` sends [`V2ipAudioFormat::STANDARD`] rather than
352 /// omitting the trailer. The firmware stores whatever this frame carries
353 /// and hands it to the FPGA unexamined, so a frame without one leaves a
354 /// zero rate and zero channel count there, which the FPGA rejects and
355 /// which takes the switch down with it.
356 pub fn select_source_addr(
357 &self,
358 sink: BayUid,
359 route: V2ipRoute,
360 format: Option<V2ipAudioFormat>,
361 ) -> Result<(), ControlError> {
362 let streams = V2ipStreams {
363 video: stream_addr(route.video, V2IP_PORT_VIDEO),
364 audio: stream_addr(route.audio, V2IP_PORT_AUDIO),
365 anc: stream_addr(route.anc, V2IP_PORT_ANC),
366 };
367 let format = format.unwrap_or(V2ipAudioFormat::STANDARD);
368 self.shared.command(move |state| {
369 let (device, _) = v2ip_sink(state, sink)?;
370 Ok(Command::new(
371 Addressee::device(device),
372 op::V2IP_MANUAL_SRC_SWITCH,
373 build_v2ip_manual_source_switch(device.uid, streams, Some(format)),
374 ))
375 })
376 }
377
378 /// Routes this V2IP sink's audio from the input bay with the given
379 /// user-assigned name.
380 ///
381 /// A format is carried on the manual switch frame, which is the only form
382 /// that can override the receiver's sample rate and channel count.
383 pub fn select_audio_source_by_name(
384 &self,
385 sink: BayUid,
386 name: &str,
387 format: Option<V2ipAudioFormat>,
388 ) -> Result<(), ControlError> {
389 let port = self.source_port(sink, name)?;
390 let Some(format) = format else {
391 return self.select_audio_source(sink, port);
392 };
393 self.shared.command(move |state| {
394 let (device, _) = v2ip_sink(state, sink)?;
395 let audio = source_streams(device, port)?.audio;
396 let streams = V2ipStreams {
397 audio: StreamAddr {
398 ip: audio.ip,
399 port: audio.port,
400 },
401 ..V2ipStreams::default()
402 };
403 Ok(Command::new(
404 Addressee::device(device),
405 op::V2IP_MANUAL_SRC_SWITCH,
406 build_v2ip_manual_source_switch(device.uid, streams, Some(format)),
407 ))
408 })
409 }
410
411 /// The port of the input bay on `sink`'s device carrying `name`.
412 fn source_port(&self, sink: BayUid, name: &str) -> Result<u16, ControlError> {
413 self.shared.read(|state| {
414 let (device, _) = bay_of(state, sink)?;
415 device
416 .bay_by_user_name(name)
417 .map(|b| b.port)
418 .ok_or_else(|| ControlError::UnknownSource(name.to_owned()))
419 })
420 }
421
422 // ---- bay settings ----
423
424 /// Renames a bay.
425 pub fn set_bay_name(&self, bay: BayUid, name: &str) -> Result<(), ControlError> {
426 let name = stored_name(name);
427 self.shared.command(move |state| {
428 let (device, _) = bay_of(state, bay)?;
429 let payload = build_set_bay_name(device.uid, bay.port, &name);
430 Ok(
431 Command::new(Addressee::device(device), op::CHANGE_BAY_NAME, payload).then(
432 move |state, ev| {
433 if let Some(b) = state.bay_mut(bay) {
434 b.set_user_name(name, ev);
435 }
436 },
437 ),
438 )
439 })
440 }
441
442 /// Hides a bay from the pickers that list it, or shows it again.
443 pub fn set_bay_hidden(&self, bay: BayUid, hidden: bool) -> Result<(), ControlError> {
444 self.shared.command(move |state| {
445 let (device, _) = bay_of(state, bay)?;
446 Ok(Command::new(
447 Addressee::device(device),
448 op::BAY_HIDE,
449 build_bay_hide(device.uid, bay.port, hidden),
450 )
451 .then(move |state, ev| {
452 if let Some(b) = state.bay_mut(bay) {
453 let status = if hidden {
454 HiddenStatus::Hidden
455 } else {
456 HiddenStatus::Visible
457 };
458 b.apply_hidden(status, ev);
459 }
460 }))
461 })
462 }
463
464 /// Sets the EDID profile an input presents to the source attached to it.
465 pub fn select_edid_profile(
466 &self,
467 bay: BayUid,
468 profile: EdidProfile,
469 ) -> Result<(), ControlError> {
470 self.shared.command(move |state| {
471 let (device, _) = bay_of(state, bay)?;
472 Ok(Command::new(
473 Addressee::device(device),
474 op::BAY_EDID_PROFILE,
475 build_edid_profile(device.uid, profile),
476 )
477 .then(move |state, ev| {
478 if let Some(b) = state.bay_mut(bay) {
479 b.set_edid_profile(profile, ev);
480 }
481 }))
482 })
483 }
484
485 /// Sends a remote-control action to whatever is attached to a bay.
486 pub fn send_action(&self, bay: BayUid, action: RcAction) -> Result<(), ControlError> {
487 self.shared.command(move |state| {
488 let (device, _) = bay_of(state, bay)?;
489 Ok(Command::new(
490 Addressee::device(device),
491 op::RC_TX_ACTION,
492 build_rc_action(device.uid, bay.port, action),
493 ))
494 })
495 }
496
497 /// Sends a remote-control key press to whatever is attached to a bay.
498 ///
499 /// The device forwards it over CEC, infrared or IP, whichever that bay is
500 /// configured for; the caller does not choose. An action from
501 /// [`Remote::send_action`] names an outcome instead, and the device
502 /// decides which keys reach it.
503 pub fn send_key(&self, bay: BayUid, key: RcKey) -> Result<(), ControlError> {
504 self.shared.command(move |state| {
505 let (device, _) = bay_of(state, bay)?;
506 Ok(Command::new(
507 Addressee::device(device),
508 op::RC_TX_KEY,
509 build_rc_key(device.uid, bay.port, key),
510 ))
511 })
512 }
513
514 /// Powers on the device attached to a bay.
515 pub fn power_on(&self, bay: BayUid) -> Result<(), ControlError> {
516 self.set_power(bay, RcAction::POWER_ON, PowerStatus::On)
517 }
518
519 /// Powers off the device attached to a bay.
520 pub fn power_off(&self, bay: BayUid) -> Result<(), ControlError> {
521 self.set_power(bay, RcAction::POWER_OFF, PowerStatus::Off)
522 }
523
524 fn set_power(
525 &self,
526 bay: BayUid,
527 action: RcAction,
528 power: PowerStatus,
529 ) -> Result<(), ControlError> {
530 self.shared.command(move |state| {
531 let (device, _) = bay_of(state, bay)?;
532 Ok(Command::new(
533 Addressee::device(device),
534 op::RC_TX_ACTION,
535 build_rc_action(device.uid, bay.port, action),
536 )
537 .then(move |state, ev| {
538 if let Some(b) = state.bay_mut(bay) {
539 b.set_power_status(power, ev);
540 }
541 }))
542 })
543 }
544
545 /// Sets a bay's volume, as a percentage, and optionally its mute state.
546 ///
547 /// Both channels are set together: the wire carries them separately, but
548 /// nothing on this surface splits them.
549 ///
550 /// A bay with no volume control of its own is set through its
551 /// [`linked_bay`](crate::BayInfo::linked_bay), so an output wired to an
552 /// amplifier zone reaches that zone. [`volume_up`](Self::volume_up),
553 /// [`volume_down`](Self::volume_down) and [`set_muted`](Self::set_muted)
554 /// follow the same link, and read the volume they step from through it.
555 pub fn set_volume(
556 &self,
557 bay: BayUid,
558 volume: u8,
559 muted: Option<bool>,
560 ) -> Result<(), ControlError> {
561 let volume = volume.min(100);
562 let wanted = VolumeMuteStatus {
563 volume_left: Some(volume),
564 volume_right: Some(volume),
565 muted_left: muted,
566 muted_right: muted,
567 };
568 self.shared.command(move |state| {
569 // The mesh may put this bay's volume control on another device, and
570 // the command belongs where the volume lives, not where it was
571 // addressed.
572 let target = state.volume_bay(bay);
573 let (device, b) = bay_of(state, target)?;
574 if !b.has_volume_control() {
575 return Err(ControlError::Unsupported("the bay has no volume control"));
576 }
577 Ok(Command::new(
578 Addressee::device(device),
579 op::AUDIO_SET_VOLUME,
580 build_set_volume(device.uid, target.port, wanted),
581 )
582 .then(move |state, ev| {
583 if let Some(device) = state.device_mut(target.device) {
584 device.apply_bay_volume(target.port, wanted, ev);
585 }
586 }))
587 })
588 }
589
590 /// Raises a bay's volume by one percent.
591 pub fn volume_up(&self, bay: BayUid) -> Result<(), ControlError> {
592 self.set_volume(bay, self.current_volume(bay)?.saturating_add(1), None)
593 }
594
595 /// Lowers a bay's volume by one percent.
596 pub fn volume_down(&self, bay: BayUid) -> Result<(), ControlError> {
597 self.set_volume(bay, self.current_volume(bay)?.saturating_sub(1), None)
598 }
599
600 /// Mutes or unmutes a bay, keeping the volume it is set to.
601 pub fn set_muted(&self, bay: BayUid, muted: bool) -> Result<(), ControlError> {
602 self.set_volume(bay, self.current_volume(bay)?, Some(muted))
603 }
604
605 /// The volume a step or a mute is relative to.
606 fn current_volume(&self, bay: BayUid) -> Result<u8, ControlError> {
607 self.shared.read(|state| {
608 let (_, b) = bay_of(state, state.volume_bay(bay))?;
609 b.audio_volume
610 .map(|v| v.volume())
611 .ok_or(ControlError::NotReported("the bay's volume"))
612 })
613 }
614
615 /// Applies amplifier settings to a zone.
616 pub fn set_amp_zone_settings(
617 &self,
618 bay: BayUid,
619 settings: AmpZoneSettings,
620 ) -> Result<(), ControlError> {
621 self.shared.command(move |state| {
622 let (device, _) = bay_of(state, bay)?;
623 Ok(Command::new(
624 Addressee::device(device),
625 op::AMP_ZONE_SETTINGS,
626 build_amp_zone_settings(device.uid, bay.port, &settings),
627 )
628 .then(move |state, ev| {
629 if let Some(b) = state.bay_mut(bay) {
630 b.set_amp_settings(settings, ev);
631 }
632 }))
633 })
634 }
635
636 // ---- audio endpoints ----
637
638 /// Mutes or unmutes an audio endpoint.
639 pub fn set_audio_endpoint_muted(
640 &self,
641 device: DeviceUid,
642 endpoint: u16,
643 muted: bool,
644 ) -> Result<(), ControlError> {
645 self.audio_endpoint(device, audio_sub::MUTE, endpoint, u32::from(muted))
646 }
647
648 /// Sets an audio endpoint's trigger output.
649 pub fn set_audio_endpoint_trigger(
650 &self,
651 device: DeviceUid,
652 endpoint: u16,
653 active: bool,
654 ) -> Result<(), ControlError> {
655 self.audio_endpoint(device, audio_sub::TRIGGER, endpoint, u32::from(active))
656 }
657
658 /// Locks or unlocks the audio source of an audio endpoint: while it is
659 /// locked, a video route change leaves the endpoint's audio source alone.
660 ///
661 /// Refused unless the endpoint reports
662 /// [`AudioFeatures::AUDIO_LOCK`], which
663 /// is the only one the device acts on. The device reports its endpoints
664 /// again once the lock has changed;
665 /// [`AudioEndpoints::status`](crate::AudioEndpoints::status) reads it.
666 pub fn set_audio_endpoint_locked(
667 &self,
668 device: DeviceUid,
669 endpoint: u8,
670 locked: bool,
671 ) -> Result<(), ControlError> {
672 self.shared.command(move |state| {
673 let d = device_of(state, device)?;
674 let Some(endpoints) = d.audio.as_ref() else {
675 return Err(ControlError::NotReported("the device's audio endpoints"));
676 };
677 if !endpoints
678 .get(endpoint)
679 .is_some_and(|ep| ep.features.has(AudioFeatures::AUDIO_LOCK))
680 {
681 return Err(ControlError::Unsupported(
682 "the endpoint cannot lock its audio source",
683 ));
684 }
685 let mut payload = audio_cmd_header(audio_sub::LOCK, d.uid);
686 payload.extend_from_slice(&audio_param(u16::from(endpoint), u32::from(locked)));
687 Ok(Command::new(Addressee::device(d), op::V2IP_AUDIO, payload))
688 })
689 }
690
691 /// Sets an audio endpoint's volume.
692 ///
693 /// **The audio module has no receiver for this command and ignores it.**
694 /// It builds and sends the same shape as
695 /// [`Self::set_audio_endpoint_muted`], and the send succeeds, because
696 /// nothing on these paths is acknowledged - so a caller sees success and no
697 /// change. The module dispatches this sub-command to the branch it uses for
698 /// one it does not recognise.
699 ///
700 /// It is kept because the command is defined and the module transmits it
701 /// itself, so a receiver may appear; read the endpoint back rather than
702 /// assuming either way.
703 pub fn set_audio_endpoint_volume(
704 &self,
705 device: DeviceUid,
706 endpoint: u16,
707 volume: u32,
708 ) -> Result<(), ControlError> {
709 self.audio_endpoint(device, audio_sub::VOLUME, endpoint, volume)
710 }
711
712 fn audio_endpoint(
713 &self,
714 device: DeviceUid,
715 sub: u16,
716 endpoint: u16,
717 value: u32,
718 ) -> Result<(), ControlError> {
719 self.shared.command(move |state| {
720 let device = device_of(state, device)?;
721 let mut payload = audio_cmd_header(sub, device.uid);
722 payload.extend_from_slice(&audio_param(endpoint, value));
723 Ok(Command::new(
724 Addressee::device(device),
725 op::V2IP_AUDIO,
726 payload,
727 ))
728 })
729 }
730
731 /// Routes a source endpoint on one device to a sink endpoint on another.
732 pub fn select_audio_endpoint_input(
733 &self,
734 sink: DeviceUid,
735 sink_endpoint: u16,
736 source: DeviceUid,
737 source_endpoint: u16,
738 ) -> Result<(), ControlError> {
739 self.shared.command(move |state| {
740 let device = device_of(state, sink)?;
741 Ok(Command::new(
742 Addressee::device(device),
743 op::V2IP_AUDIO,
744 build_audio_select_input(sink, sink_endpoint, source, source_endpoint),
745 ))
746 })
747 }
748
749 // ---- the whole device ----
750
751 /// Starts or stops a V2IP device reporting its transport statistics.
752 ///
753 /// There is no free-running mode: a device reports only while a
754 /// subscription is live, at 1Hz, and the subscription lapses after a
755 /// minute. A caller that wants a continuous feed re-sends inside the
756 /// minute; nothing here re-arms it.
757 ///
758 /// Reports reach [`crate::EventHandler::on_v2ip_stats_changed`] and read
759 /// back through [`Remote::v2ip_stats`]. A device new enough to send it also
760 /// carries what the sink's decoder recovered, in
761 /// [`crate::V2ipDeviceStats::decoder`].
762 pub fn subscribe_v2ip_stats(
763 &self,
764 device: DeviceUid,
765 subscribe: bool,
766 ) -> Result<(), ControlError> {
767 self.shared.command(move |state| {
768 let device = device_of(state, device)?;
769 Ok(Command::new(
770 Addressee::device(device),
771 op::V2IP_STATS,
772 build_stats_request(device.uid, subscribe),
773 ))
774 })
775 }
776
777 /// Asks a device for an EDID: the one the display on its output
778 /// publishes, or the one it presents to the source on its input.
779 ///
780 /// The device answers with a frame the receive path decodes, so the bytes
781 /// arrive at [`crate::EventHandler::on_edid_received`] and stay readable
782 /// through [`Remote::edid`].
783 ///
784 /// Only V2IP hardware handles this opcode. A matrix or an amplifier
785 /// accepts the frame and answers nothing, at any protocol version, so the
786 /// silence that follows is permanent rather than a reply still to come.
787 /// This call cannot tell the two apart and does not try: it reports what
788 /// was sent, and a caller polling for an EDID should ask a device that can
789 /// answer rather than wait on one that cannot.
790 pub fn request_edid(&self, device: DeviceUid, output: bool) -> Result<(), ControlError> {
791 self.shared.command(move |state| {
792 let device = device_of(state, device)?;
793 Ok(Command::new(
794 Addressee::device(device),
795 op::DEV_EDID,
796 build_edid_request(device.uid, output),
797 ))
798 })
799 }
800
801 /// Asks for a detailed signal report from every bay of one device, or -
802 /// with no device named - from every bay on the network.
803 ///
804 /// Devices report on their own when a signal changes, so this is what a
805 /// client that has just started needs: without it, a bay that has been
806 /// showing the same picture for an hour says nothing until it changes.
807 pub fn request_signal_status(&self, device: Option<DeviceUid>) -> Result<(), ControlError> {
808 let Some(device) = device else {
809 self.shared
810 .send(&Addressee::Broadcast, op::BAY_SIGNAL_STATUS, &[])?;
811 return Ok(());
812 };
813 self.shared.command(move |state| {
814 let device = device_of(state, device)?;
815 Ok(Command::new(
816 Addressee::device(device),
817 op::BAY_SIGNAL_STATUS,
818 build_target_only(device.uid),
819 ))
820 })
821 }
822
823 /// Reboots a device.
824 ///
825 /// The device is marked as rebooting once the frame is away, so the
826 /// silence that follows does not read as one that went offline. A device
827 /// on protocol 0x2C or later takes it only from its mesh controller or a
828 /// management application, which this client announces itself as.
829 pub fn reboot(&self, device: DeviceUid) -> Result<(), ControlError> {
830 self.shared.command(move |state| {
831 let d = device_of(state, device)?;
832 Ok(Command::new(
833 Addressee::device(d),
834 op::SYS_REBOOT,
835 build_target_only(d.uid),
836 )
837 .then(move |state, _| {
838 if let Some(d) = state.device_mut(device) {
839 d.rebooting = true;
840 }
841 }))
842 })
843 }
844
845 /// Asks a device to announce itself now.
846 ///
847 /// Its hello marks it online again, so a device suspected to be gone is
848 /// confirmed or ruled out within a second or two rather than at the end of
849 /// its silence window. A device that stays silent is not taken offline
850 /// here: that remains the silence window's call.
851 pub fn ping(&self, device: DeviceUid) -> Result<(), ControlError> {
852 self.shared.command(move |state| {
853 let d = device_of(state, device)?;
854 Ok(Command::new(
855 Addressee::device(d),
856 op::SYS_PING,
857 build_target_only(d.uid),
858 ))
859 })
860 }
861
862 /// Hands a V2IP source's stream addresses back to automatic assignment,
863 /// undoing addresses that were set on it by hand.
864 ///
865 /// The device takes this only from management, which this client
866 /// announces itself as. Nothing acknowledges it: the device's next
867 /// configuration report carries the addresses it ends up with, and
868 /// [`Remote::v2ip_details`] reads them.
869 ///
870 /// Refused for a device that is not a V2IP source, and for one below
871 /// protocol 0x2B, which predates the operation and ignores it.
872 pub fn auto_assign_v2ip_source_addresses(&self, device: DeviceUid) -> Result<(), ControlError> {
873 self.shared.command(move |state| {
874 let d = device_of(state, device)?;
875 if !d.hello.features.has(DeviceFeature::V2IP_SOURCE) {
876 return Err(ControlError::Unsupported("the device is not a V2IP source"));
877 }
878 let have = d.hello.supported_protocol;
879 if have != 0 && have < AUTO_ADDRESSES_PROTOCOL {
880 return Err(ControlError::Send(SendError::ProtocolTooOld {
881 serial: d.serial().to_owned(),
882 opcode: op::MESH_OPERATION.0,
883 have,
884 need: AUTO_ADDRESSES_PROTOCOL,
885 }));
886 }
887 Ok(Command::new(
888 Addressee::device(d),
889 op::MESH_OPERATION,
890 build_mesh_operation(mesh_op::AUTO_ADDRESSES, d.uid),
891 ))
892 })
893 }
894
895 // ---- units ----
896
897 /// Clears a unit's current system status.
898 ///
899 /// The terms of [`Remote::upgrade_unit_fpga`] apply.
900 pub fn clear_unit_status(&self, device: DeviceUid) -> Result<(), ControlError> {
901 self.unit_command(
902 device,
903 UnitCommandKind::CLEAR_STATUS,
904 UnitCommandFlag::NONE,
905 None,
906 )
907 }
908
909 /// Upgrades a unit's video processor to the latest image it holds, or
910 /// flashes that image again when `force` is set and it already runs it.
911 ///
912 /// The unit takes a command only from its mesh controller or a management
913 /// application, which this client announces itself as. Nothing
914 /// acknowledges it: [`Remote::unit_status`] reads what the unit reports
915 /// next. Refused for a device that is not a V2IP unit, and for one below
916 /// protocol 0x2C, which predates the command.
917 pub fn upgrade_unit_fpga(&self, device: DeviceUid, force: bool) -> Result<(), ControlError> {
918 let flags = if force {
919 UnitCommandFlag::FORCE
920 } else {
921 UnitCommandFlag::NONE
922 };
923 self.unit_command(device, UnitCommandKind::FPGA_UPGRADE, flags, None)
924 }
925
926 /// Switches a unit to an IP configuration from DHCP.
927 ///
928 /// The terms of [`Remote::set_unit_network`] apply.
929 pub fn set_unit_dhcp(&self, device: DeviceUid) -> Result<(), ControlError> {
930 self.unit_command(
931 device,
932 UnitCommandKind::NET_CONFIG,
933 UnitCommandFlag::DHCP,
934 Some(UnitNetConfig::default()),
935 )
936 }
937
938 /// Gives a unit a static IP configuration.
939 ///
940 /// The address must be one a unit can hold and the netmask a run of ones
941 /// from the top; anything else is refused before it is sent.
942 ///
943 /// The unit applies the change at once and reverts it unless its mesh
944 /// controller, hearing the unit report it, confirms it in time.
945 /// [`UnitStatus::network_pending`](crate::UnitStatus::network_pending) says
946 /// whether it still waits, and
947 /// [`Remote::confirm_unit_network`] confirms it. Otherwise as
948 /// [`Remote::upgrade_unit_fpga`].
949 pub fn set_unit_network(
950 &self,
951 device: DeviceUid,
952 network: UnitNetConfig,
953 ) -> Result<(), ControlError> {
954 if !network.is_valid() {
955 return Err(ControlError::InvalidRequest(
956 "not an address and netmask a unit can hold",
957 ));
958 }
959 self.unit_command(
960 device,
961 UnitCommandKind::NET_CONFIG,
962 UnitCommandFlag::NONE,
963 Some(network),
964 )
965 }
966
967 /// Keeps a unit's pending IP configuration, as its mesh controller does
968 /// when it hears the unit at its new address.
969 ///
970 /// The terms of [`Remote::upgrade_unit_fpga`] apply.
971 pub fn confirm_unit_network(&self, device: DeviceUid) -> Result<(), ControlError> {
972 self.unit_command(
973 device,
974 UnitCommandKind::NET_CONFIRM,
975 UnitCommandFlag::NONE,
976 None,
977 )
978 }
979
980 /// The one send behind the unit command methods.
981 fn unit_command(
982 &self,
983 device: DeviceUid,
984 command: UnitCommandKind,
985 flags: UnitCommandFlag,
986 network: Option<UnitNetConfig>,
987 ) -> Result<(), ControlError> {
988 self.shared.command(move |state| {
989 let d = device_of(state, device)?;
990 if !d.is_v2ip() {
991 return Err(ControlError::Unsupported("the device is not a V2IP unit"));
992 }
993 Ok(Command::new(
994 Addressee::device(d),
995 op::SYS_UNIT_COMMAND,
996 build_unit_command(d.uid, command, flags, network.as_ref()),
997 ))
998 })
999 }
1000
1001 /// Sets the time zone of every device that hears it.
1002 ///
1003 /// `zone` is an IANA name such as `Europe/Amsterdam` and `rule` the POSIX
1004 /// TZ rule the devices keep time by, such as `CET-1CEST,M3.5.0,M10.5.0/3`.
1005 /// A device applies the rule and shows the name.
1006 /// Neither may be empty, hold a NUL, or be longer than its field on the
1007 /// wire leaves room for; [`Remote::clear_mesh_time_zone`] sends both empty.
1008 ///
1009 /// The mesh controller takes it too, and announces it from then on with
1010 /// every periodic broadcast. A device takes it only from a management
1011 /// application or the controller, and this client announces itself as a
1012 /// management application.
1013 pub fn set_mesh_time_zone(&self, zone: &str, rule: &str) -> Result<(), ControlError> {
1014 let fits = |s: &str, len: usize| !s.is_empty() && s.len() < len && !s.contains('\0');
1015 if !fits(zone, TIME_ZONE_NAME_LEN) || !fits(rule, TIME_ZONE_RULE_LEN) {
1016 return Err(ControlError::InvalidRequest(
1017 "a time zone name or rule is empty, holds a NUL, or is too long",
1018 ));
1019 }
1020 self.shared.send(
1021 &Addressee::Broadcast,
1022 op::TIME_ZONE,
1023 &build_time_zone(zone, rule),
1024 )?;
1025 Ok(())
1026 }
1027
1028 /// Clears the time zone of every device that hears it, which then keeps
1029 /// UTC. Sent, received and announced as [`Remote::set_mesh_time_zone`]
1030 /// with an empty name and rule.
1031 pub fn clear_mesh_time_zone(&self) -> Result<(), ControlError> {
1032 self.shared.send(
1033 &Addressee::Broadcast,
1034 op::TIME_ZONE,
1035 &build_time_zone("", ""),
1036 )?;
1037 Ok(())
1038 }
1039
1040 /// Sets the clock of every device that hears it to `time`.
1041 ///
1042 /// A device keeps its own clock where that is within 2s of `time`. It takes
1043 /// the time only from a management application or the controller; see
1044 /// [`Remote::set_mesh_time_zone`]. A time before 1970 or past what 32 bits
1045 /// of seconds hold, in 2106, is refused.
1046 pub fn set_mesh_time(&self, time: SystemTime) -> Result<(), ControlError> {
1047 let utc = time
1048 .duration_since(UNIX_EPOCH)
1049 .ok()
1050 .and_then(|d| u32::try_from(d.as_secs()).ok())
1051 .ok_or(ControlError::InvalidRequest(
1052 "the time does not fit the frame",
1053 ))?;
1054 self.shared
1055 .send(&Addressee::Broadcast, op::TIME, &utc.to_le_bytes())?;
1056 Ok(())
1057 }
1058
1059 /// Asks every peer to report its monitoring data now rather than on its own
1060 /// schedule.
1061 pub fn send_monitoring_pulse(&self) -> Result<(), ControlError> {
1062 self.shared
1063 .send(&Addressee::Broadcast, op::SYS_MONITORING_PULSE, &[])?;
1064 Ok(())
1065 }
1066
1067 // ---- V2IP scaling ----
1068
1069 /// Turns a V2IP sink's automatic scaling on or off.
1070 ///
1071 /// Automatic scaling and a configured output mode are separate reasons for
1072 /// a sink to scale, and this moves only the first: a sink with a mode
1073 /// configured goes on scaling to it with automatic scaling off. Turning
1074 /// both off is this call plus [`Remote::clear_v2ip_output_mode`].
1075 ///
1076 /// Nothing acknowledges the frame. Read the sink back through
1077 /// [`Remote::v2ip_details`] to learn what it did, and treat the block as
1078 /// meaningful only where [`crate::DeviceInfo::config_initialised`] is set.
1079 /// **Read any route you still need before writing.** The sink rebuilds
1080 /// and rebroadcasts its subscription in response, and that report can
1081 /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
1082 /// and why.
1083 pub fn set_v2ip_auto_scaling(
1084 &self,
1085 device: DeviceUid,
1086 enabled: bool,
1087 ) -> Result<(), ControlError> {
1088 let written = if enabled {
1089 SCALING_FLAG_OPTIONS_VALID | SCALING_FLAG_AUTO_SCALING
1090 } else {
1091 SCALING_FLAG_OPTIONS_VALID
1092 };
1093 self.set_v2ip_scaling(device, MxrSignalType::NONE, 0, written, move |cached| {
1094 V2ipScalingSettings {
1095 flags: (cached.flags & !SCALING_FLAG_AUTO_SCALING)
1096 | SCALING_FLAG_OPTIONS_VALID
1097 | (written & SCALING_FLAG_AUTO_SCALING),
1098 ..cached
1099 }
1100 })
1101 }
1102
1103 /// Sets the output format a V2IP sink scales to.
1104 ///
1105 /// The mode is checked here and nothing is sent if it fails, because every
1106 /// value a sink refuses it refuses in silence. Passing that check is not a
1107 /// guarantee: the sink also weighs the format against the display's EDID
1108 /// and against what its own output stage can produce.
1109 ///
1110 /// **Turn automatic scaling off first if it is on.** A sink refuses a mode
1111 /// whose format the attached display does not list while it is scaling
1112 /// automatically, and refuses it silently. Setting a mode and then turning
1113 /// automatic scaling back on is the order that survives, because the mode
1114 /// is checked while automatic scaling is still off.
1115 ///
1116 /// Configuring a mode is itself a reason to scale, so a sink with one
1117 /// scales whether or not automatic scaling is on.
1118 ///
1119 /// **Pass a descriptor and a refresh rate that agree.** A sink stores both
1120 /// halves and, with its match-source setting on as it ships, reports back
1121 /// the descriptor matching the refresh it holds: a 60Hz descriptor written
1122 /// with a refresh of 50 reads back as that descriptor's 50Hz sibling, once,
1123 /// and stays there. A sink with match-source off reports the descriptor it
1124 /// was given. Either way a pair that agrees reads back unchanged, and the
1125 /// format driven is the same - so this costs a caller nothing except a
1126 /// descriptor it did not write. That substitution shows up on the sink's
1127 /// next report rather than immediately, because
1128 /// [`crate::V2ipScalingSettings`] holds what was written until then.
1129 ///
1130 /// A mode read from the sink's own web interface is not interchangeable
1131 /// with this pair. That interface reports the descriptor's 60Hz sibling and
1132 /// carries the refresh in a field of its own, so writing back what it shows
1133 /// as the mode, on its own, changes the setting rather than restoring it.
1134 /// **Read any route you still need before writing.** The sink rebuilds
1135 /// and rebroadcasts its subscription in response, and that report can
1136 /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
1137 /// and why.
1138 pub fn set_v2ip_output_mode(
1139 &self,
1140 device: DeviceUid,
1141 mode: V2ipOutputMode,
1142 ) -> Result<(), ControlError> {
1143 mode.validate().map_err(ControlError::InvalidRequest)?;
1144 let signal = mode.to_signal_type();
1145 let refresh = mode.refresh;
1146 self.set_v2ip_scaling(
1147 device,
1148 signal,
1149 refresh,
1150 SCALING_FLAG_MODE_VALID,
1151 move |cached| V2ipScalingSettings {
1152 mode: signal,
1153 refresh,
1154 flags: cached.flags | SCALING_FLAG_MODE_VALID,
1155 },
1156 )
1157 }
1158
1159 /// Clears the output format a V2IP sink is configured to scale to.
1160 ///
1161 /// The sink stops scaling for that reason and keeps its automatic scaling
1162 /// setting, so a sink scaling for both reasons goes on scaling until
1163 /// [`Remote::set_v2ip_auto_scaling`] turns the other one off.
1164 ///
1165 /// This is the only way to express "no mode configured", and it is what a
1166 /// caller restoring a sink that had none has to send: a sink reports no
1167 /// mode by leaving the mode's valid bit clear, which is not something a
1168 /// write can say.
1169 /// **Read any route you still need before writing.** The sink rebuilds
1170 /// and rebroadcasts its subscription in response, and that report can
1171 /// arrive empty for up to a minute; [`crate::DeviceV2ipSink`] says when
1172 /// and why.
1173 pub fn clear_v2ip_output_mode(&self, device: DeviceUid) -> Result<(), ControlError> {
1174 // The valid bit with a zero descriptor is the clear. The receiver takes
1175 // that branch ahead of validating anything, and ignores the depth,
1176 // colour space and refresh rate beside it.
1177 self.set_v2ip_scaling(
1178 device,
1179 MxrSignalType::NONE,
1180 0,
1181 SCALING_FLAG_MODE_VALID,
1182 |cached| V2ipScalingSettings {
1183 mode: MxrSignalType::NONE,
1184 refresh: 0,
1185 flags: cached.flags & !SCALING_FLAG_MODE_VALID,
1186 },
1187 )
1188 }
1189
1190 /// The one send behind the scaling methods.
1191 ///
1192 /// `written` is the flag byte that goes out, and `applied` says what the
1193 /// sink will report afterwards. The two differ where the wire spells a
1194 /// write differently from the state it produces - clearing a mode is sent
1195 /// as the valid bit over a zero mode and read back as the valid bit clear -
1196 /// so predicting the cached value from the frame alone would leave a
1197 /// caller reading a state no device ever broadcasts.
1198 fn set_v2ip_scaling(
1199 &self,
1200 device: DeviceUid,
1201 mode: MxrSignalType,
1202 refresh: u16,
1203 written: u8,
1204 applied: impl FnOnce(V2ipScalingSettings) -> V2ipScalingSettings + Send + 'static,
1205 ) -> Result<(), ControlError> {
1206 self.shared.command(move |state| {
1207 let d = device_of(state, device)?;
1208 if !d.is_v2ip_sink() {
1209 return Err(ControlError::Unsupported(
1210 "scaling settings need a V2IP sink",
1211 ));
1212 }
1213 Ok(Command::new(
1214 Addressee::device(d),
1215 op::V2IP_DEVICE_CFG,
1216 build_v2ip_scaling(d.uid, mode, refresh, written),
1217 )
1218 .then(move |state, ev| {
1219 if let Some(d) = state.device_mut(device) {
1220 let cached = d.v2ip_scaling();
1221 d.set_v2ip_scaling(applied(cached), ev);
1222 }
1223 }))
1224 })
1225 }
1226
1227 // ---- V2IP device settings ----
1228
1229 /// Switches on/off settings of a V2IP device, all to the same value.
1230 ///
1231 /// `setting` names one or more of [`V2ipDeviceSetting::SWITCHES`], and
1232 /// each must be one the device has reported: a device ignores a setting it
1233 /// does not have, so a write for one would read back as applied here and
1234 /// change nothing there.
1235 ///
1236 /// Nothing acknowledges the frame. The device answers by reporting its
1237 /// settings, and until then [`Remote::v2ip_device_settings`] reads back
1238 /// what was written.
1239 pub fn set_v2ip_device_setting(
1240 &self,
1241 device: DeviceUid,
1242 setting: V2ipDeviceSetting,
1243 enabled: bool,
1244 ) -> Result<(), ControlError> {
1245 if setting.is_empty() || !setting.without(V2ipDeviceSetting::SWITCHES).is_empty() {
1246 return Err(ControlError::InvalidRequest(
1247 "only on/off device settings are switched",
1248 ));
1249 }
1250 self.set_v2ip_device_settings(
1251 device,
1252 V2ipDeviceSettings {
1253 valid: setting,
1254 flags: if enabled {
1255 setting
1256 } else {
1257 V2ipDeviceSetting::NONE
1258 },
1259 ..V2ipDeviceSettings::default()
1260 },
1261 )
1262 }
1263
1264 /// Sets the infrared profile of a V2IP device's global infrared port.
1265 ///
1266 /// `profile` is below [`V2IP_IR_PROFILE_MAX`], and is checked here because
1267 /// a device ignores one out of range. The terms of
1268 /// [`Remote::set_v2ip_device_setting`] apply.
1269 pub fn set_v2ip_ir_profile(&self, device: DeviceUid, profile: i8) -> Result<(), ControlError> {
1270 if !(0..V2IP_IR_PROFILE_MAX).contains(&profile) {
1271 return Err(ControlError::InvalidRequest("no such infrared profile"));
1272 }
1273 self.set_v2ip_device_settings(
1274 device,
1275 V2ipDeviceSettings {
1276 valid: V2ipDeviceSetting::IR_PROFILE,
1277 ir_profile: profile,
1278 ..V2ipDeviceSettings::default()
1279 },
1280 )
1281 }
1282
1283 /// Sets the infrared profile of a V2IP device's output infrared port.
1284 ///
1285 /// [`V2IP_IR_PROFILE_NOT_SET`] makes the port follow the global one.
1286 /// Otherwise as [`Remote::set_v2ip_ir_profile`].
1287 pub fn set_v2ip_sink_ir_profile(
1288 &self,
1289 device: DeviceUid,
1290 profile: i8,
1291 ) -> Result<(), ControlError> {
1292 if !(V2IP_IR_PROFILE_NOT_SET..V2IP_IR_PROFILE_MAX).contains(&profile) {
1293 return Err(ControlError::InvalidRequest("no such infrared profile"));
1294 }
1295 self.set_v2ip_device_settings(
1296 device,
1297 V2ipDeviceSettings {
1298 valid: V2ipDeviceSetting::IR_PROFILE_SINK,
1299 ir_profile_sink: profile,
1300 ..V2ipDeviceSettings::default()
1301 },
1302 )
1303 }
1304
1305 /// Sets how many idle minutes a V2IP device waits before it powers down by
1306 /// itself, 0 for never. The terms of [`Remote::set_v2ip_device_setting`]
1307 /// apply.
1308 pub fn set_v2ip_auto_power_save(
1309 &self,
1310 device: DeviceUid,
1311 minutes: u16,
1312 ) -> Result<(), ControlError> {
1313 self.set_v2ip_device_settings(
1314 device,
1315 V2ipDeviceSettings {
1316 valid: V2ipDeviceSetting::AUTO_POWER_SAVE,
1317 auto_power_save: minutes,
1318 ..V2ipDeviceSettings::default()
1319 },
1320 )
1321 }
1322
1323 /// Sets a V2IP device's daily power save windows.
1324 ///
1325 /// Every time is below [`V2IP_MINUTES_PER_DAY`](crate::V2IP_MINUTES_PER_DAY),
1326 /// and is checked here.
1327 /// The windows are kept in the device's own time zone. The terms of
1328 /// [`Remote::set_v2ip_device_setting`] apply.
1329 pub fn set_v2ip_power_save_schedule(
1330 &self,
1331 device: DeviceUid,
1332 schedule: V2ipPowerSaveSchedule,
1333 ) -> Result<(), ControlError> {
1334 if !schedule.is_valid() {
1335 return Err(ControlError::InvalidRequest(
1336 "a power save time is not a time of day",
1337 ));
1338 }
1339 self.set_v2ip_device_settings(
1340 device,
1341 V2ipDeviceSettings {
1342 valid: V2ipDeviceSetting::POWER_SAVE_SCHEDULE,
1343 power_save: schedule,
1344 ..V2ipDeviceSettings::default()
1345 },
1346 )
1347 }
1348
1349 /// Sets the PTP mode a V2IP device runs. The terms of
1350 /// [`Remote::set_v2ip_device_setting`] apply.
1351 pub fn set_v2ip_ptp_mode(
1352 &self,
1353 device: DeviceUid,
1354 mode: V2ipPtpMode,
1355 ) -> Result<(), ControlError> {
1356 if mode > V2ipPtpMode::AUTO {
1357 return Err(ControlError::InvalidRequest("no such PTP mode"));
1358 }
1359 self.set_v2ip_device_settings(
1360 device,
1361 V2ipDeviceSettings {
1362 valid: V2ipDeviceSetting::PTP_MODE,
1363 ptp_mode: mode,
1364 ..V2ipDeviceSettings::default()
1365 },
1366 )
1367 }
1368
1369 /// Sets the PTP domain a V2IP device runs in, at most
1370 /// [`V2IP_PTP_DOMAIN_MAX`](crate::V2IP_PTP_DOMAIN_MAX). The terms of
1371 /// [`Remote::set_v2ip_device_setting`] apply.
1372 pub fn set_v2ip_ptp_domain(&self, device: DeviceUid, domain: u8) -> Result<(), ControlError> {
1373 if domain > V2IP_PTP_DOMAIN_MAX {
1374 return Err(ControlError::InvalidRequest("no such PTP domain"));
1375 }
1376 self.set_v2ip_device_settings(
1377 device,
1378 V2ipDeviceSettings {
1379 valid: V2ipDeviceSetting::PTP_DOMAIN,
1380 ptp_domain: domain,
1381 ..V2ipDeviceSettings::default()
1382 },
1383 )
1384 }
1385
1386 /// Sets the priority1 a V2IP device announces as a PTP grandmaster, lower
1387 /// winning. The terms of [`Remote::set_v2ip_device_setting`] apply.
1388 pub fn set_v2ip_ptp_priority1(
1389 &self,
1390 device: DeviceUid,
1391 priority1: u8,
1392 ) -> Result<(), ControlError> {
1393 self.set_v2ip_device_settings(
1394 device,
1395 V2ipDeviceSettings {
1396 valid: V2ipDeviceSetting::PTP_PRIORITY1,
1397 ptp_priority1: priority1,
1398 ..V2ipDeviceSettings::default()
1399 },
1400 )
1401 }
1402
1403 /// Changes settings on every V2IP device of the mesh with one frame.
1404 ///
1405 /// `settings` carries the settings behind their bits in
1406 /// [`valid`](V2ipDeviceSettings::valid), as a device reports them. Each
1407 /// device applies those it has and ignores the rest, and none that
1408 /// predates the frame applies any. A setting only a device reports about
1409 /// itself, a profile, PTP mode or PTP domain out of range and a schedule
1410 /// time that is not a time of day are refused here, since every device
1411 /// would ignore them.
1412 ///
1413 /// Nothing is cached: each device that applies a change reports its
1414 /// settings, and [`Remote::v2ip_device_settings`] reads that.
1415 pub fn set_all_v2ip_device_settings(
1416 &self,
1417 settings: V2ipDeviceSettings,
1418 ) -> Result<(), ControlError> {
1419 let valid = settings.valid;
1420 if valid.is_empty() {
1421 return Err(ControlError::InvalidRequest("no setting is carried"));
1422 }
1423 if !(valid & V2ipDeviceSetting::REPORTED_ONLY).is_empty() {
1424 return Err(ControlError::InvalidRequest(
1425 "a setting only a device reports is carried",
1426 ));
1427 }
1428 if valid.has(V2ipDeviceSetting::IR_PROFILE)
1429 && !(0..V2IP_IR_PROFILE_MAX).contains(&settings.ir_profile)
1430 {
1431 return Err(ControlError::InvalidRequest("no such infrared profile"));
1432 }
1433 if valid.has(V2ipDeviceSetting::IR_PROFILE_SINK)
1434 && !(V2IP_IR_PROFILE_NOT_SET..V2IP_IR_PROFILE_MAX).contains(&settings.ir_profile_sink)
1435 {
1436 return Err(ControlError::InvalidRequest("no such infrared profile"));
1437 }
1438 if valid.has(V2ipDeviceSetting::POWER_SAVE_SCHEDULE) && !settings.power_save.is_valid() {
1439 return Err(ControlError::InvalidRequest(
1440 "a power save time is not a time of day",
1441 ));
1442 }
1443 if valid.has(V2ipDeviceSetting::PTP_MODE) && settings.ptp_mode > V2ipPtpMode::AUTO {
1444 return Err(ControlError::InvalidRequest("no such PTP mode"));
1445 }
1446 if valid.has(V2ipDeviceSetting::PTP_DOMAIN) && settings.ptp_domain > V2IP_PTP_DOMAIN_MAX {
1447 return Err(ControlError::InvalidRequest("no such PTP domain"));
1448 }
1449 self.shared.send(
1450 &Addressee::Broadcast,
1451 op::V2IP_SETTINGS_ALL,
1452 &build_v2ip_settings_all(&settings),
1453 )?;
1454 Ok(())
1455 }
1456
1457 /// The one send behind the device settings methods.
1458 fn set_v2ip_device_settings(
1459 &self,
1460 device: DeviceUid,
1461 settings: V2ipDeviceSettings,
1462 ) -> Result<(), ControlError> {
1463 self.shared.command(move |state| {
1464 let d = device_of(state, device)?;
1465 let Some(reported) = d.v2ip_settings else {
1466 return Err(ControlError::NotReported("the device's settings"));
1467 };
1468 if !reported.valid.has(settings.valid) {
1469 return Err(ControlError::Unsupported(
1470 "the device does not have this setting",
1471 ));
1472 }
1473 Ok(Command::new(
1474 Addressee::device(d),
1475 op::V2IP_DEVICE_CFG,
1476 build_v2ip_device_settings(d.uid, &settings),
1477 )
1478 .then(move |state, ev| {
1479 if let Some(d) = state.device_mut(device) {
1480 d.merge_v2ip_settings(settings, ev);
1481 }
1482 }))
1483 })
1484 }
1485
1486 // ---- V2IP VLAN ----
1487
1488 /// Changes a V2IP device's VLAN configuration.
1489 ///
1490 /// Writes the VLAN ids, the pinned uplink and the
1491 /// [`TRUNK`](V2ipVlanFlag::TRUNK) flag of `vlan`; its other flags and the
1492 /// fields only the device reports are not sent. Refused before anything is
1493 /// sent unless the device announces [`DeviceFeature::VLAN`] and has
1494 /// reported its configuration, every id is at most
1495 /// [`V2IP_VLAN_ID_MAX`](crate::V2IP_VLAN_ID_MAX), and the uplink is
1496 /// detected or names a port the device has.
1497 ///
1498 /// The device applies the change at once and reverts it unless the mesh
1499 /// controller, hearing the device report it, confirms it. Nothing is
1500 /// cached here: [`Remote::v2ip_vlan`] reads what the device reports, and
1501 /// [`V2ipVlan::is_pending`] whether it is still to be confirmed.
1502 pub fn set_v2ip_vlan(&self, device: DeviceUid, vlan: V2ipVlan) -> Result<(), ControlError> {
1503 if !vlan.is_valid() {
1504 return Err(ControlError::InvalidRequest(
1505 "a VLAN id is out of range or the uplink names no port",
1506 ));
1507 }
1508 let written = V2ipVlan {
1509 flags: V2ipVlanFlag::VALID | (vlan.flags & V2ipVlanFlag::TRUNK),
1510 device: vlan.device,
1511 port: vlan.port,
1512 uplink: vlan.uplink,
1513 active_uplink: 0,
1514 revert_s: 0,
1515 };
1516 self.shared.command(move |state| {
1517 let d = device_of(state, device)?;
1518 if !d.hello.features.has(DeviceFeature::VLAN) {
1519 return Err(ControlError::Unsupported("the device does not take VLANs"));
1520 }
1521 let Some(reported) = d.v2ip_vlan else {
1522 return Err(ControlError::NotReported("the device's VLAN configuration"));
1523 };
1524 if written.pinned_uplink() == Some(V2IP_VLAN_PORT_SFP) && !reported.has_sfp() {
1525 return Err(ControlError::Unsupported("the device has no SFP port"));
1526 }
1527 Ok(Command::new(
1528 Addressee::device(d),
1529 op::V2IP_DEVICE_CFG,
1530 build_v2ip_vlan(d.uid, &written),
1531 ))
1532 })
1533 }
1534
1535 // ---- V2IP test card ----
1536
1537 /// Asks a V2IP sink for its test card, which it reports straight back;
1538 /// [`Remote::v2ip_testcard`] reads it.
1539 ///
1540 /// Refused unless the sink's video processor has reported
1541 /// [`SINK_TEST_PATTERN`](V2ipFpgaFeature::SINK_TEST_PATTERN). A sink with
1542 /// that feature but without the module that draws the test card does not
1543 /// answer.
1544 pub fn request_v2ip_testcard(&self, device: DeviceUid) -> Result<(), ControlError> {
1545 self.send_v2ip_testcard(device, testcard_type::REQUEST, 0, V2ipTestcard::default())
1546 }
1547
1548 /// Shows a test pattern on a V2IP sink's output, or none for
1549 /// [`V2ipTestPattern::OFF`]. `colour` is `0xRRGGBB`, used by
1550 /// [`V2ipTestPattern::FLAT`].
1551 ///
1552 /// A pattern runs until it is turned off, and holds the output on while it
1553 /// does. The sink reports its test card in answer. Refused as
1554 /// [`Remote::request_v2ip_testcard`] is, and for a pattern this library
1555 /// does not name or a colour above `0xFFFFFF`.
1556 pub fn set_v2ip_test_pattern(
1557 &self,
1558 device: DeviceUid,
1559 pattern: V2ipTestPattern,
1560 colour: u32,
1561 ) -> Result<(), ControlError> {
1562 if pattern.to_wire() > V2ipTestPattern::CARD.to_wire() || colour > 0x00FF_FFFF {
1563 return Err(ControlError::InvalidRequest(
1564 "no such test pattern, or a colour wider than 24 bits",
1565 ));
1566 }
1567 self.send_v2ip_testcard(
1568 device,
1569 testcard_type::SET,
1570 testcard_part::PATTERN,
1571 V2ipTestcard {
1572 pattern,
1573 colour,
1574 ..V2ipTestcard::default()
1575 },
1576 )
1577 }
1578
1579 /// Plays a test tone on a V2IP sink's output.
1580 ///
1581 /// Every value is checked here, as the sink ignores a tone that is not
1582 /// [`V2ipTestTone::is_valid`]; [`V2ipToneMode::OFF`] stops it whatever the
1583 /// rest holds, and the sink keeps those values as its last ones. Otherwise
1584 /// as [`Remote::set_v2ip_test_pattern`].
1585 pub fn set_v2ip_test_tone(
1586 &self,
1587 device: DeviceUid,
1588 tone: V2ipTestTone,
1589 ) -> Result<(), ControlError> {
1590 if tone.mode != V2ipToneMode::OFF && !tone.is_valid() {
1591 return Err(ControlError::InvalidRequest(
1592 "a test tone value is out of range",
1593 ));
1594 }
1595 self.send_v2ip_testcard(
1596 device,
1597 testcard_type::SET,
1598 testcard_part::TONE,
1599 V2ipTestcard {
1600 tone,
1601 ..V2ipTestcard::default()
1602 },
1603 )
1604 }
1605
1606 /// Sets a V2IP sink's lip-sync flash.
1607 ///
1608 /// Checked here as [`V2ipTestSync::is_valid`], since the sink ignores
1609 /// settings that are not. Otherwise as [`Remote::set_v2ip_test_pattern`].
1610 pub fn set_v2ip_test_sync(
1611 &self,
1612 device: DeviceUid,
1613 sync: V2ipTestSync,
1614 ) -> Result<(), ControlError> {
1615 if !sync.is_valid() {
1616 return Err(ControlError::InvalidRequest(
1617 "a lip-sync value is out of range",
1618 ));
1619 }
1620 self.send_v2ip_testcard(
1621 device,
1622 testcard_type::SET,
1623 testcard_part::SYNC,
1624 V2ipTestcard {
1625 sync,
1626 ..V2ipTestcard::default()
1627 },
1628 )
1629 }
1630
1631 /// The one send behind the test card methods. Nothing is cached: the sink
1632 /// answers every frame with its test card.
1633 fn send_v2ip_testcard(
1634 &self,
1635 device: DeviceUid,
1636 kind: u8,
1637 parts: u8,
1638 testcard: V2ipTestcard,
1639 ) -> Result<(), ControlError> {
1640 self.shared.command(move |state| {
1641 let d = device_of(state, device)?;
1642 let Some(features) = d.v2ip_features else {
1643 return Err(ControlError::NotReported(
1644 "the sink's video processor features",
1645 ));
1646 };
1647 if !features.has(V2ipFpgaFeature::SINK_TEST_PATTERN) {
1648 return Err(ControlError::Unsupported(
1649 "the sink cannot draw a test card",
1650 ));
1651 }
1652 Ok(Command::new(
1653 Addressee::device(d),
1654 op::V2IP_TESTCARD,
1655 build_v2ip_testcard(d.uid, kind, parts, &testcard),
1656 ))
1657 })
1658 }
1659
1660 // ---- video wall ----
1661
1662 /// Shows a window on a sink's video wall without persisting it.
1663 ///
1664 /// The window survives until the sink is told otherwise or restarts.
1665 /// [`Remote::revert_video_wall`] puts back whatever was stored.
1666 ///
1667 /// Pass [`crate::VIDEO_WALL_CLEARED`] to show the whole frame again.
1668 pub fn preview_video_wall(
1669 &self,
1670 sink: DeviceUid,
1671 window: VideoWallWindow,
1672 ) -> Result<(), ControlError> {
1673 self.set_video_wall(sink, window, VideoWallOp::PREVIEW)
1674 }
1675
1676 /// Persists a window as a sink's video wall.
1677 ///
1678 /// The geometry is checked here, before anything is sent, because the sink
1679 /// is not guaranteed to check it. A sink running a video-wall module older
1680 /// than 2026083100 writes the window to its configuration *before* asking
1681 /// its video processor to apply it, and the processor's refusal does not
1682 /// undo that write - so an out-of-spec window survives a reboot and is
1683 /// re-offered on every stream restart until something else replaces it. A
1684 /// power cycle does not clear it.
1685 ///
1686 /// Nothing acknowledges this frame either way, so an `Ok` says only that
1687 /// it was sent. Read the sink's state back to learn what it did.
1688 ///
1689 /// Pass [`crate::VIDEO_WALL_CLEARED`] to store "show the whole frame".
1690 pub fn store_video_wall(
1691 &self,
1692 sink: DeviceUid,
1693 window: VideoWallWindow,
1694 ) -> Result<(), ControlError> {
1695 self.set_video_wall(sink, window, VideoWallOp::STORE)
1696 }
1697
1698 /// Restores the window a sink has stored, discarding a preview.
1699 ///
1700 /// Carries no window of its own: the sink already holds the one this puts
1701 /// back.
1702 pub fn revert_video_wall(&self, sink: DeviceUid) -> Result<(), ControlError> {
1703 self.set_video_wall(sink, VIDEO_WALL_CLEARED, VideoWallOp::REVERT)
1704 }
1705
1706 /// The one send behind the three video-wall methods.
1707 ///
1708 /// Validation sits here rather than in each of them, so an operation added
1709 /// later cannot reach the wire without it, and is skipped for a revert
1710 /// because the sink ignores the window on that operation rather than
1711 /// checking it.
1712 ///
1713 /// Passing it is not proof a wall appeared. Two things the sink refuses
1714 /// afterwards are equally silent: a window it will not draw, which it logs
1715 /// and drops, and a sink whose image has no tiling support at all, which
1716 /// takes the window into its own state and then fails to push it to the
1717 /// hardware. Neither reaches the wire, so read the sink back over HTTP to
1718 /// learn a window landed.
1719 fn set_video_wall(
1720 &self,
1721 sink: DeviceUid,
1722 window: VideoWallWindow,
1723 op: VideoWallOp,
1724 ) -> Result<(), ControlError> {
1725 if op != VideoWallOp::REVERT {
1726 window.validate().map_err(ControlError::InvalidRequest)?;
1727 }
1728 self.shared.command(move |state| {
1729 let device = device_of(state, sink)?;
1730 Ok(Command::new(
1731 Addressee::device(device),
1732 op::V2IP_VIDEO_WALL,
1733 build_video_wall(device.uid, window, op),
1734 ))
1735 })
1736 }
1737
1738 // ---- multiviewer ----
1739
1740 /// Sets the window layout.
1741 pub fn set_multiviewer_view_mode(
1742 &self,
1743 device: DeviceUid,
1744 mode: MultiviewerViewMode,
1745 ) -> Result<(), ControlError> {
1746 let mode = mv_setting(mode.to_wire(), 8, "the multiviewer has no such view mode")?;
1747 self.multiviewer(device, mv_sub::VIEW_MODE, &[mode])
1748 }
1749
1750 /// Assigns a source to one window, counting windows from zero.
1751 ///
1752 /// A window index the multiviewer is not currently showing is refused
1753 /// rather than sent: firmware accepts an index one past the last window
1754 /// and writes through the end of the array it indexes, so the frame that
1755 /// would carry it is the one frame this library must never put on the
1756 /// wire. The bound comes from the layout in the multiviewer's last status
1757 /// report, so a multiviewer that has reported none can only be given
1758 /// window zero, which every layout has.
1759 pub fn set_multiviewer_video_source(
1760 &self,
1761 device: DeviceUid,
1762 screen: u8,
1763 source: MultiviewerSource,
1764 ) -> Result<(), ControlError> {
1765 let source = source_index(source, "the source names no multiviewer input")?;
1766 self.shared.command(|state| {
1767 let target = multiviewer_of(state, device)?;
1768 let windows = target
1769 .multiviewer
1770 .as_ref()
1771 .and_then(MultiviewerStatus::window_count)
1772 .unwrap_or(1);
1773 if screen >= windows {
1774 return Err(ControlError::InvalidRequest(
1775 "the window is not one the multiviewer is showing",
1776 ));
1777 }
1778 Ok(mv_command(target, mv_sub::VIDEO_SOURCE, &[screen, source]))
1779 })
1780 }
1781
1782 /// Selects which window's audio is output.
1783 pub fn set_multiviewer_audio_source(
1784 &self,
1785 device: DeviceUid,
1786 source: MultiviewerSource,
1787 ) -> Result<(), ControlError> {
1788 let source = source_index(source, "the audio source names no multiviewer input")?;
1789 self.multiviewer(device, mv_sub::AUDIO_SOURCE, &[source])
1790 }
1791
1792 /// Sets the output volume, as a percentage, and the mute state.
1793 ///
1794 /// A volume above 100 is refused rather than sent. What a multiviewer does
1795 /// with one depends on its module version: from 2026083100 it drops the
1796 /// whole frame, and before that it dropped the volume alone and still
1797 /// acted on the mute beside it. Neither is what the caller asked for, and
1798 /// neither is reported back.
1799 pub fn set_multiviewer_audio_volume(
1800 &self,
1801 device: DeviceUid,
1802 volume: u8,
1803 muted: bool,
1804 ) -> Result<(), ControlError> {
1805 if volume > 100 {
1806 return Err(ControlError::InvalidRequest(
1807 "a multiviewer volume is a percentage",
1808 ));
1809 }
1810 self.multiviewer(device, mv_sub::AUDIO_VOLUME, &[volume, u8::from(muted)])
1811 }
1812
1813 /// Sets the EDID template presented to the sources.
1814 pub fn set_multiviewer_edid_template(
1815 &self,
1816 device: DeviceUid,
1817 template: MultiviewerEdidTemplate,
1818 ) -> Result<(), ControlError> {
1819 let template = mv_setting(
1820 template.to_wire(),
1821 19,
1822 "the multiviewer has no such EDID template",
1823 )?;
1824 self.multiviewer(device, mv_sub::EDID_TEMPLATE, &[template])
1825 }
1826
1827 /// Selects which window receives remote-control passthrough.
1828 pub fn set_multiviewer_remote_control(
1829 &self,
1830 device: DeviceUid,
1831 source: MultiviewerSource,
1832 ) -> Result<(), ControlError> {
1833 let source = source_index(
1834 source,
1835 "the remote-control source names no multiviewer input",
1836 )?;
1837 self.multiviewer(device, mv_sub::ROUTE_RC, &[source])
1838 }
1839
1840 /// Sets how large the picture-in-picture window is.
1841 pub fn set_multiviewer_pip_size(
1842 &self,
1843 device: DeviceUid,
1844 size: MultiviewerPipSize,
1845 ) -> Result<(), ControlError> {
1846 let size = mv_setting(
1847 size.to_wire(),
1848 3,
1849 "the multiviewer has no such picture-in-picture size",
1850 )?;
1851 self.multiviewer(device, mv_sub::PIP_SIZE, &[size])
1852 }
1853
1854 /// Sets which corner the picture-in-picture window sits in.
1855 pub fn set_multiviewer_pip_position(
1856 &self,
1857 device: DeviceUid,
1858 position: MultiviewerPipPosition,
1859 ) -> Result<(), ControlError> {
1860 let position = mv_setting(
1861 position.to_wire(),
1862 4,
1863 "the multiviewer has no such picture-in-picture position",
1864 )?;
1865 self.multiviewer(device, mv_sub::PIP_POSITION, &[position])
1866 }
1867
1868 /// Sets the aspect ratio the windows are scaled to.
1869 pub fn set_multiviewer_aspect_ratio(
1870 &self,
1871 device: DeviceUid,
1872 aspect: MultiviewerAspectRatio,
1873 ) -> Result<(), ControlError> {
1874 let aspect = mv_setting(
1875 aspect.to_wire(),
1876 2,
1877 "the multiviewer has no such aspect ratio",
1878 )?;
1879 self.multiviewer(device, mv_sub::ASPECT, &[aspect])
1880 }
1881
1882 /// Enables or disables switching windows on its own.
1883 pub fn set_multiviewer_auto_switch(
1884 &self,
1885 device: DeviceUid,
1886 enable: bool,
1887 ) -> Result<(), ControlError> {
1888 self.multiviewer(device, mv_sub::AUTO_SWITCH, &[u8::from(enable)])
1889 }
1890
1891 /// Sets the output resolution and refresh rate.
1892 pub fn set_multiviewer_output_mode(
1893 &self,
1894 device: DeviceUid,
1895 mode: MultiviewerOutputMode,
1896 ) -> Result<(), ControlError> {
1897 let mode = mv_setting(
1898 mode.to_wire(),
1899 14,
1900 "the multiviewer has no such output mode",
1901 )?;
1902 self.multiviewer(device, mv_sub::OUTPUT_MODE, &[mode])
1903 }
1904
1905 /// Sets the IT-content flag on the output.
1906 pub fn set_multiviewer_output_itc(
1907 &self,
1908 device: DeviceUid,
1909 mode: MultiviewerItcMode,
1910 ) -> Result<(), ControlError> {
1911 let mode = mv_setting(
1912 mode.to_wire(),
1913 2,
1914 "the multiviewer has no such IT-content mode",
1915 )?;
1916 self.multiviewer(device, mv_sub::OUTPUT_ITC_MODE, &[mode])
1917 }
1918
1919 /// Sets the HDCP version negotiated on the output.
1920 pub fn set_multiviewer_hdcp_mode(
1921 &self,
1922 device: DeviceUid,
1923 mode: MultiviewerHdcpMode,
1924 ) -> Result<(), ControlError> {
1925 let mode = mv_setting(mode.to_wire(), 3, "the multiviewer has no such HDCP mode")?;
1926 self.multiviewer(device, mv_sub::HDCP_MODE, &[mode])
1927 }
1928
1929 /// Maps a source device onto one of the multiviewer's inputs, counting
1930 /// inputs from zero.
1931 ///
1932 /// [`DeviceUid::ZERO`] clears the mapping on a multiviewer running module
1933 /// version 2026083100 or newer, and is stored as a mapping like any other
1934 /// on anything older. No version checks that a mapping names a device on
1935 /// the mesh.
1936 ///
1937 /// Which of the two happened shows in `mappings` on a later status report,
1938 /// where a cleared input reads as [`DeviceUid::ZERO`] only from that same
1939 /// version. It will not be the next frame this multiviewer sends: this is
1940 /// one of the two settings that schedule no status broadcast of their own,
1941 /// so the answer arrives whenever something else prompts one.
1942 pub fn set_multiviewer_input_source(
1943 &self,
1944 device: DeviceUid,
1945 input: u8,
1946 source: DeviceUid,
1947 ) -> Result<(), ControlError> {
1948 if usize::from(input) >= MULTIVIEWER_INPUTS {
1949 return Err(ControlError::InvalidRequest(
1950 "the multiviewer has no such input",
1951 ));
1952 }
1953 let mut args = Vec::with_capacity(24);
1954 args.extend_from_slice(source.as_bytes());
1955 args.push(input);
1956 // mv_config_source_t is 4-aligned behind its uid, so seven bytes of
1957 // padding follow the input index.
1958 args.extend_from_slice(&[0; 7]);
1959 self.multiviewer(device, mv_sub::CONFIG_SOURCE, &args)
1960 }
1961
1962 /// Asks the multiviewer to route its sources itself.
1963 pub fn multiviewer_auto_route(&self, device: DeviceUid) -> Result<(), ControlError> {
1964 self.multiviewer(device, mv_sub::AUTO_ROUTE, &[])
1965 }
1966
1967 fn multiviewer(&self, device: DeviceUid, sub: u8, args: &[u8]) -> Result<(), ControlError> {
1968 self.shared
1969 .command(|state| Ok(mv_command(multiviewer_of(state, device)?, sub, args)))
1970 }
1971}