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