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