mx-remote 4.0.0

Client library for Pulse-Eight MatrixOS devices over UDP multicast/broadcast
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
// Copyright (c) 2026 Op den Kamp IT Solutions

//! V2IP stream configuration, statistics and the sink-side route.

use core::fmt;
use std::net::Ipv4Addr;

use crate::wire::{
    DeviceUid, MxrSignalType, V2IP_AUDIO_DEFAULT_CHANNELS, V2IP_AUDIO_DEFAULT_SAMPLE_RATE,
    V2IP_DSCP_MAX, V2IP_DSCP_SET,
};

/// Which of a V2IP device's streams an address describes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum StreamKind {
    /// The video stream.
    #[default]
    Video,
    /// The audio stream.
    Audio,
    /// The ancillary-data stream.
    Anc,
    /// The audio-return stream.
    Arc,
}

impl fmt::Display for StreamKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Self::Video => "video",
            Self::Audio => "audio",
            Self::Anc => "anc",
            Self::Arc => "arc",
        })
    }
}

/// A single multicast stream address.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct V2ipStreamSource {
    /// Which stream this address is for.
    pub kind: StreamKind,
    /// The multicast group.
    pub ip: Ipv4Addr,
    /// The destination UDP port.
    pub port: u16,
}

impl Default for V2ipStreamSource {
    fn default() -> Self {
        Self {
            kind: StreamKind::default(),
            ip: Ipv4Addr::UNSPECIFIED,
            port: 0,
        }
    }
}

impl V2ipStreamSource {
    /// Reports whether this carries a usable address: a multicast group and a
    /// non-zero port, both, matching firmware `mxr_v2ip_stream_valid`.
    pub const fn is_valid(&self) -> bool {
        self.ip.is_multicast() && self.port != 0
    }
}

impl fmt::Display for V2ipStreamSource {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}={}:{}", self.kind, self.ip, self.port)
    }
}

/// The streams advertised by a single V2IP source.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct V2ipStreamSources {
    /// The originating device, or the zero UID when it is not known.
    pub uid: DeviceUid,
    /// The video stream.
    pub video: V2ipStreamSource,
    /// The audio stream.
    pub audio: V2ipStreamSource,
    /// The ancillary-data stream.
    pub anc: V2ipStreamSource,
    /// The audio-return stream, when one is advertised.
    pub arc: Option<V2ipStreamSource>,
}

impl fmt::Display for V2ipStreamSources {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "video:{} audio:{} anc:{}",
            self.video, self.audio, self.anc
        )
    }
}

/// One multicast destination in a route the caller assembles.
///
/// The unspecified address sends the slot zeroed, naming no group for that
/// stream. It is not a way to leave one stream alone: the firmware decides
/// whether a sink has a manual route at all by reading the video and
/// ancillary slots, so an empty one of those disqualifies the whole route
/// rather than preserving anything - see
/// [`crate::Remote::select_source_addr`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct V2ipRouteTarget {
    /// The multicast group.
    pub ip: Ipv4Addr,
    /// The destination UDP port. Zero means the standard port for the stream
    /// this target is given as.
    pub port: u16,
}

impl Default for V2ipRouteTarget {
    fn default() -> Self {
        Self {
            ip: Ipv4Addr::UNSPECIFIED,
            port: 0,
        }
    }
}

impl V2ipRouteTarget {
    /// A target at the standard port for its stream.
    pub const fn new(ip: Ipv4Addr) -> Self {
        Self { ip, port: 0 }
    }

    /// The port to send, substituting `standard` for an unset one.
    pub(crate) const fn port_or(self, standard: u16) -> u16 {
        if self.port == 0 {
            standard
        } else {
            self.port
        }
    }
}

impl fmt::Display for V2ipRouteTarget {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}:{}", self.ip, self.port)
    }
}

/// The three streams a manual route points a V2IP sink at.
///
/// Fill in all three. The firmware decides whether a sink has a manual route
/// at all by looking at the video and ancillary groups, so a route carrying
/// only audio does not register as one and the sink falls back to the audio
/// source its mesh picks.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct V2ipRoute {
    /// The video stream, at [`crate::V2IP_PORT_VIDEO`] unless the port says otherwise.
    pub video: V2ipRouteTarget,
    /// The audio stream, at [`crate::V2IP_PORT_AUDIO`] unless the port says otherwise.
    pub audio: V2ipRouteTarget,
    /// The ancillary-data stream, at [`crate::V2IP_PORT_ANC`] unless the port says
    /// otherwise.
    pub anc: V2ipRouteTarget,
}

