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