Skip to main content

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, V2IP_AUDIO_DEFAULT_CHANNELS, V2IP_AUDIO_DEFAULT_SAMPLE_RATE,
11    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
256impl V2ipScalingSettings {
257    /// Folds a received scaling config onto the cached one, field by field.
258    ///
259    /// A write carries the mode or the options alone, so taking the block
260    /// wholesale would drop whichever half was not being written. The options
261    /// branch replaces the option bit rather than adding to it, which is what
262    /// lets an options-only write clear [`SCALING_FLAG_AUTO_SCALING`].
263    #[must_use]
264    pub fn merge(self, previous: Self) -> Self {
265        let mut out = previous;
266        if self.flags & SCALING_FLAG_MODE_VALID != 0 {
267            out.mode = self.mode;
268            out.refresh = self.refresh;
269            out.flags |= SCALING_FLAG_MODE_VALID;
270        }
271        if self.flags & SCALING_FLAG_OPTIONS_VALID != 0 {
272            out.flags &= !SCALING_FLAG_AUTO_SCALING;
273            out.flags |= SCALING_FLAG_OPTIONS_VALID;
274            out.flags |= self.flags & SCALING_FLAG_AUTO_SCALING;
275        }
276        out
277    }
278}
279
280/// The per-stream DSCP marking in a V2IP device configuration.
281///
282/// A stream whose wire byte carries no [`V2IP_DSCP_SET`] bit reads back as
283/// `None`. Firmware treats the marking as all-or-nothing: it applies one only
284/// when all three streams carry a value and otherwise falls back to the
285/// default, so [`V2ipDscpConfig::is_complete`] reports which case a frame is in.
286#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
287pub struct V2ipDscpConfig {
288    /// Marking for the video stream.
289    pub video: Option<u8>,
290    /// Marking for the audio stream.
291    pub audio: Option<u8>,
292    /// Marking for the ancillary-data stream.
293    pub anc: Option<u8>,
294}
295
296impl V2ipDscpConfig {
297    /// Reports whether all three streams carry a marking, which is what
298    /// firmware requires before it applies one.
299    pub const fn is_complete(&self) -> bool {
300        self.video.is_some() && self.audio.is_some() && self.anc.is_some()
301    }
302}
303
304impl fmt::Display for V2ipDscpConfig {
305    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306        match (self.video, self.audio, self.anc) {
307            (Some(v), Some(a), Some(n)) => write!(f, "video:{v} audio:{a} anc:{n}"),
308            _ => f.write_str("no marking"),
309        }
310    }
311}
312
313/// Decodes one `dscp` byte, or `None` when the byte carries no marking.
314pub(crate) fn parse_dscp(raw: u8) -> Option<u8> {
315    (raw & V2IP_DSCP_SET != 0).then_some(raw & V2IP_DSCP_MAX)
316}
317
318/// The local encoder/decoder configuration of a V2IP device.
319#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
320pub struct DeviceV2ipDetails {
321    /// The video stream this device sources.
322    pub video: V2ipStreamSource,
323    /// The audio stream this device sources.
324    pub audio: V2ipStreamSource,
325    /// The ancillary-data stream this device sources.
326    pub anc: V2ipStreamSource,
327    /// The audio-return stream this device sources.
328    pub arc: V2ipStreamSource,
329
330    /// Encoder rate in units of 10Mb/s, or `None` when the sender offered no
331    /// rate.
332    ///
333    /// A rate-only write carries the rate on its own; every other controller
334    /// write puts a value outside the valid range here, which firmware drops as
335    /// invalid so that address-only and scaling writes leave the peer's rate
336    /// alone.
337    pub tx_rate: Option<u8>,
338
339    /// Per-stream DSCP marking.
340    pub dscp: V2ipDscpConfig,
341    /// Scaling mode, refresh rate and flags.
342    pub scaling: V2ipScalingSettings,
343}
344
345impl DeviceV2ipDetails {
346    /// Reports whether the source block carries usable addresses.
347    ///
348    /// Firmware requires video and anc; audio is optional and is carried with
349    /// them.
350    pub const fn source_is_valid(&self) -> bool {
351        self.video.is_valid() && self.anc.is_valid()
352    }
353
354    /// Folds a received device configuration onto the cached one.
355    ///
356    /// Every field is optional behind its own validity marker: the payload is
357    /// zeroed before a sender fills in the one field it is writing, so a
358    /// controller writing a TX rate sends zeroed addresses and a controller
359    /// writing addresses sends an out-of-range rate. Firmware applies each
360    /// field only behind its own test, so replacing the whole cached config on
361    /// every frame would make the peer read back with its addresses, rate or
362    /// marking gone.
363    #[must_use]
364    pub fn merge(mut self, previous: Option<Self>) -> Self {
365        let Some(previous) = previous else {
366            return self;
367        };
368        if !self.source_is_valid() {
369            self.video = previous.video;
370            self.audio = previous.audio;
371            self.anc = previous.anc;
372        }
373        if !self.arc.is_valid() {
374            self.arc = previous.arc;
375        }
376        if self.tx_rate.is_none() {
377            self.tx_rate = previous.tx_rate;
378        }
379        // Firmware gates all three dscp bytes on the video byte's set bit
380        // alone, and stores whatever the other two carry.
381        if self.dscp.video.is_none() {
382            self.dscp = previous.dscp;
383        }
384        self.scaling = self.scaling.merge(previous.scaling);
385        self
386    }
387}
388
389/// The sink-side route a V2IP device is subscribed to, as the mesh believes it.
390///
391/// A route request addressed to the device sets this the moment it is seen,
392/// which is what every device on the mesh does with one. So a request the
393/// device refused, or that reached it while it was offline, reads back here as
394/// though it had taken effect. Only the device's own configuration report
395/// confirms a route, and it sends that on its own schedule rather than in reply.
396#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
397pub struct DeviceV2ipSink {
398    /// The streams the sink subscribes to.
399    pub addresses: V2ipStreamSources,
400    /// The resolved audio format, when the sender reported one.
401    pub audio_fmt: Option<V2ipAudioFormat>,
402}
403
404/// Transmitter stream statistics.
405#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
406pub struct V2ipTxStats {
407    /// Video packets sent.
408    pub video: u32,
409    /// Audio packets sent.
410    pub audio: u32,
411    /// Ancillary-data packets sent.
412    pub anc: u32,
413    /// Times the stream went down.
414    pub stream_down: u32,
415    /// Transmit overflows.
416    pub overflow: u32,
417}
418
419/// The health state of a V2IP decoder.
420#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
421pub struct V2ipDecoderState(u8);
422
423impl V2ipDecoderState {
424    /// The sink has not reported a state.
425    pub const UNKNOWN: Self = Self(0);
426    /// Decoding normally.
427    pub const HEALTHY: Self = Self(1);
428    /// Failed to decode.
429    pub const BAD: Self = Self(2);
430    /// Still coming up, which any sink subscribed to during a route change
431    /// reports.
432    pub const STARTING: Self = Self(3);
433
434    /// Wraps a raw wire value, including one this library has no name for.
435    pub const fn from_wire(value: u8) -> Self {
436        Self(value)
437    }
438
439    /// Returns the raw wire value.
440    pub const fn to_wire(self) -> u8 {
441        self.0
442    }
443
444    /// Reports whether the decoder has reached a verdict.
445    ///
446    /// Only healthy and bad are verdicts. Testing for failure as "not healthy"
447    /// reads a receiver that is merely coming up as one that failed to decode,
448    /// which is what a sink reports for a moment after every route change.
449    pub const fn is_settled(self) -> bool {
450        matches!(self, Self::HEALTHY | Self::BAD)
451    }
452}
453
454impl fmt::Display for V2ipDecoderState {
455    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
456        match *self {
457            Self::UNKNOWN => f.write_str("Unknown"),
458            Self::HEALTHY => f.write_str("Healthy"),
459            Self::BAD => f.write_str("Bad"),
460            Self::STARTING => f.write_str("Starting"),
461            Self(v) => write!(f, "state {v}"),
462        }
463    }
464}
465
466/// Receiver stream statistics.
467#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
468pub struct V2ipRxStats {
469    /// Video packets received.
470    pub video_total: u32,
471    /// Video packets dropped.
472    pub video_dropped: u32,
473    /// Video sequence errors.
474    pub video_seq_errors: u32,
475    /// Watchdog timeouts.
476    pub wdt_timeout: u32,
477    /// Audio packets received.
478    pub audio_total: u32,
479    /// Audio packets dropped.
480    pub audio_dropped: u32,
481    /// Audio sequence errors.
482    pub audio_seq_errors: u32,
483    /// Ancillary-data packets received.
484    pub anc_total: u32,
485    /// Ancillary-data packets dropped.
486    pub anc_dropped: u32,
487    /// Ancillary-data sequence errors.
488    pub anc_seq_errors: u32,
489    /// The decoder's health state.
490    pub decoder_state: V2ipDecoderState,
491}
492
493/// Why a decoder reports the state it does.
494///
495/// The primary cause only. Several causes can be true at once, and which of
496/// them lands here is a fixed priority order in the firmware that the numbering
497/// does not express: these values are identities, not ranks, and comparing or
498/// ordering them says nothing. Ask [`V2ipDecoderReport::has_cause`] whether a
499/// particular cause applies - a test against this field answers "is this the
500/// one that won" instead, which is a different question.
501///
502/// Firmware adds causes, so the wire value is carried as it arrived: folding an
503/// unrecognised one onto a named cause would report a fault this library
504/// invented. Appending one cannot reorder the existing priorities.
505#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
506pub struct V2ipDecoderReason(u8);
507
508impl V2ipDecoderReason {
509    /// Decoding normally.
510    pub const OK: Self = Self(0);
511    /// No packets are arriving.
512    pub const NO_PACKETS: Self = Self(1);
513    /// Packets are arriving, degraded.
514    pub const PACKETS_DEGRADED: Self = Self(2);
515    /// No format could be recovered from the codestream.
516    pub const NO_FORMAT: Self = Self(3);
517    /// The recovered format is not the one the sink is configured for.
518    pub const FORMAT_MISMATCH: Self = Self(4);
519    /// The configured output format was refused.
520    pub const FORMAT_REJECTED: Self = Self(5);
521    /// The converter watchdog is holding the stream back.
522    pub const DECODER_BLOCKED: Self = Self(6);
523    /// A source switch is in progress: a step in an operation someone asked
524    /// for, rather than a fault.
525    pub const SWITCH_PENDING: Self = Self(7);
526    /// PTP is unlocked. That costs audio alone; the picture is unaffected.
527    pub const PTP_UNLOCKED: Self = Self(8);
528    /// The pipeline is rebuilding after the HDMI transmitter stayed unlocked.
529    ///
530    /// The picture is down, and has been for five seconds before this can
531    /// appear: the sender debounces the unlocked reading for that long, so
532    /// this never reports a transient. Unlike [`Self::SWITCH_PENDING`] nobody
533    /// asked for it.
534    ///
535    /// The debounce restarts each time it elapses, so this holding across
536    /// reports is a restart loop rather than one event, and that is what to
537    /// escalate on.
538    ///
539    /// It sits near the bottom of the priority order, below every input-side
540    /// cause, so a rebuilding pipeline names one of those in
541    /// [`V2ipDecoderReport::reason`] and carries this in
542    /// [`V2ipDecoderReport::flags`] alone - always, rather than briefly.
543    ///
544    /// It is evaluated only while no format change is in progress. Across a
545    /// switch it holds its previous value and clears on the first reading
546    /// after the change settles, which [`V2ipDecoderReport::updates`] cannot
547    /// distinguish: a value carried forward is still a stored reading.
548    pub const TX_BRIDGE_UNLOCKED: Self = Self(9);
549    /// The sink is configured but not expecting a stream.
550    ///
551    /// Effectively unreachable on current firmware: the sink derives its
552    /// expectation from the channel's running state, which the pipeline
553    /// re-establishes within about one 10ms poll, so the window this describes
554    /// closes before a report goes out. A sink that has been switched off
555    /// reports [`Self::NO_PACKETS`] indefinitely instead, indistinguishable
556    /// from one whose source is dead. **Nothing on this wire says a sink was
557    /// switched off deliberately** - the block carries no enablement field at
558    /// all, so a sink that is off and a sink that should be receiving and is
559    /// not produce the same reading. Enablement comes from `V2IP_DEVICE_CFG`
560    /// or the device's HTTP status, and only whatever issued the instruction
561    /// knows it was deliberate.
562    pub const IDLE: Self = Self(10);
563
564    /// Wraps a raw wire value, including one this library has no name for.
565    pub const fn from_wire(value: u8) -> Self {
566        Self(value)
567    }
568
569    /// Returns the raw wire value.
570    pub const fn to_wire(self) -> u8 {
571        self.0
572    }
573}
574
575impl fmt::Display for V2ipDecoderReason {
576    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
577        match *self {
578            Self::OK => f.write_str("ok"),
579            Self::NO_PACKETS => f.write_str("no packets"),
580            Self::PACKETS_DEGRADED => f.write_str("packets degraded"),
581            Self::NO_FORMAT => f.write_str("no format recovered"),
582            Self::FORMAT_MISMATCH => f.write_str("format mismatch"),
583            Self::FORMAT_REJECTED => f.write_str("format rejected"),
584            Self::DECODER_BLOCKED => f.write_str("decoder blocked"),
585            Self::SWITCH_PENDING => f.write_str("switch pending"),
586            Self::PTP_UNLOCKED => f.write_str("PTP unlocked"),
587            Self::TX_BRIDGE_UNLOCKED => f.write_str("TX bridge unlocked"),
588            Self::IDLE => f.write_str("idle"),
589            Self(v) => write!(f, "reason {v}"),
590        }
591    }
592}
593
594/// The colour space a decoder recovered from a codestream.
595///
596/// Zero is RGB and is also what a decoder with nothing to decode reports, so no
597/// value here means "no signal" - [`V2ipDecoderReport::has_geometry`] is what
598/// answers that.
599#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
600pub struct V2ipDecoderFormat(u16);
601
602impl V2ipDecoderFormat {
603    /// RGB.
604    pub const RGB: Self = Self(0);
605    /// YCbCr 4:4:4.
606    pub const YCBCR_444: Self = Self(1);
607    /// YCbCr 4:2:2.
608    pub const YCBCR_422: Self = Self(2);
609    /// YCbCr 4:2:0.
610    pub const YCBCR_420: Self = Self(3);
611    /// The decoder cannot name the format.
612    ///
613    /// 255, which is a value of its own rather than the 0xF a signal report
614    /// uses for an unknown colour space. Mapping one onto the other yields a
615    /// colour space the decoder never reported.
616    pub const UNNAMED: Self = Self(255);
617
618    /// Wraps a raw wire value, including one this library has no name for.
619    pub const fn from_wire(value: u16) -> Self {
620        Self(value)
621    }
622
623    /// Returns the raw wire value.
624    pub const fn to_wire(self) -> u16 {
625        self.0
626    }
627}
628
629impl fmt::Display for V2ipDecoderFormat {
630    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
631        match *self {
632            Self::RGB => f.write_str("RGB"),
633            Self::YCBCR_444 => f.write_str("YCbCr 4:4:4"),
634            Self::YCBCR_422 => f.write_str("YCbCr 4:2:2"),
635            Self::YCBCR_420 => f.write_str("YCbCr 4:2:0"),
636            Self::UNNAMED => f.write_str("unnamed"),
637            Self(v) => write!(f, "format {v}"),
638        }
639    }
640}
641
642/// What a sink's decoder recovered from the codestream it is being given.
643///
644/// This is what the decoder understood, read ahead of the scaler: the geometry
645/// is unrounded and is not what the display is being sent. It separates "the
646/// decoder understood the codestream" from "a picture came out the other end".
647///
648/// Colour depth is absent on purpose and will stay absent. The video processor
649/// answers that one from a driver constant rather than from the codestream, so
650/// there is no reading to carry; assert depth at the encoder's input bay
651/// instead.
652#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
653pub struct V2ipDecoderReport {
654    /// The primary cause of the state the decoder is in.
655    pub reason: V2ipDecoderReason,
656    /// The converter watchdog is holding the stream back.
657    pub blocking: bool,
658    /// The recovered picture width, and 0 when none was recovered.
659    pub width: u16,
660    /// The recovered picture height, and 0 when none was recovered.
661    pub height: u16,
662    /// The recovered colour space.
663    pub format: V2ipDecoderFormat,
664    /// How many readings the sink has stored. Monotonic, wrapping at 65535
665    /// after some 36 hours, and never reset.
666    ///
667    /// A sink reads its video processor every two seconds and reports every
668    /// second, so roughly every other report repeats a reading already seen:
669    /// a frame arriving says nothing about how fresh the values in it are.
670    /// This counter moves only when a reading is stored, so a processor that
671    /// stopped answering leaves it still rather than implying a refresh.
672    ///
673    /// After pointing a sink at something else, wait for this to advance by
674    /// two before trusting the geometry. It ticks when a reply lands rather
675    /// than when a query is sent, so the first tick can carry an answer the
676    /// processor read fractionally before the switch; the second cannot,
677    /// because at most one query is outstanding at a time.
678    pub updates: u16,
679    /// Every cause that applies, as bit N for reason N. See
680    /// [`Self::has_cause`].
681    ///
682    /// This is what to classify on. [`Self::reason`] carries whichever cause
683    /// won a fixed priority contest, so a cause that is true can be absent
684    /// from it while present here. Bit 0 is cleared by the sender, so an empty
685    /// word means nothing beyond the primary cause applies.
686    ///
687    /// [`V2ipDecoderReason::NO_FORMAT`] and
688    /// [`V2ipDecoderReason::FORMAT_MISMATCH`] are the two arms of one decision
689    /// and never appear together.
690    pub flags: u32,
691    /// How many times the converter watchdog has triggered.
692    pub blocked_count: u32,
693}
694
695impl V2ipDecoderReport {
696    /// Reports whether the decoder recovered a geometry.
697    ///
698    /// This is what says whether the decoder is being given a codestream it
699    /// understands. [`Self::format`] cannot: it reads
700    /// [`V2ipDecoderFormat::RGB`] when nothing is arriving, which is
701    /// indistinguishable from a real RGB reading.
702    pub const fn has_geometry(&self) -> bool {
703        self.width != 0 && self.height != 0
704    }
705
706    /// Reports whether `reason` is among the causes that apply.
707    ///
708    /// [`Self::reason`] carries the primary cause and `flags` carries all of
709    /// them at once. Bit 0 is unused, so [`V2ipDecoderReason::OK`] is never
710    /// among them and an empty word means nothing beyond the primary cause
711    /// applies.
712    pub const fn has_cause(&self, reason: V2ipDecoderReason) -> bool {
713        let bit = reason.to_wire();
714        bit > 0 && bit < u32::BITS as u8 && self.flags & (1 << bit) != 0
715    }
716}
717
718/// What a statistics report says about the sink's decoder.
719///
720/// The three states are distinct answers and only [`Self::Answered`] carries a
721/// reading. `valid` follows the sink being configured rather than the sink
722/// being enabled, so a sink that is switched off still reports - with zero
723/// geometry and [`V2ipDecoderReason::NO_PACKETS`], the same reading a sink
724/// whose source has died produces.
725#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
726pub enum V2ipDecoderDetail {
727    /// The report carried no decoder block: the sender's firmware predates it.
728    #[default]
729    Absent,
730    /// The block is there and the decoder has never answered. Every field it
731    /// would carry is meaningless, so none is offered.
732    NeverAnswered,
733    /// A reading.
734    Answered(V2ipDecoderReport),
735}
736
737impl V2ipDecoderDetail {
738    /// The reading, for a caller that treats both of the other states as
739    /// "nothing to show".
740    pub const fn reading(self) -> Option<V2ipDecoderReport> {
741        match self {
742            Self::Answered(report) => Some(report),
743            _ => None,
744        }
745    }
746}
747
748/// The cumulative and per-minute transmit and receive statistics.
749#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
750pub struct V2ipDeviceStats {
751    /// Transmit totals since boot.
752    pub tx: V2ipTxStats,
753    /// Transmit counts over the last minute.
754    pub tx_per_minute: V2ipTxStats,
755    /// Receive totals since boot.
756    pub rx: V2ipRxStats,
757    /// Receive counts over the last minute.
758    pub rx_per_minute: V2ipRxStats,
759    /// What the sink's decoder recovered from the codestream it is decoding.
760    pub decoder: V2ipDecoderDetail,
761}