impl V2ipRoute {
    /// The three streams of one source, at the ports it advertises them on.
    pub fn of(sources: &V2ipStreamSources) -> Self {
        let target = |s: &V2ipStreamSource| V2ipRouteTarget {
            ip: s.ip,
            port: s.port,
        };
        Self {
            video: target(&sources.video),
            audio: target(&sources.audio),
            anc: target(&sources.anc),
        }
    }
}

impl fmt::Display for V2ipRoute {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "video:{} audio:{} anc:{}",
            self.video, self.audio, self.anc
        )
    }
}

/// The sample rate and channel count a V2IP audio stream is decoded at.
///
/// Fill both in. The firmware header calls zero "use the default", but the
/// path that applies a manual route substitutes nothing: it hands the pair to
/// the FPGA as it arrived, and the FPGA rejects a zero rate and takes the
/// whole switch down with it. [`V2ipAudioFormat::STANDARD`] is the pair the
/// header documents as the default.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct V2ipAudioFormat {
    /// Sample rate in Hz.
    pub sample_rate: u32,
    /// Channel count.
    pub channels: u8,
}

impl V2ipAudioFormat {
    /// 48kHz stereo: the rate and channel count the firmware header names as
    /// its default, which a caller has to send because firmware does not
    /// substitute it.
    pub const STANDARD: Self = Self {
        sample_rate: V2IP_AUDIO_DEFAULT_SAMPLE_RATE,
        channels: V2IP_AUDIO_DEFAULT_CHANNELS,
    };

    /// Encodes `v2ip_audio_format`: a `u32` rate, a channel byte and three
    /// reserved bytes, padded to the struct's 8-byte alignment.
    pub(crate) fn wire(&self) -> [u8; 8] {
        let r = self.sample_rate.to_le_bytes();
        [r[0], r[1], r[2], r[3], self.channels, 0, 0, 0]
    }
}

impl fmt::Display for V2ipAudioFormat {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}Hz/{}ch", self.sample_rate, self.channels)
    }
}

/// A V2IP output's scaling mode, refresh rate and flags.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct V2ipScalingSettings {
    /// The signal type the output scales to.
    pub mode: MxrSignalType,
    /// Refresh rate in Hz.
    pub refresh: u16,
    /// The flag bits below.
    pub flags: u8,
}

/// Set when the frame carries a scaling mode and refresh rate.
pub const SCALING_FLAG_MODE_VALID: u8 = 1 << 0;

/// Set when the frame carries the scaling options.
pub const SCALING_FLAG_OPTIONS_VALID: u8 = 1 << 1;

/// Set when the output scales automatically.
pub const SCALING_FLAG_AUTO_SCALING: u8 = 1 << 7;

/// The flag bits that carry meaning.
///
/// Bits 2..6 are undefined and are not reliably zero on the wire: firmware
/// that does not initialise the configuration it broadcasts builds this frame
/// from an uninitialised stack local and ORs its flags onto whatever was
/// there.
pub const SCALING_FLAGS_DEFINED: u8 =
    SCALING_FLAG_MODE_VALID | SCALING_FLAG_OPTIONS_VALID | SCALING_FLAG_AUTO_SCALING;

impl V2ipScalingSettings {
    /// Folds a received scaling config onto the cached one, field by field.
    ///
    /// A write carries the mode or the options alone, so taking the block
    /// wholesale would drop whichever half was not being written. The options
    /// branch replaces the option bit rather than adding to it, which is what
    /// lets an options-only write clear [`SCALING_FLAG_AUTO_SCALING`].
    #[must_use]
    pub fn merge(self, previous: Self) -> Self {
        let mut out = previous;
        if self.flags & SCALING_FLAG_MODE_VALID != 0 {
            out.mode = self.mode;
            out.refresh = self.refresh;
            out.flags |= SCALING_FLAG_MODE_VALID;
        }
        if self.flags & SCALING_FLAG_OPTIONS_VALID != 0 {
            out.flags &= !SCALING_FLAG_AUTO_SCALING;
            out.flags |= SCALING_FLAG_OPTIONS_VALID;
            out.flags |= self.flags & SCALING_FLAG_AUTO_SCALING;
        }
        out
    }
}

