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, 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 frame carries the second group of scaling options.
245///
246/// Firmware that has those options sets this on every configuration it sends
247/// about itself, so it doubles as the report that the device has them at all.
248pub const SCALING_FLAG_OPTIONS2_VALID: u8 = 1 << 4;
249
250/// Set when the output follows its source's format instead of a fixed one.
251pub const SCALING_FLAG_MATCH_SOURCE: u8 = 1 << 5;
252
253/// Set when the output declines 4:2:0 rather than scaling it.
254pub const SCALING_FLAG_SKIP_420: u8 = 1 << 6;
255
256/// Set when the output scales automatically.
257pub const SCALING_FLAG_AUTO_SCALING: u8 = 1 << 7;
258
259/// The flag bits that carry meaning.
260///
261/// Bits 2 and 3 have no meaning and are excluded: they are not reliably zero
262/// on the wire, because firmware that does not initialise the configuration it
263/// broadcasts builds this frame from an uninitialised stack local and ORs its
264/// flags onto whatever was there. The same is true of every bit here on such a
265/// sender, which is why each reading below says what it rests on.
266pub const SCALING_FLAGS_DEFINED: u8 = SCALING_FLAG_MODE_VALID
267    | SCALING_FLAG_OPTIONS_VALID
268    | SCALING_FLAG_OPTIONS2_VALID
269    | SCALING_FLAG_MATCH_SOURCE
270    | SCALING_FLAG_SKIP_420
271    | SCALING_FLAG_AUTO_SCALING;
272
273/// Lowest refresh rate a V2IP output stage accepts, in Hz.
274///
275/// A receiver replaces anything outside
276/// [`V2IP_SCALING_REFRESH_MIN`]..=[`V2IP_SCALING_REFRESH_MAX`] with 50 rather
277/// than refusing the write, so 0 asks for 50Hz here instead of asking for
278/// nothing.
279pub const V2IP_SCALING_REFRESH_MIN: u16 = 24;
280
281/// Highest refresh rate a V2IP output stage accepts, in Hz. See
282/// [`V2IP_SCALING_REFRESH_MIN`].
283pub const V2IP_SCALING_REFRESH_MAX: u16 = 120;
284
285/// The output format to scale a V2IP sink to.
286///
287/// Built from a depth and a colour space rather than from a packed signal-type
288/// word, so the word a caller sends cannot carry the unset bpp index a sink
289/// reports while it has no mode configured.
290#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
291pub struct V2ipOutputMode {
292    /// The CTA-861 short video descriptor to output.
293    pub svd: u8,
294    /// Bit depth: 8, 10 or 12.
295    pub depth: u8,
296    /// The colour space to output.
297    pub colour: V2ipColourSpace,
298    /// Refresh rate in Hz, [`V2IP_SCALING_REFRESH_MIN`] to
299    /// [`V2IP_SCALING_REFRESH_MAX`].
300    pub refresh: u16,
301}
302
303impl V2ipOutputMode {
304    /// Reports whether a sink will take this mode, or why it will not.
305    ///
306    /// Checked here because a sink checks it and then says nothing: every value
307    /// this rejects is one the receiver decodes cleanly and drops, leaving a
308    /// caller with a send that succeeded and a setting that did not move.
309    ///
310    /// Passing is not a guarantee. A sink also weighs the format against the
311    /// EDID of the display attached to it and against what its own clock and
312    /// output stage can produce, and none of that is knowable from here.
313    pub fn validate(&self) -> Result<(), &'static str> {
314        if self.svd == 0 {
315            return Err("svd 0 is how a mode is cleared, not a mode to set");
316        }
317        if crate::lookup_svd(u16::from(self.svd)).is_none() {
318            return Err("the svd names no known video descriptor");
319        }
320        if MxrSignalType::bpp_index_for_depth(self.depth).is_none() {
321            return Err("a V2IP output stage takes 8, 10 or 12 bits per pixel");
322        }
323        if self.colour > V2ipColourSpace::YCBCR420 {
324            return Err("the colour space names none of RGB, 4:4:4, 4:2:2 or 4:2:0");
325        }
326        if !(V2IP_SCALING_REFRESH_MIN..=V2IP_SCALING_REFRESH_MAX).contains(&self.refresh) {
327            return Err("the refresh rate is outside 24..=120Hz");
328        }
329        Ok(())
330    }
331
332    /// The packed signal type a scaling write carries for this mode.
333    ///
334    /// Call [`V2ipOutputMode::validate`] first: an unvalidated depth packs as
335    /// the index for "no depth", which a receiver drops.
336    pub(crate) fn to_signal_type(self) -> MxrSignalType {
337        MxrSignalType::from_parts(
338            self.svd,
339            self.colour.to_wire(),
340            MxrSignalType::bpp_index_for_depth(self.depth).unwrap_or(0),
341        )
342    }
343}
344
345impl fmt::Display for V2ipOutputMode {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        write!(
348            f,
349            "svd {}, colour {}, {}bpp, {}Hz",
350            self.svd,
351            self.colour.to_wire(),
352            self.depth,
353            self.refresh
354        )
355    }
356}
357
358impl V2ipScalingSettings {
359    /// The mode this sink is configured to scale to, `None` when it has none.
360    ///
361    /// The two are distinct on the wire: a sink with no mode configured leaves
362    /// [`SCALING_FLAG_MODE_VALID`] clear, and never sets it over a zero mode.
363    ///
364    /// Trust it only where the sender reports
365    /// [`crate::DeviceInfo::config_initialised`]. Firmware without that builds
366    /// this block over uninitialised stack, where the valid bit itself is
367    /// noise.
368    pub const fn configured_mode(&self) -> Option<(MxrSignalType, u16)> {
369        if self.flags & SCALING_FLAG_MODE_VALID == 0 {
370            return None;
371        }
372        Some((self.mode, self.refresh))
373    }
374
375    /// Whether the output scales automatically, `None` when the sender did not
376    /// say.
377    pub const fn auto_scaling(&self) -> Option<bool> {
378        if self.flags & SCALING_FLAG_OPTIONS_VALID == 0 {
379            return None;
380        }
381        Some(self.flags & SCALING_FLAG_AUTO_SCALING != 0)
382    }
383
384    /// Whether the output follows its source's format, `None` when the device
385    /// has never said.
386    ///
387    /// Firmware with this option announces it on every configuration it sends
388    /// about itself, so a device that has reported it once is known to have
389    /// it. The cached block accumulates its validity bits, so a later write
390    /// carrying only the first options group does not take that back.
391    ///
392    /// Reported only from a sender announcing
393    /// [`crate::DeviceInfo::config_initialised`], so this needs no caveat of
394    /// its own: no firmware has these options without that announcement, and
395    /// one that lacks it would be reporting uninitialised stack. That is the
396    /// difference from [`Self::configured_mode`], which is reported from any
397    /// sender because a mode can be genuine on one of those.
398    pub const fn match_source(&self) -> Option<bool> {
399        if self.flags & SCALING_FLAG_OPTIONS2_VALID == 0 {
400            return None;
401        }
402        Some(self.flags & SCALING_FLAG_MATCH_SOURCE != 0)
403    }
404
405    /// Whether the output declines 4:2:0 rather than scaling it, `None` when
406    /// the device has never said. Reported on the same terms as
407    /// [`Self::match_source`], which shares its validity bit.
408    pub const fn skip_420(&self) -> Option<bool> {
409        if self.flags & SCALING_FLAG_OPTIONS2_VALID == 0 {
410            return None;
411        }
412        Some(self.flags & SCALING_FLAG_SKIP_420 != 0)
413    }
414
415    /// Folds a received scaling config onto the cached one, field by field.
416    ///
417    /// A write carries the mode or the options alone, so taking the block
418    /// wholesale would drop whichever half was not being written. The options
419    /// branch replaces the option bit rather than adding to it, which is what
420    /// lets an options-only write clear [`SCALING_FLAG_AUTO_SCALING`].
421    #[must_use]
422    pub fn merge(self, previous: Self) -> Self {
423        let mut out = previous;
424        if self.flags & SCALING_FLAG_MODE_VALID != 0 {
425            out.mode = self.mode;
426            out.refresh = self.refresh;
427            out.flags |= SCALING_FLAG_MODE_VALID;
428        }
429        if self.flags & SCALING_FLAG_OPTIONS_VALID != 0 {
430            out.flags &= !SCALING_FLAG_AUTO_SCALING;
431            out.flags |= SCALING_FLAG_OPTIONS_VALID;
432            out.flags |= self.flags & SCALING_FLAG_AUTO_SCALING;
433        }
434        // One validity bit covers both options in this group, so a frame
435        // carrying it replaces both and a frame without it leaves both alone -
436        // which is also what keeps the bit itself, and so the knowledge that
437        // the device has these options, from being taken back by a later write
438        // that carries only the first group.
439        if self.flags & SCALING_FLAG_OPTIONS2_VALID != 0 {
440            out.flags &= !(SCALING_FLAG_MATCH_SOURCE | SCALING_FLAG_SKIP_420);
441            out.flags |= SCALING_FLAG_OPTIONS2_VALID;
442            out.flags |= self.flags & (SCALING_FLAG_MATCH_SOURCE | SCALING_FLAG_SKIP_420);
443        }
444        out
445    }
446}
447
448/// The per-stream DSCP marking in a V2IP device configuration.
449///
450/// A stream whose wire byte carries no [`V2IP_DSCP_SET`] bit reads back as
451/// `None`. Firmware treats the marking as all-or-nothing: it applies one only
452/// when all three streams carry a value and otherwise falls back to the
453/// default, so [`V2ipDscpConfig::is_complete`] reports which case a frame is in.
454#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
455pub struct V2ipDscpConfig {
456    /// Marking for the video stream.
457    pub video: Option<u8>,
458    /// Marking for the audio stream.
459    pub audio: Option<u8>,
460    /// Marking for the ancillary-data stream.
461    pub anc: Option<u8>,
462}
463
464impl V2ipDscpConfig {
465    /// Reports whether all three streams carry a marking, which is what
466    /// firmware requires before it applies one.
467    pub const fn is_complete(&self) -> bool {
468        self.video.is_some() && self.audio.is_some() && self.anc.is_some()
469    }
470}
471
472impl fmt::Display for V2ipDscpConfig {
473    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
474        match (self.video, self.audio, self.anc) {
475            (Some(v), Some(a), Some(n)) => write!(f, "video:{v} audio:{a} anc:{n}"),
476            _ => f.write_str("no marking"),
477        }
478    }
479}
480
481/// Decodes one `dscp` byte, or `None` when the byte carries no marking.
482pub(crate) fn parse_dscp(raw: u8) -> Option<u8> {
483    (raw & V2IP_DSCP_SET != 0).then_some(raw & V2IP_DSCP_MAX)
484}
485
486/// The local encoder/decoder configuration of a V2IP device.
487#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
488pub struct DeviceV2ipDetails {
489    /// The video stream this device sources.
490    pub video: V2ipStreamSource,
491    /// The audio stream this device sources.
492    pub audio: V2ipStreamSource,
493    /// The ancillary-data stream this device sources.
494    pub anc: V2ipStreamSource,
495    /// The audio-return stream this device sources.
496    pub arc: V2ipStreamSource,
497
498    /// Encoder rate in units of 10Mb/s, or `None` when the sender offered no
499    /// rate.
500    ///
501    /// A rate-only write carries the rate on its own; every other controller
502    /// write puts a value outside the valid range here, which firmware drops as
503    /// invalid so that address-only and scaling writes leave the peer's rate
504    /// alone.
505    pub tx_rate: Option<u8>,
506
507    /// Per-stream DSCP marking.
508    pub dscp: V2ipDscpConfig,
509    /// Scaling mode, refresh rate and flags.
510    pub scaling: V2ipScalingSettings,
511}
512
513impl DeviceV2ipDetails {
514    /// Reports whether the source block carries usable addresses.
515    ///
516    /// Firmware requires video and anc; audio is optional and is carried with
517    /// them.
518    pub const fn source_is_valid(&self) -> bool {
519        self.video.is_valid() && self.anc.is_valid()
520    }
521
522    /// Folds a received device configuration onto the cached one.
523    ///
524    /// Every field is optional behind its own validity marker: the payload is
525    /// zeroed before a sender fills in the one field it is writing, so a
526    /// controller writing a TX rate sends zeroed addresses and a controller
527    /// writing addresses sends an out-of-range rate. Firmware applies each
528    /// field only behind its own test, so replacing the whole cached config on
529    /// every frame would make the peer read back with its addresses, rate or
530    /// marking gone.
531    #[must_use]
532    pub fn merge(mut self, previous: Option<Self>) -> Self {
533        let Some(previous) = previous else {
534            return self;
535        };
536        if !self.source_is_valid() {
537            self.video = previous.video;
538            self.audio = previous.audio;
539            self.anc = previous.anc;
540        }
541        if !self.arc.is_valid() {
542            self.arc = previous.arc;
543        }
544        if self.tx_rate.is_none() {
545            self.tx_rate = previous.tx_rate;
546        }
547        // Firmware gates all three dscp bytes on the video byte's set bit
548        // alone, and stores whatever the other two carry.
549        if self.dscp.video.is_none() {
550            self.dscp = previous.dscp;
551        }
552        self.scaling = self.scaling.merge(previous.scaling);
553        self
554    }
555}
556
557/// The sink-side route a V2IP device is subscribed to, as the mesh believes it.
558///
559/// A route request addressed to the device sets this the moment it is seen,
560/// which is what every device on the mesh does with one. So a request the
561/// device refused, or that reached it while it was offline, reads back here as
562/// though it had taken effect. Only the device's own configuration report
563/// confirms a route, and it sends that on its own schedule rather than in reply.
564///
565/// **Addresses that read as unset mean "no route, or the sink could not work
566/// one out" - never "definitely not subscribed".** This block is the one part
567/// of a device configuration with no validity marker of its own, so a sender
568/// with nothing to say sends zeros and every receiver stores them. A sender
569/// leaves it empty when its own stream configuration does not resolve, and that
570/// covers more than having no route: a selected source whose record has not
571/// arrived yet, which is the state after a restart at either end, missing audio
572/// bay configuration, or any of the three streams failing its validity check.
573/// The audio format has a second gate of its own, so it can be absent while the
574/// addresses are not.
575///
576/// This is worth expecting rather than guarding against. Any scaling change
577/// makes the device rebuild and rebroadcast this block, and a write aimed at a
578/// remote bay sends it zeroed however it was requested - so the empty reading
579/// arrives most often during exactly the no-signal troubleshooting that
580/// prompted the change. A device's periodic report puts a real route back
581/// within a minute of it having one.
582///
583/// An empty reading is applied rather than ignored on purpose. A sink that has
584/// genuinely dropped its route sends the same zeros, and so does every report
585/// after it, so refusing them would cache a route that nothing later could ever
586/// clear.
587#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
588pub struct DeviceV2ipSink {
589    /// The streams the sink subscribes to.
590    pub addresses: V2ipStreamSources,
591    /// The resolved audio format, when the sender reported one.
592    pub audio_fmt: Option<V2ipAudioFormat>,
593}
594
595/// Transmitter stream statistics.
596#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
597pub struct V2ipTxStats {
598    /// Video packets sent.
599    pub video: u32,
600    /// Audio packets sent.
601    pub audio: u32,
602    /// Ancillary-data packets sent.
603    pub anc: u32,
604    /// Times the stream went down.
605    pub stream_down: u32,
606    /// Transmit overflows.
607    pub overflow: u32,
608}
609
610/// The health state of a V2IP decoder.
611#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
612pub struct V2ipDecoderState(u8);
613
614impl V2ipDecoderState {
615    /// The sink has not reported a state.
616    pub const UNKNOWN: Self = Self(0);
617    /// Decoding normally.
618    pub const HEALTHY: Self = Self(1);
619    /// Failed to decode.
620    pub const BAD: Self = Self(2);
621    /// Still coming up, which any sink subscribed to during a route change
622    /// reports.
623    pub const STARTING: Self = Self(3);
624
625    /// Wraps a raw wire value, including one this library has no name for.
626    pub const fn from_wire(value: u8) -> Self {
627        Self(value)
628    }
629
630    /// Returns the raw wire value.
631    pub const fn to_wire(self) -> u8 {
632        self.0
633    }
634
635    /// Reports whether the decoder has reached a verdict.
636    ///
637    /// Only healthy and bad are verdicts. Testing for failure as "not healthy"
638    /// reads a receiver that is merely coming up as one that failed to decode,
639    /// which is what a sink reports for a moment after every route change.
640    pub const fn is_settled(self) -> bool {
641        matches!(self, Self::HEALTHY | Self::BAD)
642    }
643}
644
645impl fmt::Display for V2ipDecoderState {
646    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
647        match *self {
648            Self::UNKNOWN => f.write_str("Unknown"),
649            Self::HEALTHY => f.write_str("Healthy"),
650            Self::BAD => f.write_str("Bad"),
651            Self::STARTING => f.write_str("Starting"),
652            Self(v) => write!(f, "state {v}"),
653        }
654    }
655}
656
657/// Receiver stream statistics.
658#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
659pub struct V2ipRxStats {
660    /// Video packets received.
661    pub video_total: u32,
662    /// Video packets dropped.
663    pub video_dropped: u32,
664    /// Video sequence errors.
665    pub video_seq_errors: u32,
666    /// Watchdog timeouts.
667    pub wdt_timeout: u32,
668    /// Audio packets received.
669    pub audio_total: u32,
670    /// Audio packets dropped.
671    pub audio_dropped: u32,
672    /// Audio sequence errors.
673    pub audio_seq_errors: u32,
674    /// Ancillary-data packets received.
675    pub anc_total: u32,
676    /// Ancillary-data packets dropped.
677    pub anc_dropped: u32,
678    /// Ancillary-data sequence errors.
679    pub anc_seq_errors: u32,
680    /// The decoder's health state.
681    pub decoder_state: V2ipDecoderState,
682}
683
684/// Why a decoder reports the state it does.
685///
686/// The primary cause only. Several causes can be true at once, and which of
687/// them lands here is a fixed priority order in the firmware that the numbering
688/// does not express: these values are identities, not ranks, and comparing or
689/// ordering them says nothing. Ask [`V2ipDecoderReport::has_cause`] whether a
690/// particular cause applies - a test against this field answers "is this the
691/// one that won" instead, which is a different question.
692///
693/// Firmware adds causes, so the wire value is carried as it arrived: folding an
694/// unrecognised one onto a named cause would report a fault this library
695/// invented. Appending one cannot reorder the existing priorities.
696#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
697pub struct V2ipDecoderReason(u8);
698
699impl V2ipDecoderReason {
700    /// Decoding normally.
701    pub const OK: Self = Self(0);
702    /// No packets are arriving.
703    pub const NO_PACKETS: Self = Self(1);
704    /// Packets are arriving, degraded.
705    pub const PACKETS_DEGRADED: Self = Self(2);
706    /// No format could be recovered from the codestream.
707    pub const NO_FORMAT: Self = Self(3);
708    /// The recovered format is not the one the sink is configured for.
709    pub const FORMAT_MISMATCH: Self = Self(4);
710    /// The configured output format was refused.
711    pub const FORMAT_REJECTED: Self = Self(5);
712    /// The converter watchdog is holding the stream back.
713    pub const DECODER_BLOCKED: Self = Self(6);
714    /// A source switch is in progress: a step in an operation someone asked
715    /// for, rather than a fault.
716    pub const SWITCH_PENDING: Self = Self(7);
717    /// PTP is unlocked. That costs audio alone; the picture is unaffected.
718    pub const PTP_UNLOCKED: Self = Self(8);
719    /// The pipeline is rebuilding after the HDMI transmitter stayed unlocked.
720    ///
721    /// The picture is down, and has been for five seconds before this can
722    /// appear: the sender debounces the unlocked reading for that long, so
723    /// this never reports a transient. Unlike [`Self::SWITCH_PENDING`] nobody
724    /// asked for it.
725    ///
726    /// The debounce restarts each time it elapses, so this holding across
727    /// reports is a restart loop rather than one event, and that is what to
728    /// escalate on.
729    ///
730    /// It sits near the bottom of the priority order, below every input-side
731    /// cause, so a rebuilding pipeline names one of those in
732    /// [`V2ipDecoderReport::reason`] and carries this in
733    /// [`V2ipDecoderReport::flags`] alone - always, rather than briefly.
734    ///
735    /// It is evaluated only while no format change is in progress. Across a
736    /// switch it holds its previous value and clears on the first reading
737    /// after the change settles, which [`V2ipDecoderReport::updates`] cannot
738    /// distinguish: a value carried forward is still a stored reading.
739    pub const TX_BRIDGE_UNLOCKED: Self = Self(9);
740    /// The sink is configured but switched off, so no stream is expected.
741    ///
742    /// This outranks every other cause: whenever it applies it is what
743    /// [`V2ipDecoderReport::reason`] carries.
744    ///
745    /// **The causes beneath it stay set in [`V2ipDecoderReport::flags`].** A
746    /// sink switched off while it was running keeps the bits the decoder
747    /// genuinely observed on the way down - no packets, no format - so a
748    /// classifier that tests a fault mask over the whole word calls a
749    /// deliberately disabled sink broken. Ask for this cause first and stop
750    /// there; the bits below it describe what was seen, not a fault to report.
751    ///
752    /// This says nothing about geometry, in either direction. The decoder
753    /// reports what it currently detects whatever the cause, so a switched-off
754    /// sink still detecting a codestream carries a real geometry, and a zero
755    /// one means the decoder has nothing rather than that the sink is off.
756    ///
757    /// Older senders never report this and give [`Self::NO_PACKETS`] for a
758    /// disabled sink instead, indistinguishable from one whose source has
759    /// died. So an absent [`Self::IDLE`] is not evidence a sink is enabled,
760    /// and **nothing in this block answers enablement**: it carries no such
761    /// field, and the answer comes from `V2IP_DEVICE_CFG` or the device's HTTP
762    /// status.
763    pub const IDLE: Self = Self(10);
764
765    /// Wraps a raw wire value, including one this library has no name for.
766    pub const fn from_wire(value: u8) -> Self {
767        Self(value)
768    }
769
770    /// Returns the raw wire value.
771    pub const fn to_wire(self) -> u8 {
772        self.0
773    }
774}
775
776impl fmt::Display for V2ipDecoderReason {
777    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
778        match *self {
779            Self::OK => f.write_str("ok"),
780            Self::NO_PACKETS => f.write_str("no packets"),
781            Self::PACKETS_DEGRADED => f.write_str("packets degraded"),
782            Self::NO_FORMAT => f.write_str("no format recovered"),
783            Self::FORMAT_MISMATCH => f.write_str("format mismatch"),
784            Self::FORMAT_REJECTED => f.write_str("format rejected"),
785            Self::DECODER_BLOCKED => f.write_str("decoder blocked"),
786            Self::SWITCH_PENDING => f.write_str("switch pending"),
787            Self::PTP_UNLOCKED => f.write_str("PTP unlocked"),
788            Self::TX_BRIDGE_UNLOCKED => f.write_str("TX bridge unlocked"),
789            Self::IDLE => f.write_str("idle"),
790            Self(v) => write!(f, "reason {v}"),
791        }
792    }
793}
794
795/// The colour space a decoder recovered from a codestream.
796///
797/// Zero is RGB and is also what a decoder with nothing to decode reports, so no
798/// value here means "no signal" - [`V2ipDecoderReport::has_geometry`] is what
799/// answers that.
800#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
801pub struct V2ipDecoderFormat(u16);
802
803impl V2ipDecoderFormat {
804    /// RGB.
805    pub const RGB: Self = Self(0);
806    /// YCbCr 4:4:4.
807    pub const YCBCR_444: Self = Self(1);
808    /// YCbCr 4:2:2.
809    pub const YCBCR_422: Self = Self(2);
810    /// YCbCr 4:2:0.
811    pub const YCBCR_420: Self = Self(3);
812    /// The decoder cannot name the format.
813    ///
814    /// 255, which is a value of its own rather than the 0xF a signal report
815    /// uses for an unknown colour space. Mapping one onto the other yields a
816    /// colour space the decoder never reported.
817    pub const UNNAMED: Self = Self(255);
818
819    /// Wraps a raw wire value, including one this library has no name for.
820    pub const fn from_wire(value: u16) -> Self {
821        Self(value)
822    }
823
824    /// Returns the raw wire value.
825    pub const fn to_wire(self) -> u16 {
826        self.0
827    }
828}
829
830impl fmt::Display for V2ipDecoderFormat {
831    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
832        match *self {
833            Self::RGB => f.write_str("RGB"),
834            Self::YCBCR_444 => f.write_str("YCbCr 4:4:4"),
835            Self::YCBCR_422 => f.write_str("YCbCr 4:2:2"),
836            Self::YCBCR_420 => f.write_str("YCbCr 4:2:0"),
837            Self::UNNAMED => f.write_str("unnamed"),
838            Self(v) => write!(f, "format {v}"),
839        }
840    }
841}
842
843/// What a sink's decoder recovered from the codestream it is being given.
844///
845/// This is what the decoder understood, read ahead of the scaler: the geometry
846/// is unrounded and is not what the display is being sent. It separates "the
847/// decoder understood the codestream" from "a picture came out the other end".
848///
849/// Colour depth is absent on purpose and will stay absent. The video processor
850/// answers that one from a driver constant rather than from the codestream, so
851/// there is no reading to carry; assert depth at the encoder's input bay
852/// instead.
853#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
854pub struct V2ipDecoderReport {
855    /// The primary cause of the state the decoder is in.
856    pub reason: V2ipDecoderReason,
857    /// The converter watchdog is holding the stream back.
858    pub blocking: bool,
859    /// The recovered picture width, and 0 when none was recovered.
860    pub width: u16,
861    /// The recovered picture height, and 0 when none was recovered.
862    pub height: u16,
863    /// The recovered colour space.
864    pub format: V2ipDecoderFormat,
865    /// How many readings the sink has stored. Monotonic, wrapping at 65535
866    /// after some 36 hours, and never reset.
867    ///
868    /// A sink reads its video processor every two seconds and reports every
869    /// second, so roughly every other report repeats a reading already seen:
870    /// a frame arriving says nothing about how fresh the values in it are.
871    /// This counter moves only when a reading is stored, so a processor that
872    /// stopped answering leaves it still rather than implying a refresh.
873    ///
874    /// After pointing a sink at something else, wait for this to advance by
875    /// two before trusting the geometry. It ticks when a reply lands rather
876    /// than when a query is sent, so the first tick can carry an answer the
877    /// processor read fractionally before the switch; the second cannot,
878    /// because at most one query is outstanding at a time.
879    pub updates: u16,
880    /// Every cause that applies, as bit N for reason N. See
881    /// [`Self::has_cause`].
882    ///
883    /// This is what to classify on, once [`V2ipDecoderReason::IDLE`] has been
884    /// ruled out: that cause outranks the whole word and leaves the bits below
885    /// it set, so a fault mask over `flags` reports a switched-off sink as
886    /// broken. [`Self::reason`] carries whichever cause won a fixed priority
887    /// contest, so a cause that is true can be absent from it while present
888    /// here. Bit 0 is cleared by the sender, so an empty word means nothing
889    /// beyond the primary cause applies.
890    ///
891    /// [`V2ipDecoderReason::NO_FORMAT`] and
892    /// [`V2ipDecoderReason::FORMAT_MISMATCH`] are the two arms of one decision
893    /// and never appear together.
894    pub flags: u32,
895    /// How many times the converter watchdog has triggered.
896    pub blocked_count: u32,
897}
898
899impl V2ipDecoderReport {
900    /// Reports whether the decoder recovered a geometry.
901    ///
902    /// This is what says whether the decoder is being given a codestream it
903    /// understands. [`Self::format`] cannot: it reads
904    /// [`V2ipDecoderFormat::RGB`] when nothing is arriving, which is
905    /// indistinguishable from a real RGB reading.
906    ///
907    /// It answers that and nothing else. The reading is taken before any cause
908    /// is decided, so it does not say whether the sink is switched on: a sink
909    /// that is off can still detect a codestream, and one that is on can
910    /// detect nothing.
911    pub const fn has_geometry(&self) -> bool {
912        self.width != 0 && self.height != 0
913    }
914
915    /// Reports whether `reason` is among the causes that apply.
916    ///
917    /// [`Self::reason`] carries the primary cause and `flags` carries all of
918    /// them at once. Bit 0 is unused, so [`V2ipDecoderReason::OK`] is never
919    /// among them and an empty word means nothing beyond the primary cause
920    /// applies.
921    pub const fn has_cause(&self, reason: V2ipDecoderReason) -> bool {
922        let bit = reason.to_wire();
923        bit > 0 && bit < u32::BITS as u8 && self.flags & (1 << bit) != 0
924    }
925}
926
927/// What a statistics report says about the sink's decoder.
928///
929/// The three states are distinct answers and only [`Self::Answered`] carries a
930/// reading. `valid` follows the sink being configured rather than the sink
931/// being enabled, so a sink that is switched off still reports: as
932/// [`V2ipDecoderReason::IDLE`], or from an older sender as
933/// [`V2ipDecoderReason::NO_PACKETS`], which is the same reading a sink whose
934/// source has died produces.
935#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
936pub enum V2ipDecoderDetail {
937    /// The report carried no decoder block: the sender's firmware predates it.
938    #[default]
939    Absent,
940    /// The block is there and the decoder has never answered. Every field it
941    /// would carry is meaningless, so none is offered.
942    NeverAnswered,
943    /// A reading.
944    Answered(V2ipDecoderReport),
945}
946
947impl V2ipDecoderDetail {
948    /// The reading, for a caller that treats both of the other states as
949    /// "nothing to show".
950    pub const fn reading(self) -> Option<V2ipDecoderReport> {
951        match self {
952            Self::Answered(report) => Some(report),
953            _ => None,
954        }
955    }
956}
957
958/// The cumulative and per-minute transmit and receive statistics.
959#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
960pub struct V2ipDeviceStats {
961    /// Transmit totals since boot.
962    pub tx: V2ipTxStats,
963    /// Transmit counts over the last minute.
964    pub tx_per_minute: V2ipTxStats,
965    /// Receive totals since boot.
966    pub rx: V2ipRxStats,
967    /// Receive counts over the last minute.
968    pub rx_per_minute: V2ipRxStats,
969    /// What the sink's decoder recovered from the codestream it is decoding.
970    pub decoder: V2ipDecoderDetail,
971}