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