/// The per-stream DSCP marking in a V2IP device configuration.
///
/// A stream whose wire byte carries no [`V2IP_DSCP_SET`] bit reads back as
/// `None`. Firmware treats the marking as all-or-nothing: it applies one only
/// when all three streams carry a value and otherwise falls back to the
/// default, so [`V2ipDscpConfig::is_complete`] reports which case a frame is in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct V2ipDscpConfig {
    /// Marking for the video stream.
    pub video: Option<u8>,
    /// Marking for the audio stream.
    pub audio: Option<u8>,
    /// Marking for the ancillary-data stream.
    pub anc: Option<u8>,
}

impl V2ipDscpConfig {
    /// Reports whether all three streams carry a marking, which is what
    /// firmware requires before it applies one.
    pub const fn is_complete(&self) -> bool {
        self.video.is_some() && self.audio.is_some() && self.anc.is_some()
    }
}

impl fmt::Display for V2ipDscpConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match (self.video, self.audio, self.anc) {
            (Some(v), Some(a), Some(n)) => write!(f, "video:{v} audio:{a} anc:{n}"),
            _ => f.write_str("no marking"),
        }
    }
}

/// Decodes one `dscp` byte, or `None` when the byte carries no marking.
pub(crate) fn parse_dscp(raw: u8) -> Option<u8> {
    (raw & V2IP_DSCP_SET != 0).then_some(raw & V2IP_DSCP_MAX)
}

/// The local encoder/decoder configuration of a V2IP device.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DeviceV2ipDetails {
    /// The video stream this device sources.
    pub video: V2ipStreamSource,
    /// The audio stream this device sources.
    pub audio: V2ipStreamSource,
    /// The ancillary-data stream this device sources.
    pub anc: V2ipStreamSource,
    /// The audio-return stream this device sources.
    pub arc: V2ipStreamSource,

    /// Encoder rate in units of 10Mb/s, or `None` when the sender offered no
    /// rate.
    ///
    /// A rate-only write carries the rate on its own; every other controller
    /// write puts a value outside the valid range here, which firmware drops as
    /// invalid so that address-only and scaling writes leave the peer's rate
    /// alone.
    pub tx_rate: Option<u8>,

    /// Per-stream DSCP marking.
    pub dscp: V2ipDscpConfig,
    /// Scaling mode, refresh rate and flags.
    pub scaling: V2ipScalingSettings,
}

impl DeviceV2ipDetails {
    /// Reports whether the source block carries usable addresses.
    ///
    /// Firmware requires video and anc; audio is optional and is carried with
    /// them.
    pub const fn source_is_valid(&self) -> bool {
        self.video.is_valid() && self.anc.is_valid()
    }

    /// Folds a received device configuration onto the cached one.
    ///
    /// Every field is optional behind its own validity marker: the payload is
    /// zeroed before a sender fills in the one field it is writing, so a
    /// controller writing a TX rate sends zeroed addresses and a controller
    /// writing addresses sends an out-of-range rate. Firmware applies each
    /// field only behind its own test, so replacing the whole cached config on
    /// every frame would make the peer read back with its addresses, rate or
    /// marking gone.
    #[must_use]
    pub fn merge(mut self, previous: Option<Self>) -> Self {
        let Some(previous) = previous else {
            return self;
        };
        if !self.source_is_valid() {
            self.video = previous.video;
            self.audio = previous.audio;
            self.anc = previous.anc;
        }
        if !self.arc.is_valid() {
            self.arc = previous.arc;
        }
        if self.tx_rate.is_none() {
            self.tx_rate = previous.tx_rate;
        }
        // Firmware gates all three dscp bytes on the video byte's set bit
        // alone, and stores whatever the other two carry.
        if self.dscp.video.is_none() {
            self.dscp = previous.dscp;
        }
        self.scaling = self.scaling.merge(previous.scaling);
        self
    }
}

/// The sink-side route a V2IP device is subscribed to, as the mesh believes it.
///
/// A route request addressed to the device sets this the moment it is seen,
/// which is what every device on the mesh does with one. So a request the
/// device refused, or that reached it while it was offline, reads back here as
/// though it had taken effect. Only the device's own configuration report
/// confirms a route, and it sends that on its own schedule rather than in reply.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DeviceV2ipSink {
    /// The streams the sink subscribes to.
    pub addresses: V2ipStreamSources,
    /// The resolved audio format, when the sender reported one.
    pub audio_fmt: Option<V2ipAudioFormat>,
}

/// Transmitter stream statistics.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct V2ipTxStats {
    /// Video packets sent.
    pub video: u32,
    /// Audio packets sent.
    pub audio: u32,
    /// Ancillary-data packets sent.
    pub anc: u32,
    /// Times the stream went down.
    pub stream_down: u32,
    /// Transmit overflows.
    pub overflow: u32,
}

