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