Skip to main content

mx_remote/types/
v2ip.rs

1// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
2// Copyright (c) 2026 Op den Kamp IT Solutions
3
4//! V2IP stream configuration, statistics and the sink-side route.
5
6use core::fmt;
7use std::net::Ipv4Addr;
8
9use crate::wire::{
10    DeviceUid, MxrSignalType, V2IP_AUDIO_DEFAULT_CHANNELS, V2IP_AUDIO_DEFAULT_SAMPLE_RATE,
11    V2IP_DSCP_MAX, V2IP_DSCP_SET,
12};
13
14/// Which of a V2IP device's streams an address describes.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum StreamKind {
17    /// The video stream.
18    #[default]
19    Video,
20    /// The audio stream.
21    Audio,
22    /// The ancillary-data stream.
23    Anc,
24    /// The audio-return stream.
25    Arc,
26}
27
28impl fmt::Display for StreamKind {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        f.write_str(match self {
31            Self::Video => "video",
32            Self::Audio => "audio",
33            Self::Anc => "anc",
34            Self::Arc => "arc",
35        })
36    }
37}
38
39/// A single multicast stream address.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub struct V2ipStreamSource {
42    /// Which stream this address is for.
43    pub kind: StreamKind,
44    /// The multicast group.
45    pub ip: Ipv4Addr,
46    /// The destination UDP port.
47    pub port: u16,
48}
49
50impl Default for V2ipStreamSource {
51    fn default() -> Self {
52        Self {
53            kind: StreamKind::default(),
54            ip: Ipv4Addr::UNSPECIFIED,
55            port: 0,
56        }
57    }
58}
59
60impl V2ipStreamSource {
61    /// Reports whether this carries a usable address: a multicast group and a
62    /// non-zero port, both, matching firmware `mxr_v2ip_stream_valid`.
63    pub const fn is_valid(&self) -> bool {
64        self.ip.is_multicast() && self.port != 0
65    }
66}
67
68impl fmt::Display for V2ipStreamSource {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(f, "{}={}:{}", self.kind, self.ip, self.port)
71    }
72}
73
74/// The streams advertised by a single V2IP source.
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
76pub struct V2ipStreamSources {
77    /// The originating device, or the zero UID when it is not known.
78    pub uid: DeviceUid,
79    /// The video stream.
80    pub video: V2ipStreamSource,
81    /// The audio stream.
82    pub audio: V2ipStreamSource,
83    /// The ancillary-data stream.
84    pub anc: V2ipStreamSource,
85    /// The audio-return stream, when one is advertised.
86    pub arc: Option<V2ipStreamSource>,
87}
88
89impl fmt::Display for V2ipStreamSources {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(
92            f,
93            "video:{} audio:{} anc:{}",
94            self.video, self.audio, self.anc
95        )
96    }
97}
98
99/// One multicast destination in a route the caller assembles.
100///
101/// The unspecified address sends the slot zeroed, naming no group for that
102/// stream. It is not a way to leave one stream alone: the firmware decides
103/// whether a sink has a manual route at all by reading the video and
104/// ancillary slots, so an empty one of those disqualifies the whole route
105/// rather than preserving anything - see
106/// [`crate::Remote::select_source_addr`].
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub struct V2ipRouteTarget {
109    /// The multicast group.
110    pub ip: Ipv4Addr,
111    /// The destination UDP port. Zero means the standard port for the stream
112    /// this target is given as.
113    pub port: u16,
114}
115
116impl Default for V2ipRouteTarget {
117    fn default() -> Self {
118        Self {
119            ip: Ipv4Addr::UNSPECIFIED,
120            port: 0,
121        }
122    }
123}
124
125impl V2ipRouteTarget {
126    /// A target at the standard port for its stream.
127    pub const fn new(ip: Ipv4Addr) -> Self {
128        Self { ip, port: 0 }
129    }
130
131    /// The port to send, substituting `standard` for an unset one.
132    pub(crate) const fn port_or(self, standard: u16) -> u16 {
133        if self.port == 0 {
134            standard
135        } else {
136            self.port
137        }
138    }
139}
140
141impl fmt::Display for V2ipRouteTarget {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(f, "{}:{}", self.ip, self.port)
144    }
145}
146
147/// The three streams a manual route points a V2IP sink at.
148///
149/// Fill in all three. The firmware decides whether a sink has a manual route
150/// at all by looking at the video and ancillary groups, so a route carrying
151/// only audio does not register as one and the sink falls back to the audio
152/// source its mesh picks.
153#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
154pub struct V2ipRoute {
155    /// The video stream, at [`crate::V2IP_PORT_VIDEO`] unless the port says otherwise.
156    pub video: V2ipRouteTarget,
157    /// The audio stream, at [`crate::V2IP_PORT_AUDIO`] unless the port says otherwise.
158    pub audio: V2ipRouteTarget,
159    /// The ancillary-data stream, at [`crate::V2IP_PORT_ANC`] unless the port says
160    /// otherwise.
161    pub anc: V2ipRouteTarget,
162}
163
164impl V2ipRoute {
165    /// The three streams of one source, at the ports it advertises them on.
166    pub fn of(sources: &V2ipStreamSources) -> Self {
167        let target = |s: &V2ipStreamSource| V2ipRouteTarget {
168            ip: s.ip,
169            port: s.port,
170        };
171        Self {
172            video: target(&sources.video),
173            audio: target(&sources.audio),
174            anc: target(&sources.anc),
175        }
176    }
177}
178
179impl fmt::Display for V2ipRoute {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        write!(
182            f,
183            "video:{} audio:{} anc:{}",
184            self.video, self.audio, self.anc
185        )
186    }
187}
188
189/// The sample rate and channel count a V2IP audio stream is decoded at.
190///
191/// Fill both in. The firmware header calls zero "use the default", but the
192/// path that applies a manual route substitutes nothing: it hands the pair to
193/// the FPGA as it arrived, and the FPGA rejects a zero rate and takes the
194/// whole switch down with it. [`V2ipAudioFormat::STANDARD`] is the pair the
195/// header documents as the default.
196#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
197pub struct V2ipAudioFormat {
198    /// Sample rate in Hz.
199    pub sample_rate: u32,
200    /// Channel count.
201    pub channels: u8,
202}
203
204impl V2ipAudioFormat {
205    /// 48kHz stereo: the rate and channel count the firmware header names as
206    /// its default, which a caller has to send because firmware does not
207    /// substitute it.
208    pub const STANDARD: Self = Self {
209        sample_rate: V2IP_AUDIO_DEFAULT_SAMPLE_RATE,
210        channels: V2IP_AUDIO_DEFAULT_CHANNELS,
211    };
212
213    /// Encodes `v2ip_audio_format`: a `u32` rate, a channel byte and three
214    /// reserved bytes, padded to the struct's 8-byte alignment.
215    pub(crate) fn wire(&self) -> [u8; 8] {
216        let r = self.sample_rate.to_le_bytes();
217        [r[0], r[1], r[2], r[3], self.channels, 0, 0, 0]
218    }
219}
220
221impl fmt::Display for V2ipAudioFormat {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        write!(f, "{}Hz/{}ch", self.sample_rate, self.channels)
224    }
225}
226
227/// A V2IP output's scaling mode, refresh rate and flags.
228#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
229pub struct V2ipScalingSettings {
230    /// The signal type the output scales to.
231    pub mode: MxrSignalType,
232    /// Refresh rate in Hz.
233    pub refresh: u16,
234    /// The flag bits below.
235    pub flags: u8,
236}
237
238/// Set when the frame carries a scaling mode and refresh rate.
239pub const SCALING_FLAG_MODE_VALID: u8 = 1 << 0;
240
241/// Set when the frame carries the scaling options.
242pub const SCALING_FLAG_OPTIONS_VALID: u8 = 1 << 1;
243
244/// Set when the output scales automatically.
245pub const SCALING_FLAG_AUTO_SCALING: u8 = 1 << 7;
246
247/// The flag bits that carry meaning.
248///
249/// Bits 2..6 are undefined and are not reliably zero on the wire: firmware
250/// predating the fix builds this frame from an uninitialised stack local and
251/// ORs its flags onto whatever was there.
252pub const SCALING_FLAGS_DEFINED: u8 =
253    SCALING_FLAG_MODE_VALID | SCALING_FLAG_OPTIONS_VALID | SCALING_FLAG_AUTO_SCALING;
254
255impl V2ipScalingSettings {
256    /// Folds a received scaling config onto the cached one, field by field.
257    ///
258    /// A write carries the mode or the options alone, so taking the block
259    /// wholesale would drop whichever half was not being written. The options
260    /// branch replaces the option bit rather than adding to it, which is what
261    /// lets an options-only write clear [`SCALING_FLAG_AUTO_SCALING`].
262    #[must_use]
263    pub fn merge(self, previous: Self) -> Self {
264        let mut out = previous;
265        if self.flags & SCALING_FLAG_MODE_VALID != 0 {
266            out.mode = self.mode;
267            out.refresh = self.refresh;
268            out.flags |= SCALING_FLAG_MODE_VALID;
269        }
270        if self.flags & SCALING_FLAG_OPTIONS_VALID != 0 {
271            out.flags &= !SCALING_FLAG_AUTO_SCALING;
272            out.flags |= SCALING_FLAG_OPTIONS_VALID;
273            out.flags |= self.flags & SCALING_FLAG_AUTO_SCALING;
274        }
275        out
276    }
277}
278
279/// The per-stream DSCP marking in a V2IP device configuration.
280///
281/// A stream whose wire byte carries no [`V2IP_DSCP_SET`] bit reads back as
282/// `None`. Firmware treats the marking as all-or-nothing: it applies one only
283/// when all three streams carry a value and otherwise falls back to the
284/// default, so [`V2ipDscpConfig::is_complete`] reports which case a frame is in.
285#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
286pub struct V2ipDscpConfig {
287    /// Marking for the video stream.
288    pub video: Option<u8>,
289    /// Marking for the audio stream.
290    pub audio: Option<u8>,
291    /// Marking for the ancillary-data stream.
292    pub anc: Option<u8>,
293}
294
295impl V2ipDscpConfig {
296    /// Reports whether all three streams carry a marking, which is what
297    /// firmware requires before it applies one.
298    pub const fn is_complete(&self) -> bool {
299        self.video.is_some() && self.audio.is_some() && self.anc.is_some()
300    }
301}
302
303impl fmt::Display for V2ipDscpConfig {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        match (self.video, self.audio, self.anc) {
306            (Some(v), Some(a), Some(n)) => write!(f, "video:{v} audio:{a} anc:{n}"),
307            _ => f.write_str("no marking"),
308        }
309    }
310}
311
312/// Decodes one `dscp` byte, or `None` when the byte carries no marking.
313pub(crate) fn parse_dscp(raw: u8) -> Option<u8> {
314    (raw & V2IP_DSCP_SET != 0).then_some(raw & V2IP_DSCP_MAX)
315}
316
317/// The local encoder/decoder configuration of a V2IP device.
318#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
319pub struct DeviceV2ipDetails {
320    /// The video stream this device sources.
321    pub video: V2ipStreamSource,
322    /// The audio stream this device sources.
323    pub audio: V2ipStreamSource,
324    /// The ancillary-data stream this device sources.
325    pub anc: V2ipStreamSource,
326    /// The audio-return stream this device sources.
327    pub arc: V2ipStreamSource,
328
329    /// Encoder rate in units of 10Mb/s, or `None` when the sender offered no
330    /// rate.
331    ///
332    /// A rate-only write carries the rate on its own; every other controller
333    /// write puts a value outside the valid range here, which firmware drops as
334    /// invalid so that address-only and scaling writes leave the peer's rate
335    /// alone.
336    pub tx_rate: Option<u8>,
337
338    /// Per-stream DSCP marking.
339    pub dscp: V2ipDscpConfig,
340    /// Scaling mode, refresh rate and flags.
341    pub scaling: V2ipScalingSettings,
342}
343
344impl DeviceV2ipDetails {
345    /// Reports whether the source block carries usable addresses.
346    ///
347    /// Firmware requires video and anc; audio is optional and is carried with
348    /// them.
349    pub const fn source_is_valid(&self) -> bool {
350        self.video.is_valid() && self.anc.is_valid()
351    }
352
353    /// Folds a received device configuration onto the cached one.
354    ///
355    /// Every field is optional behind its own validity marker: the payload is
356    /// zeroed before a sender fills in the one field it is writing, so a
357    /// controller writing a TX rate sends zeroed addresses and a controller
358    /// writing addresses sends an out-of-range rate. Firmware applies each
359    /// field only behind its own test, so replacing the whole cached config on
360    /// every frame would make the peer read back with its addresses, rate or
361    /// marking gone.
362    #[must_use]
363    pub fn merge(mut self, previous: Option<Self>) -> Self {
364        let Some(previous) = previous else {
365            return self;
366        };
367        if !self.source_is_valid() {
368            self.video = previous.video;
369            self.audio = previous.audio;
370            self.anc = previous.anc;
371        }
372        if !self.arc.is_valid() {
373            self.arc = previous.arc;
374        }
375        if self.tx_rate.is_none() {
376            self.tx_rate = previous.tx_rate;
377        }
378        // Firmware gates all three dscp bytes on the video byte's set bit
379        // alone, and stores whatever the other two carry.
380        if self.dscp.video.is_none() {
381            self.dscp = previous.dscp;
382        }
383        self.scaling = self.scaling.merge(previous.scaling);
384        self
385    }
386}
387
388/// The sink-side route a V2IP device is currently subscribed to.
389#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
390pub struct DeviceV2ipSink {
391    /// The streams the sink subscribes to.
392    pub addresses: V2ipStreamSources,
393    /// The resolved audio format, when the sender reported one.
394    pub audio_fmt: Option<V2ipAudioFormat>,
395}
396
397/// Transmitter stream statistics.
398#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
399pub struct V2ipTxStats {
400    /// Video packets sent.
401    pub video: u32,
402    /// Audio packets sent.
403    pub audio: u32,
404    /// Ancillary-data packets sent.
405    pub anc: u32,
406    /// Times the stream went down.
407    pub stream_down: u32,
408    /// Transmit overflows.
409    pub overflow: u32,
410}
411
412/// The health state of a V2IP decoder.
413#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
414pub struct V2ipDecoderState(u8);
415
416impl V2ipDecoderState {
417    /// The sink has not reported a state.
418    pub const UNKNOWN: Self = Self(0);
419    /// Decoding normally.
420    pub const HEALTHY: Self = Self(1);
421    /// Failed to decode.
422    pub const BAD: Self = Self(2);
423    /// Still coming up, which any sink subscribed to during a route change
424    /// reports.
425    pub const STARTING: Self = Self(3);
426
427    /// Wraps a raw wire value, including one this library has no name for.
428    pub const fn from_wire(value: u8) -> Self {
429        Self(value)
430    }
431
432    /// Returns the raw wire value.
433    pub const fn to_wire(self) -> u8 {
434        self.0
435    }
436
437    /// Reports whether the decoder has reached a verdict.
438    ///
439    /// Only healthy and bad are verdicts. Testing for failure as "not healthy"
440    /// reads a receiver that is merely coming up as one that failed to decode,
441    /// which is what a sink reports for a moment after every route change.
442    pub const fn is_settled(self) -> bool {
443        matches!(self, Self::HEALTHY | Self::BAD)
444    }
445}
446
447impl fmt::Display for V2ipDecoderState {
448    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449        match *self {
450            Self::UNKNOWN => f.write_str("Unknown"),
451            Self::HEALTHY => f.write_str("Healthy"),
452            Self::BAD => f.write_str("Bad"),
453            Self::STARTING => f.write_str("Starting"),
454            Self(v) => write!(f, "state {v}"),
455        }
456    }
457}
458
459/// Receiver stream statistics.
460#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
461pub struct V2ipRxStats {
462    /// Video packets received.
463    pub video_total: u32,
464    /// Video packets dropped.
465    pub video_dropped: u32,
466    /// Video sequence errors.
467    pub video_seq_errors: u32,
468    /// Watchdog timeouts.
469    pub wdt_timeout: u32,
470    /// Audio packets received.
471    pub audio_total: u32,
472    /// Audio packets dropped.
473    pub audio_dropped: u32,
474    /// Audio sequence errors.
475    pub audio_seq_errors: u32,
476    /// Ancillary-data packets received.
477    pub anc_total: u32,
478    /// Ancillary-data packets dropped.
479    pub anc_dropped: u32,
480    /// Ancillary-data sequence errors.
481    pub anc_seq_errors: u32,
482    /// The decoder's health state.
483    pub decoder_state: V2ipDecoderState,
484}
485
486/// The cumulative and per-minute transmit and receive statistics.
487#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
488pub struct V2ipDeviceStats {
489    /// Transmit totals since boot.
490    pub tx: V2ipTxStats,
491    /// Transmit counts over the last minute.
492    pub tx_per_minute: V2ipTxStats,
493    /// Receive totals since boot.
494    pub rx: V2ipRxStats,
495    /// Receive counts over the last minute.
496    pub rx_per_minute: V2ipRxStats,
497}