/// The health state of a V2IP decoder.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct V2ipDecoderState(u8);

impl V2ipDecoderState {
    /// The sink has not reported a state.
    pub const UNKNOWN: Self = Self(0);
    /// Decoding normally.
    pub const HEALTHY: Self = Self(1);
    /// Failed to decode.
    pub const BAD: Self = Self(2);
    /// Still coming up, which any sink subscribed to during a route change
    /// reports.
    pub const STARTING: Self = Self(3);

    /// Wraps a raw wire value, including one this library has no name for.
    pub const fn from_wire(value: u8) -> Self {
        Self(value)
    }

    /// Returns the raw wire value.
    pub const fn to_wire(self) -> u8 {
        self.0
    }

    /// Reports whether the decoder has reached a verdict.
    ///
    /// Only healthy and bad are verdicts. Testing for failure as "not healthy"
    /// reads a receiver that is merely coming up as one that failed to decode,
    /// which is what a sink reports for a moment after every route change.
    pub const fn is_settled(self) -> bool {
        matches!(self, Self::HEALTHY | Self::BAD)
    }
}

impl fmt::Display for V2ipDecoderState {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match *self {
            Self::UNKNOWN => f.write_str("Unknown"),
            Self::HEALTHY => f.write_str("Healthy"),
            Self::BAD => f.write_str("Bad"),
            Self::STARTING => f.write_str("Starting"),
            Self(v) => write!(f, "state {v}"),
        }
    }
}

/// Receiver stream statistics.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct V2ipRxStats {
    /// Video packets received.
    pub video_total: u32,
    /// Video packets dropped.
    pub video_dropped: u32,
    /// Video sequence errors.
    pub video_seq_errors: u32,
    /// Watchdog timeouts.
    pub wdt_timeout: u32,
    /// Audio packets received.
    pub audio_total: u32,
    /// Audio packets dropped.
    pub audio_dropped: u32,
    /// Audio sequence errors.
    pub audio_seq_errors: u32,
    /// Ancillary-data packets received.
    pub anc_total: u32,
    /// Ancillary-data packets dropped.
    pub anc_dropped: u32,
    /// Ancillary-data sequence errors.
    pub anc_seq_errors: u32,
    /// The decoder's health state.
    pub decoder_state: V2ipDecoderState,
}

/// Why a decoder reports the state it does.
///
/// The primary cause only. Several causes can be true at once, and which of
/// them lands here is a fixed priority order in the firmware that the numbering
/// does not express: these values are identities, not ranks, and comparing or
/// ordering them says nothing. Ask [`V2ipDecoderReport::has_cause`] whether a
/// particular cause applies - a test against this field answers "is this the
/// one that won" instead, which is a different question.
///
/// Firmware adds causes, so the wire value is carried as it arrived: folding an
/// unrecognised one onto a named cause would report a fault this library
/// invented. Appending one cannot reorder the existing priorities.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct V2ipDecoderReason(u8);

impl V2ipDecoderReason {
    /// Decoding normally.
    pub const OK: Self = Self(0);
    /// No packets are arriving.
    pub const NO_PACKETS: Self = Self(1);
    /// Packets are arriving, degraded.
    pub const PACKETS_DEGRADED: Self = Self(2);
    /// No format could be recovered from the codestream.
    pub const NO_FORMAT: Self = Self(3);
    /// The recovered format is not the one the sink is configured for.
    pub const FORMAT_MISMATCH: Self = Self(4);
    /// The configured output format was refused.
    pub const FORMAT_REJECTED: Self = Self(5);
    /// The converter watchdog is holding the stream back.
    pub const DECODER_BLOCKED: Self = Self(6);
    /// A source switch is in progress: a step in an operation someone asked
    /// for, rather than a fault.
    pub const SWITCH_PENDING: Self = Self(7);
    /// PTP is unlocked. That costs audio alone; the picture is unaffected.
    pub const PTP_UNLOCKED: Self = Self(8);
    /// The pipeline is rebuilding after the HDMI transmitter stayed unlocked.
    ///
    /// The picture is down, and has been for five seconds before this can
    /// appear: the sender debounces the unlocked reading for that long, so
    /// this never reports a transient. Unlike [`Self::SWITCH_PENDING`] nobody
    /// asked for it.
    ///
    /// The debounce restarts each time it elapses, so this holding across
    /// reports is a restart loop rather than one event, and that is what to
    /// escalate on.
    ///
    /// It sits near the bottom of the priority order, below every input-side
    /// cause, so a rebuilding pipeline names one of those in
    /// [`V2ipDecoderReport::reason`] and carries this in
    /// [`V2ipDecoderReport::flags`] alone - always, rather than briefly.
    ///
    /// It is evaluated only while no format change is in progress. Across a
    /// switch it holds its previous value and clears on the first reading
    /// after the change settles, which [`V2ipDecoderReport::updates`] cannot
    /// distinguish: a value carried forward is still a stored reading.
    pub const TX_BRIDGE_UNLOCKED: Self = Self(9);
    /// The sink is configured but not expecting a stream.
    ///
    /// Effectively unreachable on current firmware: the sink derives its
    /// expectation from the channel's running state, which the pipeline
    /// re-establishes within about one 10ms poll, so the window this describes
    /// closes before a report goes out. A sink that has been switched off
    /// reports [`Self::NO_PACKETS`] indefinitely instead, indistinguishable
    /// from one whose source is dead. **Nothing on this wire says a sink was
    /// switched off deliberately** - the block carries no enablement field at
    /// all, so a sink that is off and a sink that should be receiving and is
    /// not produce the same reading. Enablement comes from `V2IP_DEVICE_CFG`
    /// or the device's HTTP status, and only whatever issued the instruction
    /// knows it was deliberate.
    pub const IDLE: Self = Self(10);

