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