Skip to main content

mx_remote/types/
commands.rs

1// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
2// Copyright (c) 2026 Op den Kamp IT Solutions
3
4//! Payloads of the command and notification opcodes.
5//!
6//! These frames are addressed to a device rather than reporting its state, so
7//! most surface as events rather than cached state. A frame addressed to
8//! another unit still reaches every client on the group: the target field says
9//! who it was for, and that is neither necessarily this client nor the sender.
10
11use core::fmt;
12use std::net::Ipv4Addr;
13
14use crate::wire::{DeviceUid, EdidProfile, RcAction, RcKey};
15
16/// Asks a device, addressed by serial, to switch a sink.
17#[derive(Clone, Debug, Default, PartialEq, Eq)]
18pub struct SetRouteRequest {
19    /// Serial of the device to act on.
20    pub serial: String,
21    /// Output bay to switch.
22    pub sink_bay: u16,
23    /// Source bay to switch it to.
24    pub source_bay: u16,
25    /// Whether to skip the power-on commands that normally accompany a switch.
26    pub no_power_on: bool,
27    /// True when this arrived on `AUDIO_SET_ROUTE` rather than `MX_SET_ROUTE`.
28    pub audio_only: bool,
29}
30
31impl fmt::Display for SetRouteRequest {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        write!(
34            f,
35            "set route on {}: sink={} source={}",
36            self.serial, self.sink_bay, self.source_bay
37        )
38    }
39}
40
41/// One EDID block from a `DEV_EDID` reply.
42///
43/// A reply carries one record per bay mode, so a combined reply produces two.
44#[derive(Clone, Debug, Default, PartialEq, Eq)]
45pub struct EdidRecord {
46    /// True for a sink's EDID, false for a source's.
47    pub output: bool,
48    /// A 256-byte EDID: a base block plus exactly one extension block. A
49    /// display publishing further extension blocks yields only the first.
50    pub data: Vec<u8>,
51}
52
53/// Asks one device for its EDID.
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub struct EdidRequest {
56    /// The device being asked.
57    pub target: DeviceUid,
58    /// Whether the sink's EDID is wanted rather than the source's.
59    pub output: bool,
60}
61
62/// Asks a device to rename one of its bays.
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
64pub struct BayNameChange {
65    /// The device to act on.
66    pub target: DeviceUid,
67    /// The bay to rename.
68    pub port: u16,
69    /// The new name.
70    pub name: String,
71}
72
73/// Asks a device to switch its input EDID profile.
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75pub struct EdidProfileChange {
76    /// The device to act on.
77    pub target: DeviceUid,
78    /// The profile to switch to.
79    pub profile: EdidProfile,
80}
81
82/// Asks peers to factory-reset.
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84pub struct FactoryResetRequest {
85    /// Set by the broadcast form, which targets every peer.
86    pub all: bool,
87    /// Set by the single-uid form. With neither this nor `all`, the request
88    /// addresses only the sender.
89    pub target: Option<DeviceUid>,
90}
91
92/// Asks one device to reboot.
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
94pub struct RebootRequest {
95    /// The device to reboot.
96    pub target: DeviceUid,
97}
98
99/// The window a sink is currently told to show.
100///
101/// This is the readable, pollable view of a sink's window. It is not the
102/// persisted video wall setting: on a sink running the v2ipwall module a write
103/// here is transient, because that module's reconciler pushes its own target
104/// window back within about a second. [`VideoWallCommand`] carries intent.
105#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
106pub struct V2ipTilingConfig {
107    /// The sink this window belongs to.
108    pub target: DeviceUid,
109    /// Window origin, horizontal.
110    pub pos_x: u16,
111    /// Window origin, vertical.
112    pub pos_y: u16,
113    /// Window width.
114    pub width: u16,
115    /// Window height.
116    pub height: u16,
117}
118
119impl fmt::Display for V2ipTilingConfig {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        write!(
122            f,
123            "tiling x={} y={} {}x{}",
124            self.pos_x, self.pos_y, self.width, self.height
125        )
126    }
127}
128
129/// Asks a sink to enter or leave power save.
130#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
131pub struct V2ipPowerSaveRequest {
132    /// The sink to act on, or `None` on the broadcast form.
133    pub target: Option<DeviceUid>,
134    /// Whether power save is being entered.
135    pub enabled: bool,
136}
137
138/// The remote-control configuration of a source bay.
139#[derive(Clone, Debug, Default, PartialEq, Eq)]
140pub struct RcSettings {
141    /// The device this configuration belongs to.
142    pub target: DeviceUid,
143    /// The control method.
144    ///
145    /// A single byte: the enum is plain and Cortex-M builds with
146    /// `-fshort-enums`, so three bytes of padding follow it before the address.
147    /// That padding is not zero - firmware copies an uncleared stack local over
148    /// the payload - so widening this to a `u32` makes one unchanged setting
149    /// decode differently on every frame.
150    pub rc_target: u8,
151    /// The control target's address, `None` when unset.
152    pub ip: Option<Ipv4Addr>,
153    /// Whether CEC is enabled.
154    pub cec_enabled: bool,
155    /// Whether CEC powers the sink on automatically.
156    pub cec_auto_on: bool,
157    /// Whether remote-control commands are forwarded.
158    pub forward_rc: bool,
159    /// Whether infrared is forwarded.
160    pub forward_ir: bool,
161    /// The driver state on the source.
162    ///
163    /// A value above the last one this library knows is passed through as it
164    /// arrived rather than clamped, so a firmware update cannot make it read as
165    /// a known state.
166    pub rc_status: u8,
167    /// The driver-reported status string, empty when unknown.
168    pub status_name: String,
169}
170
171/// The raw-IR metadata shared by the IR capture and transmit frames.
172#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
173pub struct IrMeta {
174    /// Tick length of the timing values.
175    pub timer_resolution: u16,
176    /// Carrier frequency in Hz.
177    pub frequency: u16,
178    /// Number of timing values that follow.
179    pub nb_timings: u16,
180    /// Index at which the repeat section starts.
181    pub repeat_offset: u16,
182    /// Capture status.
183    pub status: u8,
184}
185
186/// Raw IR captured on a bay of the sending device.
187#[derive(Clone, Debug, Default, PartialEq, Eq)]
188pub struct IrCapture {
189    /// The bay that captured it.
190    pub port: u16,
191    /// Sender clock at capture time.
192    pub timestamp: u32,
193    /// Sender clock at the last signal change.
194    pub last_change: u32,
195    /// Metadata for the timings.
196    pub meta: IrMeta,
197    /// The raw on/off timing blob following the header.
198    pub timings: Vec<u8>,
199}
200
201/// Asks one device to blast raw IR on one of its local bays.
202#[derive(Clone, Debug, Default, PartialEq, Eq)]
203pub struct IrTransmitRequest {
204    /// The device to act on.
205    pub target: DeviceUid,
206    /// Bay mode in the target's own numbering, not a port.
207    pub local_mode: u8,
208    /// Bay number in the target's own numbering, not a port.
209    pub local_bay: u8,
210    /// Sender clock at send time.
211    pub timestamp: u32,
212    /// Metadata for the timings.
213    pub meta: IrMeta,
214    /// The raw on/off timing blob following the header.
215    pub timings: Vec<u8>,
216}
217
218/// Asks one device to send a remote-control key on a bay.
219#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
220pub struct KeyTransmitRequest {
221    /// The device to act on.
222    pub target: DeviceUid,
223    /// Bay in the target's own numbering.
224    pub local_bay: u16,
225    /// The key to send.
226    pub key: RcKey,
227}
228
229/// Asks one device to perform a remote-control action.
230#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
231pub struct ActionTransmitRequest {
232    /// The device to act on.
233    pub target: DeviceUid,
234    /// Bay in the target's own numbering.
235    pub local_bay: u16,
236    /// The action to perform.
237    pub action: RcAction,
238}
239
240/// Reports that a bay detected audio clipping.
241#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
242pub struct AudioClip {
243    /// The bay that clipped.
244    pub port: u16,
245    /// The clip level reported.
246    pub clip: u8,
247}
248
249/// The electrical state a PDU reports.
250#[derive(Clone, Copy, Debug, Default, PartialEq)]
251pub struct PduState {
252    /// Current in amperes.
253    pub current: f64,
254    /// Voltage in volts.
255    pub voltage: f64,
256    /// Real power in watts.
257    pub power: f64,
258    /// Dissipation in watts.
259    pub dissipation: f64,
260    /// Mains frequency in Hz.
261    pub frequency: f64,
262    /// Per-outlet state.
263    pub outlets: [u8; 8],
264}
265
266impl fmt::Display for PduState {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        write!(
269            f,
270            "{:.2}A {:.2}V {:.2}W",
271            self.current, self.voltage, self.power
272        )
273    }
274}
275
276/// Registers or unregisters a device on the source blacklist.
277///
278/// The firmware guards this opcode behind `V2IP_SUPPORT_BLACKLIST`, which is 0
279/// in shipping builds, so nothing in current firmware emits it.
280#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
281pub struct V2ipBlacklistChange {
282    /// The device being listed.
283    pub target: DeviceUid,
284    /// Whether it is being registered rather than removed.
285    pub registered: bool,
286}
287
288/// What a [`VideoWallCommand`] asks the sink to do with the window.
289#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
290pub struct VideoWallOp(u8);
291
292impl VideoWallOp {
293    /// Applies the window without persisting it.
294    pub const PREVIEW: Self = Self(0);
295    /// Persists the window as the sink's wall setting.
296    pub const STORE: Self = Self(1);
297    /// Restores the persisted setting; carries no window.
298    pub const REVERT: Self = Self(2);
299
300    /// Wraps a raw wire value, including one this library has no name for.
301    pub const fn from_wire(value: u8) -> Self {
302        Self(value)
303    }
304
305    /// Returns the raw wire value.
306    pub const fn to_wire(self) -> u8 {
307        self.0
308    }
309}
310
311impl fmt::Display for VideoWallOp {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        f.write_str(match *self {
314            Self::PREVIEW => "preview",
315            Self::STORE => "store",
316            Self::REVERT => "revert",
317            _ => "unknown",
318        })
319    }
320}
321
322/// Where a video-wall sink's window sits, and the picture it was measured
323/// against.
324///
325/// The raster travels with the window because only the sender knows what the
326/// installer drew against; a sink deriving it from what it happens to be
327/// showing would place the window against the wrong picture.
328///
329/// Nothing on the receiving side is guaranteed to check any of this, so
330/// [`VideoWallWindow::validate`] runs before every send. See
331/// [`crate::Remote::store_video_wall`] for why that matters.
332#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
333pub struct VideoWallWindow {
334    /// Window origin, horizontal. A multiple of [`VIDEO_WALL_POS_ALIGN`].
335    pub pos_x: u16,
336    /// Window origin, vertical. No alignment constraint.
337    pub pos_y: u16,
338    /// Window width. A multiple of [`VIDEO_WALL_WIDTH_ALIGN`], and at least
339    /// [`VIDEO_WALL_MIN_SIZE`] unless it is zero.
340    pub width: u16,
341    /// Window height, at least [`VIDEO_WALL_MIN_SIZE`] unless it is zero. No
342    /// alignment constraint.
343    pub height: u16,
344    /// Active picture width the window was measured against.
345    pub raster_w: u16,
346    /// Active picture height the window was measured against.
347    pub raster_h: u16,
348}
349
350/// The window that clears a wall, leaving the sink showing the whole frame.
351///
352/// A zero width or height is how the protocol spells "clear", so this is a
353/// legitimate window rather than one that fails [`VideoWallWindow::validate`].
354pub const VIDEO_WALL_CLEARED: VideoWallWindow = VideoWallWindow {
355    pos_x: 0,
356    pos_y: 0,
357    width: 0,
358    height: 0,
359    raster_w: 0,
360    raster_h: 0,
361};
362
363/// The horizontal origin must be a multiple of this: the sink's buffer start
364/// has to be aligned.
365pub const VIDEO_WALL_POS_ALIGN: u16 = 64;
366
367/// The width must be a multiple of this: the sink's pipeline moves four pixels
368/// per clock.
369pub const VIDEO_WALL_WIDTH_ALIGN: u16 = 4;
370
371/// Neither side of a window may be smaller than this, the scaler's minimum.
372pub const VIDEO_WALL_MIN_SIZE: u16 = 64;
373
374impl VideoWallWindow {
375    /// Reports whether this window clears the wall rather than placing one.
376    pub const fn is_cleared(&self) -> bool {
377        self.width == 0 || self.height == 0
378    }
379
380    /// Checks the geometry the sink is not guaranteed to check itself.
381    ///
382    /// The three alignments follow the sink's hardware and a live unit reports
383    /// them over HTTP, so a caller with access to one can read them rather
384    /// than trust the constants here. The containment rule has no such source:
385    /// it is checked by the sink's own HTTP path and by nothing on the mesh
386    /// path, at any version.
387    ///
388    /// A cleared window passes: zero is the protocol's word for "clear", not a
389    /// window too small to draw.
390    pub fn validate(&self) -> Result<(), &'static str> {
391        if self.is_cleared() {
392            return Ok(());
393        }
394        if self.pos_x % VIDEO_WALL_POS_ALIGN != 0 {
395            return Err("a video wall window's horizontal origin must be a multiple of 64");
396        }
397        if self.width % VIDEO_WALL_WIDTH_ALIGN != 0 {
398            return Err("a video wall window's width must be a multiple of 4");
399        }
400        if self.width < VIDEO_WALL_MIN_SIZE || self.height < VIDEO_WALL_MIN_SIZE {
401            return Err("neither side of a video wall window may be smaller than 64");
402        }
403        // Widened, because a window running off the raster is exactly the case
404        // where a u16 sum would wrap and read as containment.
405        if u32::from(self.pos_x) + u32::from(self.width) > u32::from(self.raster_w)
406            || u32::from(self.pos_y) + u32::from(self.height) > u32::from(self.raster_h)
407        {
408            return Err("a video wall window must fit inside the raster it names");
409        }
410        Ok(())
411    }
412}
413
414impl fmt::Display for VideoWallWindow {
415    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416        if self.is_cleared() {
417            return f.write_str("cleared");
418        }
419        write!(
420            f,
421            "{}x{}+{}+{} of {}x{}",
422            self.width, self.height, self.pos_x, self.pos_y, self.raster_w, self.raster_h
423        )
424    }
425}
426
427/// Asks one sink to crop its source to a wall window.
428///
429/// This replaces the sink's window outright: unlike a V2IP device config, no
430/// field carries a validity marker, and a zero width or height is the wire
431/// spelling of "clear the wall and show the full frame" rather than "unset".
432///
433/// The opcode belongs to the loadable v2ipwall module rather than MatrixOS, and
434/// a wall has no object of its own on the wire: it is a set of sinks each
435/// holding one rectangle, one frame each. It is a command with no reply, so
436/// nothing here is ever a status readback.
437#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
438pub struct VideoWallCommand {
439    /// The sink to act on.
440    pub target: DeviceUid,
441    /// Window origin, horizontal.
442    pub pos_x: u16,
443    /// Window origin, vertical.
444    pub pos_y: u16,
445    /// Window width.
446    pub width: u16,
447    /// Window height.
448    pub height: u16,
449    /// Active picture width the window was authored against.
450    ///
451    /// The raster travels with the window because only the sender knows what
452    /// the installer drew against; a sink deriving it from what it happens to
453    /// be showing would store the window against the wrong picture.
454    pub raster_w: u16,
455    /// Active picture height the window was authored against.
456    pub raster_h: u16,
457    /// What to do with the window.
458    pub op: VideoWallOp,
459}
460
461impl VideoWallCommand {
462    /// Reports whether the geometry in this command is meaningful.
463    ///
464    /// A revert zeroes the window and raster and the receiver ignores those
465    /// bytes, so its zeros are not a clear.
466    pub fn has_window(&self) -> bool {
467        self.op != VideoWallOp::REVERT
468    }
469
470    /// Reports a command that clears the wall and shows the full frame.
471    pub fn is_cleared(&self) -> bool {
472        self.has_window() && (self.width == 0 || self.height == 0)
473    }
474}
475
476impl fmt::Display for VideoWallCommand {
477    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
478        if !self.has_window() {
479            return f.write_str("video wall revert");
480        }
481        if self.is_cleared() {
482            return write!(f, "video wall {}: clear", self.op);
483        }
484        write!(
485            f,
486            "video wall {}: {}x{}+{}+{} of {}x{}",
487            self.op, self.width, self.height, self.pos_x, self.pos_y, self.raster_w, self.raster_h
488        )
489    }
490}
491
492/// A command addressed to a multiviewer.
493///
494/// The parameters past the envelope are exposed as raw bytes: the opcode
495/// belongs to the multiviewer module rather than MatrixOS, so beyond the
496/// envelope there is no firmware source here to pin per-sub-command field
497/// semantics against.
498#[derive(Clone, Debug, Default, PartialEq, Eq)]
499pub struct MultiviewerCommand {
500    /// The multiviewer being addressed.
501    pub target: DeviceUid,
502    /// The sub-opcode. A value this library has no name for still arrives.
503    pub op: u8,
504    /// Everything after the envelope, empty when the frame carries none.
505    pub params: Vec<u8>,
506}
507
508impl fmt::Display for MultiviewerCommand {
509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510        write!(
511            f,
512            "multiviewer command {} for {} ({} param bytes)",
513            self.op,
514            self.target,
515            self.params.len()
516        )
517    }
518}
519
520/// An audio input-selection change: which source endpoint a sink endpoint was
521/// switched to.
522///
523/// The sink is named twice on the wire, once as the command header's target and
524/// again at the head of the body; the body's second uid is the source.
525#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
526pub struct AudioChangeSource {
527    /// The device whose endpoint is being listened to.
528    pub source_uid: DeviceUid,
529    /// The endpoint being listened to.
530    pub source_id: u16,
531    /// The device doing the listening.
532    pub target_uid: DeviceUid,
533    /// The endpoint doing the listening.
534    pub target_id: u16,
535}
536
537impl fmt::Display for AudioChangeSource {
538    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539        write!(
540            f,
541            "audio source change {}:{} -> {}:{}",
542            self.source_uid, self.source_id, self.target_uid, self.target_id
543        )
544    }
545}