    /// Wraps a raw wire value, including one this library has no name for.
    pub const fn from_wire(value: u8) -> Self {
        Self(value)
    }

    /// Returns the raw wire value.
    pub const fn to_wire(self) -> u8 {
        self.0
    }
}

impl fmt::Display for V2ipDecoderReason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match *self {
            Self::OK => f.write_str("ok"),
            Self::NO_PACKETS => f.write_str("no packets"),
            Self::PACKETS_DEGRADED => f.write_str("packets degraded"),
            Self::NO_FORMAT => f.write_str("no format recovered"),
            Self::FORMAT_MISMATCH => f.write_str("format mismatch"),
            Self::FORMAT_REJECTED => f.write_str("format rejected"),
            Self::DECODER_BLOCKED => f.write_str("decoder blocked"),
            Self::SWITCH_PENDING => f.write_str("switch pending"),
            Self::PTP_UNLOCKED => f.write_str("PTP unlocked"),
            Self::TX_BRIDGE_UNLOCKED => f.write_str("TX bridge unlocked"),
            Self::IDLE => f.write_str("idle"),
            Self(v) => write!(f, "reason {v}"),
        }
    }
}

/// The colour space a decoder recovered from a codestream.
///
/// Zero is RGB and is also what a decoder with nothing to decode reports, so no
/// value here means "no signal" - [`V2ipDecoderReport::has_geometry`] is what
/// answers that.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct V2ipDecoderFormat(u16);

impl V2ipDecoderFormat {
    /// RGB.
    pub const RGB: Self = Self(0);
    /// YCbCr 4:4:4.
    pub const YCBCR_444: Self = Self(1);
    /// YCbCr 4:2:2.
    pub const YCBCR_422: Self = Self(2);
    /// YCbCr 4:2:0.
    pub const YCBCR_420: Self = Self(3);
    /// The decoder cannot name the format.
    ///
    /// 255, which is a value of its own rather than the 0xF a signal report
    /// uses for an unknown colour space. Mapping one onto the other yields a
    /// colour space the decoder never reported.
    pub const UNNAMED: Self = Self(255);

    /// Wraps a raw wire value, including one this library has no name for.
    pub const fn from_wire(value: u16) -> Self {
        Self(value)
    }

    /// Returns the raw wire value.
    pub const fn to_wire(self) -> u16 {
        self.0
    }
}

impl fmt::Display for V2ipDecoderFormat {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match *self {
            Self::RGB => f.write_str("RGB"),
            Self::YCBCR_444 => f.write_str("YCbCr 4:4:4"),
            Self::YCBCR_422 => f.write_str("YCbCr 4:2:2"),
            Self::YCBCR_420 => f.write_str("YCbCr 4:2:0"),
            Self::UNNAMED => f.write_str("unnamed"),
            Self(v) => write!(f, "format {v}"),
        }
    }
}

