mx_remote/types/v2ip.rs
1// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
2// Copyright (c) 2026 Op den Kamp IT Solutions
3
4//! V2IP stream configuration, statistics and the sink-side route.
5
6use core::fmt;
7use std::net::Ipv4Addr;
8
9use crate::wire::{
10 DeviceUid, MxrSignalType, V2ipColourSpace, V2IP_AUDIO_DEFAULT_CHANNELS,
11 V2IP_AUDIO_DEFAULT_SAMPLE_RATE, V2IP_DSCP_MAX, V2IP_DSCP_SET,
12};
13
14/// Which of a V2IP device's streams an address describes.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum StreamKind {
17 /// The video stream.
18 #[default]
19 Video,
20 /// The audio stream.
21 Audio,
22 /// The ancillary-data stream.
23 Anc,
24 /// The audio-return stream.
25 Arc,
26}
27
28impl fmt::Display for StreamKind {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 f.write_str(match self {
31 Self::Video => "video",
32 Self::Audio => "audio",
33 Self::Anc => "anc",
34 Self::Arc => "arc",
35 })
36 }
37}
38
39/// A single multicast stream address.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub struct V2ipStreamSource {
42 /// Which stream this address is for.
43 pub kind: StreamKind,
44 /// The multicast group.
45 pub ip: Ipv4Addr,
46 /// The destination UDP port.
47 pub port: u16,
48}
49
50impl Default for V2ipStreamSource {
51 fn default() -> Self {
52 Self {
53 kind: StreamKind::default(),
54 ip: Ipv4Addr::UNSPECIFIED,
55 port: 0,
56 }
57 }
58}
59
60impl V2ipStreamSource {
61 /// Reports whether this carries a usable address: a multicast group and a
62 /// non-zero port, both, matching firmware `mxr_v2ip_stream_valid`.
63 pub const fn is_valid(&self) -> bool {
64 self.ip.is_multicast() && self.port != 0
65 }
66}
67
68impl fmt::Display for V2ipStreamSource {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 write!(f, "{}={}:{}", self.kind, self.ip, self.port)
71 }
72}
73
74/// The streams advertised by a single V2IP source.
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
76pub struct V2ipStreamSources {
77 /// The originating device, or the zero UID when it is not known.
78 pub uid: DeviceUid,
79 /// The video stream.
80 pub video: V2ipStreamSource,
81 /// The audio stream.
82 pub audio: V2ipStreamSource,
83 /// The ancillary-data stream.
84 pub anc: V2ipStreamSource,
85 /// The audio-return stream, when one is advertised.
86 pub arc: Option<V2ipStreamSource>,
87}
88
89impl fmt::Display for V2ipStreamSources {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 write!(
92 f,
93 "video:{} audio:{} anc:{}",
94 self.video, self.audio, self.anc
95 )
96 }
97}
98
99/// One multicast destination in a route the caller assembles.
100///
101/// The unspecified address sends the slot zeroed, naming no group for that
102/// stream. It is not a way to leave one stream alone: the firmware decides
103/// whether a sink has a manual route at all by reading the video and
104/// ancillary slots, so an empty one of those disqualifies the whole route
105/// rather than preserving anything - see
106/// [`crate::Remote::select_source_addr`].
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub struct V2ipRouteTarget {
109 /// The multicast group.
110 pub ip: Ipv4Addr,
111 /// The destination UDP port. Zero means the standard port for the stream
112 /// this target is given as.
113 pub port: u16,
114}
115
116impl Default for V2ipRouteTarget {
117 fn default() -> Self {
118 Self {
119 ip: Ipv4Addr::UNSPECIFIED,
120 port: 0,
121 }
122 }
123}
124
125impl V2ipRouteTarget {
126 /// A target at the standard port for its stream.
127 pub const fn new(ip: Ipv4Addr) -> Self {
128 Self { ip, port: 0 }
129 }
130
131 /// The port to send, substituting `standard` for an unset one.
132 pub(crate) const fn port_or(self, standard: u16) -> u16 {
133 if self.port == 0 {
134 standard
135 } else {
136 self.port
137 }
138 }
139}
140
141impl fmt::Display for V2ipRouteTarget {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 write!(f, "{}:{}", self.ip, self.port)
144 }
145}
146
147/// The three streams a manual route points a V2IP sink at.
148///
149/// Fill in all three. The firmware decides whether a sink has a manual route
150/// at all by looking at the video and ancillary groups, so a route carrying
151/// only audio does not register as one and the sink falls back to the audio
152/// source its mesh picks.
153#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
154pub struct V2ipRoute {
155 /// The video stream, at [`crate::V2IP_PORT_VIDEO`] unless the port says otherwise.
156 pub video: V2ipRouteTarget,
157 /// The audio stream, at [`crate::V2IP_PORT_AUDIO`] unless the port says otherwise.
158 pub audio: V2ipRouteTarget,
159 /// The ancillary-data stream, at [`crate::V2IP_PORT_ANC`] unless the port says
160 /// otherwise.
161 pub anc: V2ipRouteTarget,
162}
163
164impl V2ipRoute {
165 /// The three streams of one source, at the ports it advertises them on.
166 pub fn of(sources: &V2ipStreamSources) -> Self {
167 let target = |s: &V2ipStreamSource| V2ipRouteTarget {
168 ip: s.ip,
169 port: s.port,
170 };
171 Self {
172 video: target(&sources.video),
173 audio: target(&sources.audio),
174 anc: target(&sources.anc),
175 }
176 }
177}
178
179impl fmt::Display for V2ipRoute {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 write!(
182 f,
183 "video:{} audio:{} anc:{}",
184 self.video, self.audio, self.anc
185 )
186 }
187}
188
189/// The sample rate and channel count a V2IP audio stream is decoded at.
190///
191/// Fill both in. The firmware header calls zero "use the default", but the
192/// path that applies a manual route substitutes nothing: it hands the pair to
193/// the FPGA as it arrived, and the FPGA rejects a zero rate and takes the
194/// whole switch down with it. [`V2ipAudioFormat::STANDARD`] is the pair the
195/// header documents as the default.
196#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
197pub struct V2ipAudioFormat {
198 /// Sample rate in Hz.
199 pub sample_rate: u32,
200 /// Channel count.
201 pub channels: u8,
202}
203
204impl V2ipAudioFormat {
205 /// 48kHz stereo: the rate and channel count the firmware header names as
206 /// its default, which a caller has to send because firmware does not
207 /// substitute it.
208 pub const STANDARD: Self = Self {
209 sample_rate: V2IP_AUDIO_DEFAULT_SAMPLE_RATE,
210 channels: V2IP_AUDIO_DEFAULT_CHANNELS,
211 };
212
213 /// Encodes `v2ip_audio_format`: a `u32` rate, a channel byte and three
214 /// reserved bytes, padded to the struct's 8-byte alignment.
215 pub(crate) fn wire(&self) -> [u8; 8] {
216 let r = self.sample_rate.to_le_bytes();
217 [r[0], r[1], r[2], r[3], self.channels, 0, 0, 0]
218 }
219}
220
221impl fmt::Display for V2ipAudioFormat {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 write!(f, "{}Hz/{}ch", self.sample_rate, self.channels)
224 }
225}
226
227/// A V2IP output's scaling mode, refresh rate and flags.
228#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
229pub struct V2ipScalingSettings {
230 /// The signal type the output scales to.
231 pub mode: MxrSignalType,
232 /// Refresh rate in Hz.
233 pub refresh: u16,
234 /// The flag bits below.
235 pub flags: u8,
236}
237
238/// Set when the frame carries a scaling mode and refresh rate.
239pub const SCALING_FLAG_MODE_VALID: u8 = 1 << 0;
240
241/// Set when the frame carries the scaling options.
242pub const SCALING_FLAG_OPTIONS_VALID: u8 = 1 << 1;
243
244/// Set when the output scales automatically.
245pub const SCALING_FLAG_AUTO_SCALING: u8 = 1 << 7;
246
247/// The flag bits that carry meaning.
248///
249/// Bits 2..6 are undefined and are not reliably zero on the wire: firmware
250/// that does not initialise the configuration it broadcasts builds this frame
251/// from an uninitialised stack local and ORs its flags onto whatever was
252/// there.
253pub const SCALING_FLAGS_DEFINED: u8 =
254 SCALING_FLAG_MODE_VALID | SCALING_FLAG_OPTIONS_VALID | SCALING_FLAG_AUTO_SCALING;
255
256/// Lowest refresh rate a V2IP output stage accepts, in Hz.
257///
258/// A receiver replaces anything outside
259/// [`V2IP_SCALING_REFRESH_MIN`]..=[`V2IP_SCALING_REFRESH_MAX`] with 50 rather
260/// than refusing the write, so 0 asks for 50Hz here instead of asking for
261/// nothing.
262pub const V2IP_SCALING_REFRESH_MIN: u16 = 24;
263
264/// Highest refresh rate a V2IP output stage accepts, in Hz. See
265/// [`V2IP_SCALING_REFRESH_MIN`].
266pub const V2IP_SCALING_REFRESH_MAX: u16 = 120;
267
268/// The output format to scale a V2IP sink to.
269///
270/// Built from a depth and a colour space rather than from a packed signal-type
271/// word, so the word a caller sends cannot carry the unset bpp index a sink
272/// reports while it has no mode configured.
273#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
274pub struct V2ipOutputMode {
275 /// The CTA-861 short video descriptor to output.
276 pub svd: u8,
277 /// Bit depth: 8, 10 or 12.
278 pub depth: u8,
279 /// The colour space to output.
280 pub colour: V2ipColourSpace,
281 /// Refresh rate in Hz, [`V2IP_SCALING_REFRESH_MIN`] to
282 /// [`V2IP_SCALING_REFRESH_MAX`].
283 pub refresh: u16,
284}
285
286impl V2ipOutputMode {
287 /// Reports whether a sink will take this mode, or why it will not.
288 ///
289 /// Checked here because a sink checks it and then says nothing: every value
290 /// this rejects is one the receiver decodes cleanly and drops, leaving a
291 /// caller with a send that succeeded and a setting that did not move.
292 ///
293 /// Passing is not a guarantee. A sink also weighs the format against the
294 /// EDID of the display attached to it and against what its own clock and
295 /// output stage can produce, and none of that is knowable from here.
296 pub fn validate(&self) -> Result<(), &'static str> {
297 if self.svd == 0 {
298 return Err("svd 0 is how a mode is cleared, not a mode to set");
299 }
300 if crate::lookup_svd(u16::from(self.svd)).is_none() {
301 return Err("the svd names no known video descriptor");
302 }
303 if MxrSignalType::bpp_index_for_depth(self.depth).is_none() {
304 return Err("a V2IP output stage takes 8, 10 or 12 bits per pixel");
305 }
306 if self.colour > V2ipColourSpace::YCBCR420 {
307 return Err("the colour space names none of RGB, 4:4:4, 4:2:2 or 4:2:0");
308 }
309 if !(V2IP_SCALING_REFRESH_MIN..=V2IP_SCALING_REFRESH_MAX).contains(&self.refresh) {
310 return Err("the refresh rate is outside 24..=120Hz");
311 }
312 Ok(())
313 }
314
315 /// The packed signal type a scaling write carries for this mode.
316 ///
317 /// Call [`V2ipOutputMode::validate`] first: an unvalidated depth packs as
318 /// the index for "no depth", which a receiver drops.
319 pub(crate) fn to_signal_type(self) -> MxrSignalType {
320 MxrSignalType::from_parts(
321 self.svd,
322 self.colour.to_wire(),
323 MxrSignalType::bpp_index_for_depth(self.depth).unwrap_or(0),
324 )
325 }
326}
327
328impl fmt::Display for V2ipOutputMode {
329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330 write!(
331 f,
332 "svd {}, colour {}, {}bpp, {}Hz",
333 self.svd,
334 self.colour.to_wire(),
335 self.depth,
336 self.refresh
337 )
338 }
339}
340
341impl V2ipScalingSettings {
342 /// The mode this sink is configured to scale to, `None` when it has none.
343 ///
344 /// The two are distinct on the wire: a sink with no mode configured leaves
345 /// [`SCALING_FLAG_MODE_VALID`] clear, and never sets it over a zero mode.
346 ///
347 /// Trust it only where the sender reports
348 /// [`crate::DeviceInfo::config_initialised`]. Firmware without that builds
349 /// this block over uninitialised stack, where the valid bit itself is
350 /// noise.
351 pub const fn configured_mode(&self) -> Option<(MxrSignalType, u16)> {
352 if self.flags & SCALING_FLAG_MODE_VALID == 0 {
353 return None;
354 }
355 Some((self.mode, self.refresh))
356 }
357
358 /// Whether the output scales automatically, `None` when the sender did not
359 /// say.
360 pub const fn auto_scaling(&self) -> Option<bool> {
361 if self.flags & SCALING_FLAG_OPTIONS_VALID == 0 {
362 return None;
363 }
364 Some(self.flags & SCALING_FLAG_AUTO_SCALING != 0)
365 }
366
367 /// Folds a received scaling config onto the cached one, field by field.
368 ///
369 /// A write carries the mode or the options alone, so taking the block
370 /// wholesale would drop whichever half was not being written. The options
371 /// branch replaces the option bit rather than adding to it, which is what
372 /// lets an options-only write clear [`SCALING_FLAG_AUTO_SCALING`].
373 #[must_use]
374 pub fn merge(self, previous: Self) -> Self {
375 let mut out = previous;
376 if self.flags & SCALING_FLAG_MODE_VALID != 0 {
377 out.mode = self.mode;
378 out.refresh = self.refresh;
379 out.flags |= SCALING_FLAG_MODE_VALID;
380 }
381 if self.flags & SCALING_FLAG_OPTIONS_VALID != 0 {
382 out.flags &= !SCALING_FLAG_AUTO_SCALING;
383 out.flags |= SCALING_FLAG_OPTIONS_VALID;
384 out.flags |= self.flags & SCALING_FLAG_AUTO_SCALING;
385 }
386 out
387 }
388}
389
390/// The per-stream DSCP marking in a V2IP device configuration.
391///
392/// A stream whose wire byte carries no [`V2IP_DSCP_SET`] bit reads back as
393/// `None`. Firmware treats the marking as all-or-nothing: it applies one only
394/// when all three streams carry a value and otherwise falls back to the
395/// default, so [`V2ipDscpConfig::is_complete`] reports which case a frame is in.
396#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
397pub struct V2ipDscpConfig {
398 /// Marking for the video stream.
399 pub video: Option<u8>,
400 /// Marking for the audio stream.
401 pub audio: Option<u8>,
402 /// Marking for the ancillary-data stream.
403 pub anc: Option<u8>,
404}
405
406impl V2ipDscpConfig {
407 /// Reports whether all three streams carry a marking, which is what
408 /// firmware requires before it applies one.
409 pub const fn is_complete(&self) -> bool {
410 self.video.is_some() && self.audio.is_some() && self.anc.is_some()
411 }
412}
413
414impl fmt::Display for V2ipDscpConfig {
415 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416 match (self.video, self.audio, self.anc) {
417 (Some(v), Some(a), Some(n)) => write!(f, "video:{v} audio:{a} anc:{n}"),
418 _ => f.write_str("no marking"),
419 }
420 }
421}
422
423/// Decodes one `dscp` byte, or `None` when the byte carries no marking.
424pub(crate) fn parse_dscp(raw: u8) -> Option<u8> {
425 (raw & V2IP_DSCP_SET != 0).then_some(raw & V2IP_DSCP_MAX)
426}
427
428/// The local encoder/decoder configuration of a V2IP device.
429#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
430pub struct DeviceV2ipDetails {
431 /// The video stream this device sources.
432 pub video: V2ipStreamSource,
433 /// The audio stream this device sources.
434 pub audio: V2ipStreamSource,
435 /// The ancillary-data stream this device sources.
436 pub anc: V2ipStreamSource,
437 /// The audio-return stream this device sources.
438 pub arc: V2ipStreamSource,
439
440 /// Encoder rate in units of 10Mb/s, or `None` when the sender offered no
441 /// rate.
442 ///
443 /// A rate-only write carries the rate on its own; every other controller
444 /// write puts a value outside the valid range here, which firmware drops as
445 /// invalid so that address-only and scaling writes leave the peer's rate
446 /// alone.
447 pub tx_rate: Option<u8>,
448
449 /// Per-stream DSCP marking.
450 pub dscp: V2ipDscpConfig,
451 /// Scaling mode, refresh rate and flags.
452 pub scaling: V2ipScalingSettings,
453}
454
455impl DeviceV2ipDetails {
456 /// Reports whether the source block carries usable addresses.
457 ///
458 /// Firmware requires video and anc; audio is optional and is carried with
459 /// them.
460 pub const fn source_is_valid(&self) -> bool {
461 self.video.is_valid() && self.anc.is_valid()
462 }
463
464 /// Folds a received device configuration onto the cached one.
465 ///
466 /// Every field is optional behind its own validity marker: the payload is
467 /// zeroed before a sender fills in the one field it is writing, so a
468 /// controller writing a TX rate sends zeroed addresses and a controller
469 /// writing addresses sends an out-of-range rate. Firmware applies each
470 /// field only behind its own test, so replacing the whole cached config on
471 /// every frame would make the peer read back with its addresses, rate or
472 /// marking gone.
473 #[must_use]
474 pub fn merge(mut self, previous: Option<Self>) -> Self {
475 let Some(previous) = previous else {
476 return self;
477 };
478 if !self.source_is_valid() {
479 self.video = previous.video;
480 self.audio = previous.audio;
481 self.anc = previous.anc;
482 }
483 if !self.arc.is_valid() {
484 self.arc = previous.arc;
485 }
486 if self.tx_rate.is_none() {
487 self.tx_rate = previous.tx_rate;
488 }
489 // Firmware gates all three dscp bytes on the video byte's set bit
490 // alone, and stores whatever the other two carry.
491 if self.dscp.video.is_none() {
492 self.dscp = previous.dscp;
493 }
494 self.scaling = self.scaling.merge(previous.scaling);
495 self
496 }
497}
498
499/// The sink-side route a V2IP device is subscribed to, as the mesh believes it.
500///
501/// A route request addressed to the device sets this the moment it is seen,
502/// which is what every device on the mesh does with one. So a request the
503/// device refused, or that reached it while it was offline, reads back here as
504/// though it had taken effect. Only the device's own configuration report
505/// confirms a route, and it sends that on its own schedule rather than in reply.
506///
507/// **Addresses that read as unset mean "no route, or the sink could not work
508/// one out" - never "definitely not subscribed".** This block is the one part
509/// of a device configuration with no validity marker of its own, so a sender
510/// with nothing to say sends zeros and every receiver stores them. A sender
511/// leaves it empty when its own stream configuration does not resolve, and that
512/// covers more than having no route: a selected source whose record has not
513/// arrived yet, which is the state after a restart at either end, missing audio
514/// bay configuration, or any of the three streams failing its validity check.
515/// The audio format has a second gate of its own, so it can be absent while the
516/// addresses are not.
517///
518/// This is worth expecting rather than guarding against. Any scaling change
519/// makes the device rebuild and rebroadcast this block, and a write aimed at a
520/// remote bay sends it zeroed however it was requested - so the empty reading
521/// arrives most often during exactly the no-signal troubleshooting that
522/// prompted the change. A device's periodic report puts a real route back
523/// within a minute of it having one.
524///
525/// An empty reading is applied rather than ignored on purpose. A sink that has
526/// genuinely dropped its route sends the same zeros, and so does every report
527/// after it, so refusing them would cache a route that nothing later could ever
528/// clear.
529#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
530pub struct DeviceV2ipSink {
531 /// The streams the sink subscribes to.
532 pub addresses: V2ipStreamSources,
533 /// The resolved audio format, when the sender reported one.
534 pub audio_fmt: Option<V2ipAudioFormat>,
535}
536
537/// Transmitter stream statistics.
538#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
539pub struct V2ipTxStats {
540 /// Video packets sent.
541 pub video: u32,
542 /// Audio packets sent.
543 pub audio: u32,
544 /// Ancillary-data packets sent.
545 pub anc: u32,
546 /// Times the stream went down.
547 pub stream_down: u32,
548 /// Transmit overflows.
549 pub overflow: u32,
550}
551
552/// The health state of a V2IP decoder.
553#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
554pub struct V2ipDecoderState(u8);
555
556impl V2ipDecoderState {
557 /// The sink has not reported a state.
558 pub const UNKNOWN: Self = Self(0);
559 /// Decoding normally.
560 pub const HEALTHY: Self = Self(1);
561 /// Failed to decode.
562 pub const BAD: Self = Self(2);
563 /// Still coming up, which any sink subscribed to during a route change
564 /// reports.
565 pub const STARTING: Self = Self(3);
566
567 /// Wraps a raw wire value, including one this library has no name for.
568 pub const fn from_wire(value: u8) -> Self {
569 Self(value)
570 }
571
572 /// Returns the raw wire value.
573 pub const fn to_wire(self) -> u8 {
574 self.0
575 }
576
577 /// Reports whether the decoder has reached a verdict.
578 ///
579 /// Only healthy and bad are verdicts. Testing for failure as "not healthy"
580 /// reads a receiver that is merely coming up as one that failed to decode,
581 /// which is what a sink reports for a moment after every route change.
582 pub const fn is_settled(self) -> bool {
583 matches!(self, Self::HEALTHY | Self::BAD)
584 }
585}
586
587impl fmt::Display for V2ipDecoderState {
588 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
589 match *self {
590 Self::UNKNOWN => f.write_str("Unknown"),
591 Self::HEALTHY => f.write_str("Healthy"),
592 Self::BAD => f.write_str("Bad"),
593 Self::STARTING => f.write_str("Starting"),
594 Self(v) => write!(f, "state {v}"),
595 }
596 }
597}
598
599/// Receiver stream statistics.
600#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
601pub struct V2ipRxStats {
602 /// Video packets received.
603 pub video_total: u32,
604 /// Video packets dropped.
605 pub video_dropped: u32,
606 /// Video sequence errors.
607 pub video_seq_errors: u32,
608 /// Watchdog timeouts.
609 pub wdt_timeout: u32,
610 /// Audio packets received.
611 pub audio_total: u32,
612 /// Audio packets dropped.
613 pub audio_dropped: u32,
614 /// Audio sequence errors.
615 pub audio_seq_errors: u32,
616 /// Ancillary-data packets received.
617 pub anc_total: u32,
618 /// Ancillary-data packets dropped.
619 pub anc_dropped: u32,
620 /// Ancillary-data sequence errors.
621 pub anc_seq_errors: u32,
622 /// The decoder's health state.
623 pub decoder_state: V2ipDecoderState,
624}
625
626/// Why a decoder reports the state it does.
627///
628/// The primary cause only. Several causes can be true at once, and which of
629/// them lands here is a fixed priority order in the firmware that the numbering
630/// does not express: these values are identities, not ranks, and comparing or
631/// ordering them says nothing. Ask [`V2ipDecoderReport::has_cause`] whether a
632/// particular cause applies - a test against this field answers "is this the
633/// one that won" instead, which is a different question.
634///
635/// Firmware adds causes, so the wire value is carried as it arrived: folding an
636/// unrecognised one onto a named cause would report a fault this library
637/// invented. Appending one cannot reorder the existing priorities.
638#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
639pub struct V2ipDecoderReason(u8);
640
641impl V2ipDecoderReason {
642 /// Decoding normally.
643 pub const OK: Self = Self(0);
644 /// No packets are arriving.
645 pub const NO_PACKETS: Self = Self(1);
646 /// Packets are arriving, degraded.
647 pub const PACKETS_DEGRADED: Self = Self(2);
648 /// No format could be recovered from the codestream.
649 pub const NO_FORMAT: Self = Self(3);
650 /// The recovered format is not the one the sink is configured for.
651 pub const FORMAT_MISMATCH: Self = Self(4);
652 /// The configured output format was refused.
653 pub const FORMAT_REJECTED: Self = Self(5);
654 /// The converter watchdog is holding the stream back.
655 pub const DECODER_BLOCKED: Self = Self(6);
656 /// A source switch is in progress: a step in an operation someone asked
657 /// for, rather than a fault.
658 pub const SWITCH_PENDING: Self = Self(7);
659 /// PTP is unlocked. That costs audio alone; the picture is unaffected.
660 pub const PTP_UNLOCKED: Self = Self(8);
661 /// The pipeline is rebuilding after the HDMI transmitter stayed unlocked.
662 ///
663 /// The picture is down, and has been for five seconds before this can
664 /// appear: the sender debounces the unlocked reading for that long, so
665 /// this never reports a transient. Unlike [`Self::SWITCH_PENDING`] nobody
666 /// asked for it.
667 ///
668 /// The debounce restarts each time it elapses, so this holding across
669 /// reports is a restart loop rather than one event, and that is what to
670 /// escalate on.
671 ///
672 /// It sits near the bottom of the priority order, below every input-side
673 /// cause, so a rebuilding pipeline names one of those in
674 /// [`V2ipDecoderReport::reason`] and carries this in
675 /// [`V2ipDecoderReport::flags`] alone - always, rather than briefly.
676 ///
677 /// It is evaluated only while no format change is in progress. Across a
678 /// switch it holds its previous value and clears on the first reading
679 /// after the change settles, which [`V2ipDecoderReport::updates`] cannot
680 /// distinguish: a value carried forward is still a stored reading.
681 pub const TX_BRIDGE_UNLOCKED: Self = Self(9);
682 /// The sink is configured but switched off, so no stream is expected.
683 ///
684 /// This outranks every other cause: whenever it applies it is what
685 /// [`V2ipDecoderReport::reason`] carries.
686 ///
687 /// **The causes beneath it stay set in [`V2ipDecoderReport::flags`].** A
688 /// sink switched off while it was running keeps the bits the decoder
689 /// genuinely observed on the way down - no packets, no format - so a
690 /// classifier that tests a fault mask over the whole word calls a
691 /// deliberately disabled sink broken. Ask for this cause first and stop
692 /// there; the bits below it describe what was seen, not a fault to report.
693 ///
694 /// This says nothing about geometry, in either direction. The decoder
695 /// reports what it currently detects whatever the cause, so a switched-off
696 /// sink still detecting a codestream carries a real geometry, and a zero
697 /// one means the decoder has nothing rather than that the sink is off.
698 ///
699 /// Older senders never report this and give [`Self::NO_PACKETS`] for a
700 /// disabled sink instead, indistinguishable from one whose source has
701 /// died. So an absent [`Self::IDLE`] is not evidence a sink is enabled,
702 /// and **nothing in this block answers enablement**: it carries no such
703 /// field, and the answer comes from `V2IP_DEVICE_CFG` or the device's HTTP
704 /// status.
705 pub const IDLE: Self = Self(10);
706
707 /// Wraps a raw wire value, including one this library has no name for.
708 pub const fn from_wire(value: u8) -> Self {
709 Self(value)
710 }
711
712 /// Returns the raw wire value.
713 pub const fn to_wire(self) -> u8 {
714 self.0
715 }
716}
717
718impl fmt::Display for V2ipDecoderReason {
719 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
720 match *self {
721 Self::OK => f.write_str("ok"),
722 Self::NO_PACKETS => f.write_str("no packets"),
723 Self::PACKETS_DEGRADED => f.write_str("packets degraded"),
724 Self::NO_FORMAT => f.write_str("no format recovered"),
725 Self::FORMAT_MISMATCH => f.write_str("format mismatch"),
726 Self::FORMAT_REJECTED => f.write_str("format rejected"),
727 Self::DECODER_BLOCKED => f.write_str("decoder blocked"),
728 Self::SWITCH_PENDING => f.write_str("switch pending"),
729 Self::PTP_UNLOCKED => f.write_str("PTP unlocked"),
730 Self::TX_BRIDGE_UNLOCKED => f.write_str("TX bridge unlocked"),
731 Self::IDLE => f.write_str("idle"),
732 Self(v) => write!(f, "reason {v}"),
733 }
734 }
735}
736
737/// The colour space a decoder recovered from a codestream.
738///
739/// Zero is RGB and is also what a decoder with nothing to decode reports, so no
740/// value here means "no signal" - [`V2ipDecoderReport::has_geometry`] is what
741/// answers that.
742#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
743pub struct V2ipDecoderFormat(u16);
744
745impl V2ipDecoderFormat {
746 /// RGB.
747 pub const RGB: Self = Self(0);
748 /// YCbCr 4:4:4.
749 pub const YCBCR_444: Self = Self(1);
750 /// YCbCr 4:2:2.
751 pub const YCBCR_422: Self = Self(2);
752 /// YCbCr 4:2:0.
753 pub const YCBCR_420: Self = Self(3);
754 /// The decoder cannot name the format.
755 ///
756 /// 255, which is a value of its own rather than the 0xF a signal report
757 /// uses for an unknown colour space. Mapping one onto the other yields a
758 /// colour space the decoder never reported.
759 pub const UNNAMED: Self = Self(255);
760
761 /// Wraps a raw wire value, including one this library has no name for.
762 pub const fn from_wire(value: u16) -> Self {
763 Self(value)
764 }
765
766 /// Returns the raw wire value.
767 pub const fn to_wire(self) -> u16 {
768 self.0
769 }
770}
771
772impl fmt::Display for V2ipDecoderFormat {
773 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
774 match *self {
775 Self::RGB => f.write_str("RGB"),
776 Self::YCBCR_444 => f.write_str("YCbCr 4:4:4"),
777 Self::YCBCR_422 => f.write_str("YCbCr 4:2:2"),
778 Self::YCBCR_420 => f.write_str("YCbCr 4:2:0"),
779 Self::UNNAMED => f.write_str("unnamed"),
780 Self(v) => write!(f, "format {v}"),
781 }
782 }
783}
784
785/// What a sink's decoder recovered from the codestream it is being given.
786///
787/// This is what the decoder understood, read ahead of the scaler: the geometry
788/// is unrounded and is not what the display is being sent. It separates "the
789/// decoder understood the codestream" from "a picture came out the other end".
790///
791/// Colour depth is absent on purpose and will stay absent. The video processor
792/// answers that one from a driver constant rather than from the codestream, so
793/// there is no reading to carry; assert depth at the encoder's input bay
794/// instead.
795#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
796pub struct V2ipDecoderReport {
797 /// The primary cause of the state the decoder is in.
798 pub reason: V2ipDecoderReason,
799 /// The converter watchdog is holding the stream back.
800 pub blocking: bool,
801 /// The recovered picture width, and 0 when none was recovered.
802 pub width: u16,
803 /// The recovered picture height, and 0 when none was recovered.
804 pub height: u16,
805 /// The recovered colour space.
806 pub format: V2ipDecoderFormat,
807 /// How many readings the sink has stored. Monotonic, wrapping at 65535
808 /// after some 36 hours, and never reset.
809 ///
810 /// A sink reads its video processor every two seconds and reports every
811 /// second, so roughly every other report repeats a reading already seen:
812 /// a frame arriving says nothing about how fresh the values in it are.
813 /// This counter moves only when a reading is stored, so a processor that
814 /// stopped answering leaves it still rather than implying a refresh.
815 ///
816 /// After pointing a sink at something else, wait for this to advance by
817 /// two before trusting the geometry. It ticks when a reply lands rather
818 /// than when a query is sent, so the first tick can carry an answer the
819 /// processor read fractionally before the switch; the second cannot,
820 /// because at most one query is outstanding at a time.
821 pub updates: u16,
822 /// Every cause that applies, as bit N for reason N. See
823 /// [`Self::has_cause`].
824 ///
825 /// This is what to classify on, once [`V2ipDecoderReason::IDLE`] has been
826 /// ruled out: that cause outranks the whole word and leaves the bits below
827 /// it set, so a fault mask over `flags` reports a switched-off sink as
828 /// broken. [`Self::reason`] carries whichever cause won a fixed priority
829 /// contest, so a cause that is true can be absent from it while present
830 /// here. Bit 0 is cleared by the sender, so an empty word means nothing
831 /// beyond the primary cause applies.
832 ///
833 /// [`V2ipDecoderReason::NO_FORMAT`] and
834 /// [`V2ipDecoderReason::FORMAT_MISMATCH`] are the two arms of one decision
835 /// and never appear together.
836 pub flags: u32,
837 /// How many times the converter watchdog has triggered.
838 pub blocked_count: u32,
839}
840
841impl V2ipDecoderReport {
842 /// Reports whether the decoder recovered a geometry.
843 ///
844 /// This is what says whether the decoder is being given a codestream it
845 /// understands. [`Self::format`] cannot: it reads
846 /// [`V2ipDecoderFormat::RGB`] when nothing is arriving, which is
847 /// indistinguishable from a real RGB reading.
848 ///
849 /// It answers that and nothing else. The reading is taken before any cause
850 /// is decided, so it does not say whether the sink is switched on: a sink
851 /// that is off can still detect a codestream, and one that is on can
852 /// detect nothing.
853 pub const fn has_geometry(&self) -> bool {
854 self.width != 0 && self.height != 0
855 }
856
857 /// Reports whether `reason` is among the causes that apply.
858 ///
859 /// [`Self::reason`] carries the primary cause and `flags` carries all of
860 /// them at once. Bit 0 is unused, so [`V2ipDecoderReason::OK`] is never
861 /// among them and an empty word means nothing beyond the primary cause
862 /// applies.
863 pub const fn has_cause(&self, reason: V2ipDecoderReason) -> bool {
864 let bit = reason.to_wire();
865 bit > 0 && bit < u32::BITS as u8 && self.flags & (1 << bit) != 0
866 }
867}
868
869/// What a statistics report says about the sink's decoder.
870///
871/// The three states are distinct answers and only [`Self::Answered`] carries a
872/// reading. `valid` follows the sink being configured rather than the sink
873/// being enabled, so a sink that is switched off still reports: as
874/// [`V2ipDecoderReason::IDLE`], or from an older sender as
875/// [`V2ipDecoderReason::NO_PACKETS`], which is the same reading a sink whose
876/// source has died produces.
877#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
878pub enum V2ipDecoderDetail {
879 /// The report carried no decoder block: the sender's firmware predates it.
880 #[default]
881 Absent,
882 /// The block is there and the decoder has never answered. Every field it
883 /// would carry is meaningless, so none is offered.
884 NeverAnswered,
885 /// A reading.
886 Answered(V2ipDecoderReport),
887}
888
889impl V2ipDecoderDetail {
890 /// The reading, for a caller that treats both of the other states as
891 /// "nothing to show".
892 pub const fn reading(self) -> Option<V2ipDecoderReport> {
893 match self {
894 Self::Answered(report) => Some(report),
895 _ => None,
896 }
897 }
898}
899
900/// The cumulative and per-minute transmit and receive statistics.
901#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
902pub struct V2ipDeviceStats {
903 /// Transmit totals since boot.
904 pub tx: V2ipTxStats,
905 /// Transmit counts over the last minute.
906 pub tx_per_minute: V2ipTxStats,
907 /// Receive totals since boot.
908 pub rx: V2ipRxStats,
909 /// Receive counts over the last minute.
910 pub rx_per_minute: V2ipRxStats,
911 /// What the sink's decoder recovered from the codestream it is decoding.
912 pub decoder: V2ipDecoderDetail,
913}