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}