/// What a sink's decoder recovered from the codestream it is being given.
///
/// This is what the decoder understood, read ahead of the scaler: the geometry
/// is unrounded and is not what the display is being sent. It separates "the
/// decoder understood the codestream" from "a picture came out the other end".
///
/// Colour depth is absent on purpose and will stay absent. The video processor
/// answers that one from a driver constant rather than from the codestream, so
/// there is no reading to carry; assert depth at the encoder's input bay
/// instead.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct V2ipDecoderReport {
    /// The primary cause of the state the decoder is in.
    pub reason: V2ipDecoderReason,
    /// The converter watchdog is holding the stream back.
    pub blocking: bool,
    /// The recovered picture width, and 0 when none was recovered.
    pub width: u16,
    /// The recovered picture height, and 0 when none was recovered.
    pub height: u16,
    /// The recovered colour space.
    pub format: V2ipDecoderFormat,
    /// How many readings the sink has stored. Monotonic, wrapping at 65535
    /// after some 36 hours, and never reset.
    ///
    /// A sink reads its video processor every two seconds and reports every
    /// second, so roughly every other report repeats a reading already seen:
    /// a frame arriving says nothing about how fresh the values in it are.
    /// This counter moves only when a reading is stored, so a processor that
    /// stopped answering leaves it still rather than implying a refresh.
    ///
    /// After pointing a sink at something else, wait for this to advance by
    /// two before trusting the geometry. It ticks when a reply lands rather
    /// than when a query is sent, so the first tick can carry an answer the
    /// processor read fractionally before the switch; the second cannot,
    /// because at most one query is outstanding at a time.
    pub updates: u16,
    /// Every cause that applies, as bit N for reason N. See
    /// [`Self::has_cause`].
    ///
    /// This is what to classify on. [`Self::reason`] carries whichever cause
    /// won a fixed priority contest, so a cause that is true can be absent
    /// from it while present here. Bit 0 is cleared by the sender, so an empty
    /// word means nothing beyond the primary cause applies.
    ///
    /// [`V2ipDecoderReason::NO_FORMAT`] and
    /// [`V2ipDecoderReason::FORMAT_MISMATCH`] are the two arms of one decision
    /// and never appear together.
    pub flags: u32,
    /// How many times the converter watchdog has triggered.
    pub blocked_count: u32,
}

impl V2ipDecoderReport {
    /// Reports whether the decoder recovered a geometry.
    ///
    /// This is what says whether the decoder is being given a codestream it
    /// understands. [`Self::format`] cannot: it reads
    /// [`V2ipDecoderFormat::RGB`] when nothing is arriving, which is
    /// indistinguishable from a real RGB reading.
    pub const fn has_geometry(&self) -> bool {
        self.width != 0 && self.height != 0
    }

    /// Reports whether `reason` is among the causes that apply.
    ///
    /// [`Self::reason`] carries the primary cause and `flags` carries all of
    /// them at once. Bit 0 is unused, so [`V2ipDecoderReason::OK`] is never
    /// among them and an empty word means nothing beyond the primary cause
    /// applies.
    pub const fn has_cause(&self, reason: V2ipDecoderReason) -> bool {
        let bit = reason.to_wire();
        bit > 0 && bit < u32::BITS as u8 && self.flags & (1 << bit) != 0
    }
}

/// What a statistics report says about the sink's decoder.
///
/// The three states are distinct answers and only [`Self::Answered`] carries a
/// reading. `valid` follows the sink being configured rather than the sink
/// being enabled, so a sink that is switched off still reports - with zero
/// geometry and [`V2ipDecoderReason::NO_PACKETS`], the same reading a sink
/// whose source has died produces.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum V2ipDecoderDetail {
    /// The report carried no decoder block: the sender's firmware predates it.
    #[default]
    Absent,
    /// The block is there and the decoder has never answered. Every field it
    /// would carry is meaningless, so none is offered.
    NeverAnswered,
    /// A reading.
    Answered(V2ipDecoderReport),
}

impl V2ipDecoderDetail {
    /// The reading, for a caller that treats both of the other states as
    /// "nothing to show".
    pub const fn reading(self) -> Option<V2ipDecoderReport> {
        match self {
            Self::Answered(report) => Some(report),
            _ => None,
        }
    }
}

/// The cumulative and per-minute transmit and receive statistics.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct V2ipDeviceStats {
    /// Transmit totals since boot.
    pub tx: V2ipTxStats,
    /// Transmit counts over the last minute.
    pub tx_per_minute: V2ipTxStats,
    /// Receive totals since boot.
    pub rx: V2ipRxStats,
    /// Receive counts over the last minute.
    pub rx_per_minute: V2ipRxStats,
    /// What the sink's decoder recovered from the codestream it is decoding.
    pub decoder: V2ipDecoderDetail,
}