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 every unit, or one, to factory-reset.
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84pub struct FactoryResetRequest {
85    /// Set when the request addresses every unit.
86    pub all: bool,
87    /// The one unit addressed, or `None` when `all` is set.
88    pub target: Option<DeviceUid>,
89}
90
91/// Asks every unit, or one, to reboot.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub struct RebootRequest {
94    /// Set when the request addresses every unit.
95    pub all: bool,
96    /// The one unit addressed, or `None` when `all` is set.
97    pub target: Option<DeviceUid>,
98}
99
100/// The window a sink is currently told to show.
101///
102/// This is the readable, pollable view of a sink's window. It is not the
103/// persisted video wall setting: on a sink running the v2ipwall module a write
104/// here is transient, because that module's reconciler pushes its own target
105/// window back within about a second. [`VideoWallCommand`] carries intent.
106#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
107pub struct V2ipTilingConfig {
108    /// The sink this window belongs to.
109    pub target: DeviceUid,
110    /// Window origin, horizontal.
111    pub pos_x: u16,
112    /// Window origin, vertical.
113    pub pos_y: u16,
114    /// Window width.
115    pub width: u16,
116    /// Window height.
117    pub height: u16,
118}
119
120impl fmt::Display for V2ipTilingConfig {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(
123            f,
124            "tiling x={} y={} {}x{}",
125            self.pos_x, self.pos_y, self.width, self.height
126        )
127    }
128}
129
130/// Asks a sink to enter or leave power save.
131#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
132pub struct V2ipPowerSaveRequest {
133    /// The sink to act on, or `None` on the broadcast form.
134    pub target: Option<DeviceUid>,
135    /// Whether power save is being entered.
136    pub enabled: bool,
137}
138
139/// The remote-control configuration of a source bay.
140#[derive(Clone, Debug, Default, PartialEq, Eq)]
141pub struct RcSettings {
142    /// The device this configuration belongs to.
143    pub target: DeviceUid,
144    /// The control method.
145    ///
146    /// A single byte: the enum is plain and Cortex-M builds with
147    /// `-fshort-enums`, so three bytes of padding follow it before the address.
148    /// That padding is not zero - firmware copies an uncleared stack local over
149    /// the payload - so widening this to a `u32` makes one unchanged setting
150    /// decode differently on every frame.
151    pub rc_target: u8,
152    /// The control target's address, `None` when unset.
153    pub ip: Option<Ipv4Addr>,
154    /// Whether CEC is enabled.
155    pub cec_enabled: bool,
156    /// Whether CEC powers the sink on automatically.
157    pub cec_auto_on: bool,
158    /// Whether remote-control commands are forwarded.
159    pub forward_rc: bool,
160    /// Whether infrared is forwarded.
161    pub forward_ir: bool,
162    /// The driver state on the source.
163    ///
164    /// A value above the last one this library knows is passed through as it
165    /// arrived rather than clamped, so a firmware update cannot make it read as
166    /// a known state.
167    pub rc_status: u8,
168    /// The driver-reported status string, empty when unknown.
169    pub status_name: String,
170}
171
172/// The raw-IR metadata shared by the IR capture and transmit frames.
173#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
174pub struct IrMeta {
175    /// Tick length the sender says its timing values are in.
176    ///
177    /// Reported and then ignored: a device replaying a burst applies its own
178    /// tick length rather than this one, and the two differ between products.
179    /// So it describes the sender, not the timings as the receiver will read
180    /// them, and converting with it does not make a burst portable.
181    pub timer_resolution: u16,
182    /// Carrier frequency in Hz.
183    pub frequency: u16,
184    /// How many timing values the sender says it appended.
185    ///
186    /// A declaration, not a measurement: nothing on the wire ties it to the
187    /// bytes that arrived, and a device replaying the burst indexes this many
188    /// rather than counting. Bound any read by the timing list itself, which is
189    /// the only part of a frame that cannot claim more than it carries.
190    pub nb_timings: u16,
191    /// Index at which the repeat section starts, declared on the same terms as
192    /// `nb_timings` and equally unbounded by the list.
193    pub repeat_offset: u16,
194    /// Capture status.
195    pub status: u8,
196}
197
198/// Raw IR captured on a bay of the sending device.
199#[derive(Clone, Debug, Default, PartialEq, Eq)]
200pub struct IrCapture {
201    /// The port the sender named, or `0xFFFF` when it named none. A unit names
202    /// none; the event's bay then is the sender's output.
203    pub port: u16,
204    /// Sender clock at capture time.
205    pub timestamp: u32,
206    /// Sender clock at the last signal change.
207    pub last_change: u32,
208    /// Metadata for the timings.
209    pub meta: IrMeta,
210    /// The on/off timings following the header, two little-endian bytes each.
211    ///
212    /// Always this width, whichever a sender used: the narrower form older
213    /// devices send is widened on the way in, so the values are the device's
214    /// and only the encoding is this library's. `meta.nb_timings` counts the
215    /// timings, so this is always twice that long.
216    ///
217    /// Index 0 is never replayed. A device blasting this list starts at the
218    /// second timing, so the first holds the gap captured ahead of the burst
219    /// and goes nowhere. A capture of a single timing therefore carries no
220    /// burst at all, and a caller counting pulses is counting one fewer than
221    /// this holds.
222    pub timings: Vec<u8>,
223}
224
225/// Asks one device to blast raw IR on one of its local bays.
226#[derive(Clone, Debug, Default, PartialEq, Eq)]
227pub struct IrTransmitRequest {
228    /// The device to act on.
229    pub target: DeviceUid,
230    /// Bay mode in the target's own numbering, not a port.
231    pub local_mode: u8,
232    /// Bay number in the target's own numbering, not a port.
233    pub local_bay: u8,
234    /// Sender clock at send time.
235    pub timestamp: u32,
236    /// Metadata for the timings.
237    pub meta: IrMeta,
238    /// The raw on/off timing blob following the header.
239    ///
240    /// Laid out as a capture is, and index 0 is discarded the same way, so a
241    /// capture is replayed by passing its timings through unchanged. A request
242    /// carrying one timing asks for nothing.
243    ///
244    /// Rebuild the rest of the request rather than forwarding a capture's
245    /// header: `timestamp` must be the sending client's own clock at send
246    /// time, because the addressed device measures the gap ahead of the burst
247    /// from it rather than from anything in this list.
248    pub timings: Vec<u8>,
249}
250
251/// Asks one device to send a remote-control key on a bay.
252#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
253pub struct KeyTransmitRequest {
254    /// The device to act on.
255    pub target: DeviceUid,
256    /// Bay in the target's own numbering.
257    pub local_bay: u16,
258    /// The key to send.
259    pub key: RcKey,
260}
261
262/// Asks one device to perform a remote-control action.
263#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
264pub struct ActionTransmitRequest {
265    /// The device to act on.
266    pub target: DeviceUid,
267    /// Bay in the target's own numbering.
268    pub local_bay: u16,
269    /// The action to perform.
270    pub action: RcAction,
271}
272
273/// Reports that a bay detected audio clipping.
274#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
275pub struct AudioClip {
276    /// The bay that clipped.
277    pub port: u16,
278    /// The clip level reported.
279    pub clip: u8,
280}
281
282/// Adds multicast groups to, or removes them from, the mesh-wide list that
283/// automatic address assignment avoids.
284///
285/// Shipping firmware is built without this list, so nothing current sends it.
286#[derive(Clone, Debug, Default, PartialEq, Eq)]
287pub struct V2ipBlacklistChange {
288    /// The groups being added or removed.
289    pub groups: Vec<Ipv4Addr>,
290    /// Whether they are being added rather than removed.
291    pub registered: bool,
292}
293
294/// What a [`VideoWallCommand`] asks the sink to do with the window.
295#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
296pub struct VideoWallOp(u8);
297
298impl VideoWallOp {
299    /// Applies the window without persisting it.
300    pub const PREVIEW: Self = Self(0);
301    /// Persists the window as the sink's wall setting.
302    pub const STORE: Self = Self(1);
303    /// Restores the persisted setting; carries no window.
304    pub const REVERT: Self = Self(2);
305
306    /// Wraps a raw wire value, including one this library has no name for.
307    pub const fn from_wire(value: u8) -> Self {
308        Self(value)
309    }
310
311    /// Returns the raw wire value.
312    pub const fn to_wire(self) -> u8 {
313        self.0
314    }
315}
316
317impl fmt::Display for VideoWallOp {
318    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319        f.write_str(match *self {
320            Self::PREVIEW => "preview",
321            Self::STORE => "store",
322            Self::REVERT => "revert",
323            _ => "unknown",
324        })
325    }
326}
327
328/// Where a video-wall sink's window sits, and the picture it was measured
329/// against.
330///
331/// The raster travels with the window because only the sender knows what the
332/// installer drew against; a sink deriving it from what it happens to be
333/// showing would place the window against the wrong picture.
334///
335/// Nothing on the receiving side is guaranteed to check any of this, so
336/// [`VideoWallWindow::validate`] runs before every send. See
337/// [`crate::Remote::store_video_wall`] for why that matters.
338#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
339pub struct VideoWallWindow {
340    /// Window origin, horizontal. A multiple of [`VIDEO_WALL_POS_ALIGN`].
341    pub pos_x: u16,
342    /// Window origin, vertical. No alignment constraint.
343    pub pos_y: u16,
344    /// Window width. A multiple of [`VIDEO_WALL_WIDTH_ALIGN`], and at least
345    /// [`VIDEO_WALL_MIN_SIZE`] unless it is zero.
346    pub width: u16,
347    /// Window height, at least [`VIDEO_WALL_MIN_SIZE`] unless it is zero. No
348    /// alignment constraint.
349    pub height: u16,
350    /// Active picture width the window was measured against. Zero states no
351    /// raster, which is accepted and skips the containment check.
352    pub raster_w: u16,
353    /// Active picture height the window was measured against. See
354    /// [`Self::raster_w`].
355    pub raster_h: u16,
356}
357
358/// The window that clears a wall, leaving the sink showing the whole frame.
359///
360/// A zero width or height is how the protocol spells "clear", so this is a
361/// legitimate window rather than one that fails [`VideoWallWindow::validate`].
362pub const VIDEO_WALL_CLEARED: VideoWallWindow = VideoWallWindow {
363    pos_x: 0,
364    pos_y: 0,
365    width: 0,
366    height: 0,
367    raster_w: 0,
368    raster_h: 0,
369};
370
371/// The horizontal origin must be a multiple of this: the sink's buffer start
372/// has to be aligned.
373pub const VIDEO_WALL_POS_ALIGN: u16 = 64;
374
375/// The width must be a multiple of this: the sink's pipeline moves four pixels
376/// per clock.
377pub const VIDEO_WALL_WIDTH_ALIGN: u16 = 4;
378
379/// Neither side of a window may be smaller than this, the scaler's minimum.
380pub const VIDEO_WALL_MIN_SIZE: u16 = 64;
381
382impl VideoWallWindow {
383    /// Reports whether this window clears the wall rather than placing one.
384    pub const fn is_cleared(&self) -> bool {
385        self.width == 0 || self.height == 0
386    }
387
388    /// Checks a window against the rules the sink applies to it.
389    ///
390    /// Every rule here matches one the receiver enforces, and is worth applying
391    /// anyway: a window the sink refuses is refused in silence. It logs
392    /// locally, keeps whatever it had, and answers nothing, so a caller that
393    /// skipped this would see a successful send and no wall.
394    ///
395    /// The three alignments follow the sink's hardware - the horizontal step
396    /// from its framebuffer's burst width, the width step from the pixels its
397    /// pipeline moves per clock, the minimum from its scaler - and a live unit
398    /// reports them over HTTP, so a caller with access to one can read them
399    /// rather than trust the constants here.
400    ///
401    /// A cleared window passes: zero is the protocol's word for "clear", not a
402    /// window too small to draw. So does a window naming no raster.
403    pub fn validate(&self) -> Result<(), &'static str> {
404        if self.is_cleared() {
405            return Ok(());
406        }
407        if self.pos_x % VIDEO_WALL_POS_ALIGN != 0 {
408            return Err("a video wall window's horizontal origin must be a multiple of 64");
409        }
410        if self.width % VIDEO_WALL_WIDTH_ALIGN != 0 {
411            return Err("a video wall window's width must be a multiple of 4");
412        }
413        if self.width < VIDEO_WALL_MIN_SIZE || self.height < VIDEO_WALL_MIN_SIZE {
414            return Err("neither side of a video wall window may be smaller than 64");
415        }
416        // A window naming no raster is contained in nothing, and the receiver
417        // skips this check rather than refusing it. Refusing it here would stop
418        // a caller sending a window the sink accepts - but such a window cannot
419        // be moved onto a later source, since nothing records what it was drawn
420        // against.
421        if self.raster_w == 0 || self.raster_h == 0 {
422            return Ok(());
423        }
424        // Widened, because a window running off the raster is exactly the case
425        // where a u16 sum would wrap and read as containment. The comparison is
426        // strict, so a window filling its raster exactly is contained.
427        if u32::from(self.pos_x) + u32::from(self.width) > u32::from(self.raster_w)
428            || u32::from(self.pos_y) + u32::from(self.height) > u32::from(self.raster_h)
429        {
430            return Err("a video wall window must fit inside the raster it names");
431        }
432        Ok(())
433    }
434}
435
436impl fmt::Display for VideoWallWindow {
437    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438        if self.is_cleared() {
439            return f.write_str("cleared");
440        }
441        write!(
442            f,
443            "{}x{}+{}+{} of {}x{}",
444            self.width, self.height, self.pos_x, self.pos_y, self.raster_w, self.raster_h
445        )
446    }
447}
448
449/// Asks one sink to crop its source to a wall window.
450///
451/// This replaces the sink's window outright: unlike a OneIP device config, no
452/// field carries a validity marker, and a zero width or height is the wire
453/// spelling of "clear the wall and show the full frame" rather than "unset".
454///
455/// The opcode belongs to the loadable v2ipwall module rather than MatrixOS, and
456/// a wall has no object of its own on the wire: it is a set of sinks each
457/// holding one rectangle, one frame each. It is a command with no reply, so
458/// nothing here is ever a status readback.
459#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
460pub struct VideoWallCommand {
461    /// The sink to act on.
462    pub target: DeviceUid,
463    /// Window origin, horizontal.
464    pub pos_x: u16,
465    /// Window origin, vertical.
466    pub pos_y: u16,
467    /// Window width.
468    pub width: u16,
469    /// Window height.
470    pub height: u16,
471    /// Active picture width the window was authored against, as on
472    /// [`VideoWallWindow`].
473    pub raster_w: u16,
474    /// Active picture height the window was authored against.
475    pub raster_h: u16,
476    /// What to do with the window.
477    pub op: VideoWallOp,
478}
479
480impl VideoWallCommand {
481    /// Reports whether the geometry in this command is meaningful.
482    ///
483    /// A revert zeroes the window and raster and the receiver ignores those
484    /// bytes, so its zeros are not a clear.
485    pub fn has_window(&self) -> bool {
486        self.op != VideoWallOp::REVERT
487    }
488
489    /// Reports a command that clears the wall and shows the full frame.
490    pub fn is_cleared(&self) -> bool {
491        self.has_window() && (self.width == 0 || self.height == 0)
492    }
493}
494
495impl fmt::Display for VideoWallCommand {
496    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
497        if !self.has_window() {
498            return f.write_str("video wall revert");
499        }
500        if self.is_cleared() {
501            return write!(f, "video wall {}: clear", self.op);
502        }
503        write!(
504            f,
505            "video wall {}: {}x{}+{}+{} of {}x{}",
506            self.op, self.width, self.height, self.pos_x, self.pos_y, self.raster_w, self.raster_h
507        )
508    }
509}
510
511/// A command addressed to a multiviewer.
512///
513/// The parameters past the envelope are exposed as raw bytes: the opcode
514/// belongs to the multiviewer module rather than MatrixOS, so beyond the
515/// envelope there is no firmware source here to pin per-sub-command field
516/// semantics against.
517#[derive(Clone, Debug, Default, PartialEq, Eq)]
518pub struct MultiviewerCommand {
519    /// The multiviewer being addressed.
520    pub target: DeviceUid,
521    /// The sub-opcode. A value this library has no name for still arrives.
522    pub op: u8,
523    /// Everything after the envelope, empty when the frame carries none.
524    pub params: Vec<u8>,
525}
526
527impl fmt::Display for MultiviewerCommand {
528    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
529        write!(
530            f,
531            "multiviewer command {} for {} ({} param bytes)",
532            self.op,
533            self.target,
534            self.params.len()
535        )
536    }
537}
538
539/// An audio input-selection change: which source endpoint a sink endpoint was
540/// switched to.
541///
542/// The sink is named twice on the wire, once as the command header's target and
543/// again at the head of the body; the body's second uid is the source.
544#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
545pub struct AudioChangeSource {
546    /// The device whose endpoint is being listened to.
547    pub source_uid: DeviceUid,
548    /// The endpoint being listened to.
549    pub source_id: u16,
550    /// The device doing the listening.
551    pub target_uid: DeviceUid,
552    /// The endpoint doing the listening.
553    pub target_id: u16,
554}
555
556impl fmt::Display for AudioChangeSource {
557    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558        write!(
559            f,
560            "audio source change {}:{} -> {}:{}",
561            self.source_uid, self.source_id, self.target_uid, self.target_id
562        )
563    }
564}