Skip to main content

dvb_t2mi/payload/
timestamp.rs

1//! T2-MI payload type 0x20: DVB-T2 timestamp — ETSI TS 102 773 §5.2.7.
2//!
3//! Carries an absolute or relative emission time for the T2 transmitter.
4//!
5//! ## Wire layout (88 bits = 11 bytes)
6//!
7//! - byte 0 `[7:4]`: rfu (must be 0)
8//! - byte 0 `[3:0]`: `bw` — bandwidth code, Table 4
9//! - bytes 1–5: `seconds_since_2000` (40 bits) — whole seconds since
10//!   2000-01-01T00:00:00 in the timestamp's own time base
11//! - bytes 6–9 `[7:5]`: `subseconds` (27 bits) — sub-second count in units of Tsub
12//! - byte 9 `[4:0]` + byte 10: `utco` (13 bits) — leap-second offset to subtract
13//!   to obtain civil UTC
14//!
15//! ## Emission-time formula (Table 4)
16//!
17//! ```text
18//! emission_offset = seconds_since_2000 + subseconds × Tsub
19//! civil_utc       = epoch_2000 + emission_offset − utco
20//! ```
21//!
22//! where `Tsub` per bandwidth (ETSI TS 102 773 §5.2.7, Table 4):
23//!
24//! | Bandwidth | bw | Tsub        |
25//! |-----------|----|-------------|
26//! | 1.7 MHz   | 0  | 1/131 µs    |
27//! | 5 MHz     | 1  | 1/40 µs     |
28//! | 6 MHz     | 2  | 1/48 µs     |
29//! | 7 MHz     | 3  | 1/56 µs     |
30//! | 8 MHz     | 4  | 1/64 µs     |
31//! | 10 MHz    | 5  | 1/80 µs     |
32//!
33//! ## Special values
34//!
35//! - **Null timestamp**: all 80 data bits (seconds + subseconds + utco) are 1.
36//! - **Relative timestamp**: `seconds_since_2000 == 0` (and not null).
37
38use num_enum::TryFromPrimitive;
39
40use dvb_common::{Parse, Serialize};
41
42/// Bandwidth per §5.2.7 Table 3.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize))]
45#[repr(u8)]
46#[non_exhaustive]
47pub enum Bandwidth {
48    /// 1.7 MHz bandwidth.
49    Mhz1_7 = 0,
50    /// 5 MHz bandwidth.
51    Mhz5 = 1,
52    /// 6 MHz bandwidth.
53    Mhz6 = 2,
54    /// 7 MHz bandwidth.
55    Mhz7 = 3,
56    /// 8 MHz bandwidth.
57    Mhz8 = 4,
58    /// 10 MHz bandwidth.
59    Mhz10 = 5,
60}
61
62impl From<Bandwidth> for u8 {
63    fn from(bw: Bandwidth) -> Self {
64        bw as u8
65    }
66}
67
68impl From<num_enum::TryFromPrimitiveError<Bandwidth>> for crate::error::Error {
69    fn from(_: num_enum::TryFromPrimitiveError<Bandwidth>) -> Self {
70        crate::error::Error::ReservedBitsViolation {
71            field: "bw",
72            reason: "Must be 0..=5 per ETSI TS 102 773 §5.2.7 Table 3",
73        }
74    }
75}
76
77impl Bandwidth {
78    /// Human-readable spec label (ETSI TS 102 773 Table 3).
79    #[must_use]
80    pub fn name(&self) -> &'static str {
81        match self {
82            Self::Mhz1_7 => "1.7 MHz",
83            Self::Mhz5 => "5 MHz",
84            Self::Mhz6 => "6 MHz",
85            Self::Mhz7 => "7 MHz",
86            Self::Mhz8 => "8 MHz",
87            Self::Mhz10 => "10 MHz",
88        }
89    }
90}
91dvb_common::impl_spec_display!(Bandwidth);
92
93/// Subsecond denominator D for 1.7 MHz bandwidth.
94/// ETSI TS 102 773 §5.2.7 Table 4: Tsub = 1/D µs, D = 131.
95const SUBSEC_DENOM_1_7MHZ: u64 = 131;
96
97/// Subsecond denominator D for 5 MHz bandwidth.
98/// ETSI TS 102 773 §5.2.7 Table 4: Tsub = 1/D µs, D = 40.
99const SUBSEC_DENOM_5MHZ: u64 = 40;
100
101/// Subsecond denominator D for 6 MHz bandwidth.
102/// ETSI TS 102 773 §5.2.7 Table 4: Tsub = 1/D µs, D = 48.
103const SUBSEC_DENOM_6MHZ: u64 = 48;
104
105/// Subsecond denominator D for 7 MHz bandwidth.
106/// ETSI TS 102 773 §5.2.7 Table 4: Tsub = 1/D µs, D = 56.
107const SUBSEC_DENOM_7MHZ: u64 = 56;
108
109/// Subsecond denominator D for 8 MHz bandwidth.
110/// ETSI TS 102 773 §5.2.7 Table 4: Tsub = 1/D µs, D = 64.
111const SUBSEC_DENOM_8MHZ: u64 = 64;
112
113/// Subsecond denominator D for 10 MHz bandwidth.
114/// ETSI TS 102 773 §5.2.7 Table 4: Tsub = 1/D µs, D = 80.
115const SUBSEC_DENOM_10MHZ: u64 = 80;
116
117impl Bandwidth {
118    /// Return the elementary period Tsub as a rational `(numerator, denominator)`
119    /// in **nanoseconds**, exact and without floating-point.
120    ///
121    /// ETSI TS 102 773 §5.2.7 Table 4 gives `Tsub = 1/D µs` (where D is the
122    /// bandwidth-dependent denominator), so in nanoseconds: `Tsub = 1000/D ns`.
123    ///
124    /// The returned value `(numer, denom)` satisfies `Tsub_ns = numer / denom`.
125    /// The fraction is **not** reduced (numer is always 1000, denom is D), so
126    /// callers can multiply `subseconds × 1000` and divide by `denom` to get
127    /// the total subsecond nanoseconds without any floating-point.
128    ///
129    /// # Example
130    /// ```
131    /// use dvb_t2mi::payload::timestamp::Bandwidth;
132    ///
133    /// // 8 MHz: Tsub = 1/64 µs = 1000/64 ns.
134    /// let (n, d) = Bandwidth::Mhz8.t_sub();
135    /// assert_eq!((n, d), (1000, 64));
136    /// // subseconds = 32_000_000 → total nanos = 32_000_000 × 1000 / 64 = 500_000_000 ns = 0.5 s.
137    /// let nanos = 32_000_000u128 * u128::from(n) / u128::from(d);
138    /// assert_eq!(nanos, 500_000_000);
139    /// ```
140    #[must_use]
141    pub fn t_sub(self) -> (u32, u32) {
142        // Tsub = 1/D µs = 1000/D ns.  numer = 1000, denom = D.
143        let denom = match self {
144            Bandwidth::Mhz1_7 => SUBSEC_DENOM_1_7MHZ,
145            Bandwidth::Mhz5 => SUBSEC_DENOM_5MHZ,
146            Bandwidth::Mhz6 => SUBSEC_DENOM_6MHZ,
147            Bandwidth::Mhz7 => SUBSEC_DENOM_7MHZ,
148            Bandwidth::Mhz8 => SUBSEC_DENOM_8MHZ,
149            Bandwidth::Mhz10 => SUBSEC_DENOM_10MHZ,
150        };
151        (1000, denom as u32)
152    }
153
154    /// Return the number of subsecond ticks per second for this bandwidth.
155    ///
156    /// Equals D × 1_000_000, where D is the denominator from
157    /// ETSI TS 102 773 §5.2.7 Table 4 (Tsub = 1/D µs).
158    pub fn subseconds_per_second(self) -> u64 {
159        match self {
160            Bandwidth::Mhz1_7 => SUBSEC_DENOM_1_7MHZ * 1_000_000,
161            Bandwidth::Mhz5 => SUBSEC_DENOM_5MHZ * 1_000_000,
162            Bandwidth::Mhz6 => SUBSEC_DENOM_6MHZ * 1_000_000,
163            Bandwidth::Mhz7 => SUBSEC_DENOM_7MHZ * 1_000_000,
164            Bandwidth::Mhz8 => SUBSEC_DENOM_8MHZ * 1_000_000,
165            Bandwidth::Mhz10 => SUBSEC_DENOM_10MHZ * 1_000_000,
166        }
167    }
168}
169
170/// Maximum value for the 40-bit `seconds_since_2000` field.
171const SECONDS_SINCE_2000_MAX: u64 = 0xFF_FFFF_FFFF;
172
173/// Maximum value for the 27-bit `subseconds` field.
174const SUBSECONDS_MAX: u32 = 0x7FF_FFFF;
175
176/// Maximum value for the 13-bit `utco` field.
177const UTCO_MAX: u16 = 0x1FFF;
178
179/// DVB-T2 timestamp payload (type 0x20) per ETSI TS 102 773 §5.2.7.
180///
181/// Layout (88 bits = 11 bytes):
182/// - byte 0 `[7:4]`: rfu (4 bits) — must be 0
183/// - byte 0 `[3:0]`: bw (4 bits) — Table 3
184/// - bytes 1-5: seconds_since_2000 (40 bits)
185/// - subseconds (27 bits): bytes 6-8 + byte 9 `[7:5]`
186/// - utco (13 bits): byte 9 `[4:0]` + byte 10 — UTC offset in seconds
187///
188/// Civil UTC conversion (applying the `utco` leap-second offset) is intentionally not
189/// provided yet; `utco` is exposed as a field. `emission_offset` is in the timestamp's
190/// own time base relative to 2000-01-01T00:00:00.
191#[derive(Debug, Clone, PartialEq, Eq)]
192#[cfg_attr(feature = "serde", derive(serde::Serialize))]
193pub struct T2TimestampPayload {
194    /// Bandwidth (determines Tsub units).
195    pub bw: Bandwidth,
196    /// Seconds since 2000-01-01T00:00:00Z. 0 = relative timestamp.
197    /// If all bits are 1 along with subseconds + utco, this is a Null timestamp.
198    pub seconds_since_2000: u64,
199    /// Subsecond count (27 bits).
200    pub subseconds: u32,
201    /// UTC offset in seconds (e.g. 34 for leap seconds as of 2016).
202    pub utco: u16,
203}
204
205const TIMESTAMP_HEADER_LEN: usize = 11;
206
207impl<'a> Parse<'a> for T2TimestampPayload {
208    type Error = crate::error::Error;
209
210    fn parse(bytes: &'a [u8]) -> Result<Self, crate::error::Error> {
211        if bytes.len() < TIMESTAMP_HEADER_LEN {
212            return Err(crate::Error::BufferTooShort {
213                need: TIMESTAMP_HEADER_LEN,
214                have: bytes.len(),
215                what: "T2TimestampPayload header",
216            });
217        }
218
219        // byte 0 [7:4] = rfu
220        if bytes[0] & 0xF0 != 0 {
221            return Err(crate::Error::ReservedBitsViolation {
222                field: "4-bit RFU",
223                reason: "Must be zero (ETSI TS 102 773 §5.2.7)",
224            });
225        }
226
227        let bw = Bandwidth::try_from(bytes[0] & 0x0F)?;
228
229        // bytes 1-5: seconds_since_2000 (40 bits)
230        let seconds_since_2000 = (bytes[1] as u64) << 32
231            | (bytes[2] as u64) << 24
232            | (bytes[3] as u64) << 16
233            | (bytes[4] as u64) << 8
234            | (bytes[5] as u64);
235
236        // bytes 6-8 [31:5]: subseconds (27 bits)
237        // bytes 6-7-8 = 24 bits, but subseconds extends into byte 9
238        // 27 bits: bytes 6-8 (24 bits) + byte 9 [7:5] (3 bits)
239        let subseconds = (bytes[6] as u32) << 19
240            | (bytes[7] as u32) << 11
241            | (bytes[8] as u32) << 3
242            | ((bytes[9] >> 5) as u32 & 0x7);
243
244        // byte 9 [4:0] + byte 10: utco (13 bits)
245        let utco = ((bytes[9] as u16 & 0x1F) << 8) | (bytes[10] as u16);
246
247        Ok(T2TimestampPayload {
248            bw,
249            seconds_since_2000,
250            subseconds,
251            utco,
252        })
253    }
254}
255
256impl<'a> crate::traits::PayloadDef<'a> for T2TimestampPayload {
257    const PACKET_TYPE: u8 = 0x20;
258    const NAME: &'static str = "TIMESTAMP";
259}
260
261impl Serialize for T2TimestampPayload {
262    type Error = crate::error::Error;
263
264    fn serialized_len(&self) -> usize {
265        TIMESTAMP_HEADER_LEN
266    }
267
268    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize, crate::error::Error> {
269        if buf.len() < self.serialized_len() {
270            return Err(crate::Error::OutputBufferTooSmall {
271                need: self.serialized_len(),
272                have: buf.len(),
273            });
274        }
275
276        if self.seconds_since_2000 > 0xFF_FFFF_FFFF {
277            return Err(crate::Error::ReservedBitsViolation {
278                field: "seconds_since_2000",
279                reason: "Must fit in 40 bits",
280            });
281        }
282        if self.subseconds > 0x7FFFFFF {
283            return Err(crate::Error::ReservedBitsViolation {
284                field: "subseconds",
285                reason: "Must fit in 27 bits",
286            });
287        }
288        if self.utco > 0x1FFF {
289            return Err(crate::Error::ReservedBitsViolation {
290                field: "utco",
291                reason: "Must fit in 13 bits",
292            });
293        }
294
295        buf[0] = u8::from(self.bw) & 0x0F; // RFU = 0
296        buf[1] = (self.seconds_since_2000 >> 32 & 0xFF) as u8;
297        buf[2] = (self.seconds_since_2000 >> 24 & 0xFF) as u8;
298        buf[3] = (self.seconds_since_2000 >> 16 & 0xFF) as u8;
299        buf[4] = (self.seconds_since_2000 >> 8 & 0xFF) as u8;
300        buf[5] = (self.seconds_since_2000 & 0xFF) as u8;
301        buf[6] = (self.subseconds >> 19 & 0xFF) as u8;
302        buf[7] = (self.subseconds >> 11 & 0xFF) as u8;
303        buf[8] = (self.subseconds >> 3 & 0xFF) as u8;
304        buf[9] = ((self.subseconds & 0x7) as u8) << 5 | ((self.utco >> 8) as u8 & 0x1F);
305        buf[10] = (self.utco & 0xFF) as u8;
306
307        Ok(self.serialized_len())
308    }
309}
310
311impl T2TimestampPayload {
312    /// Returns `true` if this is a Null timestamp (all bits of
313    /// `seconds_since_2000`, `subseconds`, and `utco` are 1).
314    pub fn is_null(&self) -> bool {
315        self.seconds_since_2000 == SECONDS_SINCE_2000_MAX
316            && self.subseconds == SUBSECONDS_MAX
317            && self.utco == UTCO_MAX
318    }
319
320    /// Returns `true` if this is a relative timestamp
321    /// (`seconds_since_2000` is 0 and is not null).
322    pub fn is_relative(&self) -> bool {
323        self.seconds_since_2000 == 0 && !self.is_null()
324    }
325
326    /// Time elapsed since 2000-01-01T00:00:00 in the timestamp's own time base.
327    ///
328    /// Returns `None` for a Null timestamp.
329    ///
330    /// Civil UTC conversion (applying the `utco` leap-second offset) is
331    /// intentionally not provided yet; `utco` is exposed as a field.
332    pub fn emission_offset(&self) -> Option<core::time::Duration> {
333        if self.is_null() {
334            return None;
335        }
336        let sps = self.bw.subseconds_per_second();
337        let total_nanos: u128 = self.subseconds as u128 * 1_000_000_000u128 / sps as u128;
338        let secs = self.seconds_since_2000 + (total_nanos / 1_000_000_000) as u64;
339        let sub_nanos = (total_nanos % 1_000_000_000) as u32;
340        Some(core::time::Duration::new(secs, sub_nanos))
341    }
342
343    /// Set `seconds_since_2000` and `subseconds` from a [`core::time::Duration`]
344    /// using the current `bw`. Leaves `bw` and `utco` unchanged.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`ReservedBitsViolation`](crate::error::Error::ReservedBitsViolation)
349    /// if the duration exceeds the 40-bit seconds or 27-bit subseconds range.
350    pub fn set_emission_offset(
351        &mut self,
352        offset: core::time::Duration,
353    ) -> Result<(), crate::error::Error> {
354        let secs = offset.as_secs();
355        if secs > SECONDS_SINCE_2000_MAX {
356            return Err(crate::error::Error::ReservedBitsViolation {
357                field: "seconds_since_2000",
358                reason: "exceeds 40 bits",
359            });
360        }
361        let sps = self.bw.subseconds_per_second();
362        let subseconds = (offset.subsec_nanos() as u128 * sps as u128 / 1_000_000_000u128) as u32;
363        if subseconds > SUBSECONDS_MAX {
364            return Err(crate::error::Error::ReservedBitsViolation {
365                field: "subseconds",
366                reason: "exceeds 27 bits",
367            });
368        }
369        self.seconds_since_2000 = secs;
370        self.subseconds = subseconds;
371        Ok(())
372    }
373}
374
375#[cfg(feature = "chrono")]
376#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
377impl T2TimestampPayload {
378    /// Decode this timestamp to a civil [`chrono::DateTime<chrono::Utc>`],
379    /// applying the `utco` leap-second correction.
380    ///
381    /// Per ETSI TS 102 773 §5.2.7:
382    /// ```text
383    /// civil_utc = epoch_2000 + seconds_since_2000 + subseconds × Tsub − utco
384    /// ```
385    /// where `utco` is the number of leap seconds to subtract.
386    ///
387    /// Returns `None` for a Null timestamp or if the value is out of range.
388    #[must_use]
389    pub fn emission_time_utc(&self) -> Option<chrono::DateTime<chrono::Utc>> {
390        let offset = self.emission_offset()?;
391        dvb_common::time::decode_seconds_since_2000_utc(
392            offset.as_secs(),
393            offset.subsec_nanos(),
394            self.utco,
395        )
396    }
397
398    /// Set `seconds_since_2000`, `subseconds`, and `utco` from a civil UTC
399    /// [`chrono::DateTime<chrono::Utc>`] and a `utco` leap-second offset.
400    ///
401    /// # Errors
402    ///
403    /// Returns [`ReservedBitsViolation`](crate::error::Error::ReservedBitsViolation)
404    /// if the resulting `seconds_since_2000` would not fit in 40 bits (i.e. the
405    /// date is before 2000 or more than ~34657 years in the future).
406    pub fn set_emission_time_utc(
407        &mut self,
408        dt: chrono::DateTime<chrono::Utc>,
409        utco: u16,
410    ) -> Result<(), crate::error::Error> {
411        let (secs, nanos) = dvb_common::time::encode_seconds_since_2000_utc(dt, utco).ok_or(
412            crate::error::Error::ReservedBitsViolation {
413                field: "seconds_since_2000",
414                reason: "date before 2000 epoch or exceeds 40-bit range",
415            },
416        )?;
417        let sps = self.bw.subseconds_per_second();
418        let subseconds = (u128::from(nanos) * sps as u128 / 1_000_000_000u128) as u32;
419        if subseconds > SUBSECONDS_MAX {
420            return Err(crate::error::Error::ReservedBitsViolation {
421                field: "subseconds",
422                reason: "nanosecond component exceeds 27-bit subseconds range",
423            });
424        }
425        self.seconds_since_2000 = secs;
426        self.subseconds = subseconds;
427        self.utco = utco;
428        Ok(())
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn bandwidth_try_from_valid() {
438        assert_eq!(Bandwidth::try_from(0), Ok(Bandwidth::Mhz1_7));
439        assert_eq!(Bandwidth::try_from(5), Ok(Bandwidth::Mhz10));
440    }
441
442    #[test]
443    fn bandwidth_try_from_rejects_6() {
444        assert!(Bandwidth::try_from(6).is_err());
445    }
446
447    #[test]
448    fn exhaustive_byte_sweep() {
449        let mut matched = 0u16;
450        for byte in 0u8..=0xFF {
451            if let Ok(v) = Bandwidth::try_from(byte) {
452                assert_eq!(v as u8, byte, "round-trip failed for {byte:#04x}");
453                matched += 1;
454            }
455        }
456        assert_eq!(matched, 6, "expected 6 matched variants");
457    }
458
459    #[test]
460    fn parse_extracts_all_fields() {
461        let mut buf = [0u8; 11];
462        buf[0] = 0x02; // bw = 6MHz
463        buf[1] = 0x00;
464        buf[2] = 0x00;
465        buf[3] = 0x01; // seconds_since_2000 = 65536 + 256 + ... let me just set simple values
466        buf[6] = 0x00;
467        buf[7] = 0x00;
468        buf[8] = 0x00;
469        buf[9] = 0x00; // subseconds=0, utco=0
470        buf[10] = 0x00;
471
472        let result = T2TimestampPayload::parse(&buf).unwrap();
473        assert_eq!(result.bw, Bandwidth::Mhz6);
474        assert_eq!(result.seconds_since_2000, 0x00_00_01_00_00);
475    }
476
477    #[test]
478    fn parse_rejects_nonzero_rfu() {
479        let buf = [
480            0x80u8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
481        ];
482        assert!(T2TimestampPayload::parse(&buf).is_err());
483    }
484
485    #[test]
486    fn parse_rejects_short_buffer() {
487        assert!(T2TimestampPayload::parse(&[0x00; 10]).is_err());
488    }
489
490    #[test]
491    fn serialize_round_trip() {
492        let orig = T2TimestampPayload {
493            bw: Bandwidth::Mhz8,
494            seconds_since_2000: 0x00_00_01_02_03,
495            subseconds: 0x0123456,
496            utco: 0x7FF,
497        };
498        let mut buf = [0u8; 11];
499        orig.serialize_into(&mut buf).unwrap();
500        let parsed = T2TimestampPayload::parse(&buf).unwrap();
501        assert_eq!(orig, parsed);
502    }
503
504    #[test]
505    fn null_timestamp_all_ones() {
506        let mut buf = [0xFFu8; 11];
507        buf[0] = 0x02; // bw = 6 MHz; top 4 rfu bits = 0
508        buf[1..11].fill(0xFF);
509        let result = T2TimestampPayload::parse(&buf);
510        assert!(result.is_ok());
511        let parsed = result.unwrap();
512        assert_eq!(parsed.seconds_since_2000, 0xFFFFFFFFFF); // 40 bits all 1
513        assert_eq!(parsed.subseconds, 0x7FFFFFF); // 27 bits all 1
514        assert_eq!(parsed.utco, 0x1FFF); // 13 bits all 1
515    }
516
517    #[test]
518    fn subseconds_per_second_per_table4() {
519        assert_eq!(
520            Bandwidth::Mhz1_7.subseconds_per_second(),
521            131_000_000,
522            "1.7 MHz: D=131"
523        );
524        assert_eq!(
525            Bandwidth::Mhz5.subseconds_per_second(),
526            40_000_000,
527            "5 MHz: D=40"
528        );
529        assert_eq!(
530            Bandwidth::Mhz6.subseconds_per_second(),
531            48_000_000,
532            "6 MHz: D=48"
533        );
534        assert_eq!(
535            Bandwidth::Mhz7.subseconds_per_second(),
536            56_000_000,
537            "7 MHz: D=56"
538        );
539        assert_eq!(
540            Bandwidth::Mhz8.subseconds_per_second(),
541            64_000_000,
542            "8 MHz: D=64"
543        );
544        assert_eq!(
545            Bandwidth::Mhz10.subseconds_per_second(),
546            80_000_000,
547            "10 MHz: D=80"
548        );
549    }
550
551    #[test]
552    fn emission_offset_known_values() {
553        // 8 MHz: D=64, sps=64_000_000.
554        // subseconds=32_000_000 = half of sps => 0.5 s subsecond component.
555        // total_nanos = 32_000_000 * 1_000_000_000 / 64_000_000 = 500_000_000.
556        let p = T2TimestampPayload {
557            bw: Bandwidth::Mhz8,
558            seconds_since_2000: 100,
559            subseconds: 32_000_000,
560            utco: 0,
561        };
562        assert_eq!(
563            p.emission_offset(),
564            Some(core::time::Duration::new(100, 500_000_000))
565        );
566
567        // 6 MHz: D=48, sps=48_000_000.
568        // subseconds=12_000_000 = 1/4 of sps => 0.25 s subsecond component.
569        // total_nanos = 12_000_000 * 1_000_000_000 / 48_000_000 = 250_000_000.
570        let p2 = T2TimestampPayload {
571            bw: Bandwidth::Mhz6,
572            seconds_since_2000: 200,
573            subseconds: 12_000_000,
574            utco: 0,
575        };
576        assert_eq!(
577            p2.emission_offset(),
578            Some(core::time::Duration::new(200, 250_000_000))
579        );
580    }
581
582    #[test]
583    fn set_emission_offset_round_trips() {
584        let mut p = T2TimestampPayload {
585            bw: Bandwidth::Mhz8,
586            seconds_since_2000: 0,
587            subseconds: 0,
588            utco: 0,
589        };
590        let dur = core::time::Duration::new(12345, 500_000_000);
591        p.set_emission_offset(dur).unwrap();
592        assert_eq!(p.emission_offset(), Some(dur));
593    }
594
595    #[test]
596    fn null_timestamp_offset_is_none() {
597        let p = T2TimestampPayload {
598            bw: Bandwidth::Mhz8,
599            seconds_since_2000: SECONDS_SINCE_2000_MAX,
600            subseconds: SUBSECONDS_MAX,
601            utco: UTCO_MAX,
602        };
603        assert!(p.is_null());
604        assert_eq!(p.emission_offset(), None);
605    }
606
607    #[test]
608    fn relative_timestamp_flag() {
609        let p = T2TimestampPayload {
610            bw: Bandwidth::Mhz8,
611            seconds_since_2000: 0,
612            subseconds: 1000,
613            utco: 0,
614        };
615        assert!(p.is_relative());
616        assert!(!p.is_null());
617        assert!(p.emission_offset().is_some());
618    }
619
620    // ── Bandwidth::t_sub() known-value tests ─────────────────────────────────
621    // Table 4 (ETSI TS 102 773 §5.2.7): Tsub = 1/D µs = 1000/D ns.
622    // We use subseconds = D*1000 (= 1 ms worth of ticks) to verify the arithmetic.
623
624    #[test]
625    fn t_sub_1_7mhz() {
626        // D = 131: Tsub = 1000/131 ns.
627        // subseconds = 131_000 → total nanos = 131_000 × 1000 / 131 = 1_000_000 ns = 1 ms.
628        let (n, d) = Bandwidth::Mhz1_7.t_sub();
629        assert_eq!((n, d), (1000, 131));
630        let nanos = 131_000u128 * u128::from(n) / u128::from(d);
631        assert_eq!(nanos, 1_000_000, "1.7 MHz: 131_000 ticks should be 1 ms");
632    }
633
634    #[test]
635    fn t_sub_5mhz() {
636        // D = 40: Tsub = 1000/40 = 25 ns.
637        // subseconds = 40_000 → total nanos = 40_000 × 1000 / 40 = 1_000_000 ns = 1 ms.
638        let (n, d) = Bandwidth::Mhz5.t_sub();
639        assert_eq!((n, d), (1000, 40));
640        let nanos = 40_000u128 * u128::from(n) / u128::from(d);
641        assert_eq!(nanos, 1_000_000, "5 MHz: 40_000 ticks should be 1 ms");
642    }
643
644    #[test]
645    fn t_sub_6mhz() {
646        // D = 48: Tsub = 1000/48 ns ≈ 20.83 ns.
647        // subseconds = 48_000 → total nanos = 48_000 × 1000 / 48 = 1_000_000 ns = 1 ms.
648        let (n, d) = Bandwidth::Mhz6.t_sub();
649        assert_eq!((n, d), (1000, 48));
650        let nanos = 48_000u128 * u128::from(n) / u128::from(d);
651        assert_eq!(nanos, 1_000_000, "6 MHz: 48_000 ticks should be 1 ms");
652    }
653
654    #[test]
655    fn t_sub_7mhz() {
656        // D = 56: Tsub = 1000/56 ns ≈ 17.86 ns.
657        // subseconds = 56_000 → total nanos = 56_000 × 1000 / 56 = 1_000_000 ns = 1 ms.
658        let (n, d) = Bandwidth::Mhz7.t_sub();
659        assert_eq!((n, d), (1000, 56));
660        let nanos = 56_000u128 * u128::from(n) / u128::from(d);
661        assert_eq!(nanos, 1_000_000, "7 MHz: 56_000 ticks should be 1 ms");
662    }
663
664    #[test]
665    fn t_sub_8mhz() {
666        // D = 64: Tsub = 1000/64 = 15.625 ns.
667        // subseconds = 64_000 → total nanos = 64_000 × 1000 / 64 = 1_000_000 ns = 1 ms.
668        let (n, d) = Bandwidth::Mhz8.t_sub();
669        assert_eq!((n, d), (1000, 64));
670        let nanos = 64_000u128 * u128::from(n) / u128::from(d);
671        assert_eq!(nanos, 1_000_000, "8 MHz: 64_000 ticks should be 1 ms");
672    }
673
674    #[test]
675    fn t_sub_10mhz() {
676        // D = 80: Tsub = 1000/80 = 12.5 ns.
677        // subseconds = 80_000 → total nanos = 80_000 × 1000 / 80 = 1_000_000 ns = 1 ms.
678        let (n, d) = Bandwidth::Mhz10.t_sub();
679        assert_eq!((n, d), (1000, 80));
680        let nanos = 80_000u128 * u128::from(n) / u128::from(d);
681        assert_eq!(nanos, 1_000_000, "10 MHz: 80_000 ticks should be 1 ms");
682    }
683
684    #[cfg(feature = "chrono")]
685    #[test]
686    fn emission_time_utc_known_value() {
687        use chrono::{Datelike, Timelike};
688        // seconds_since_2000 = 0, subseconds = 0, utco = 0 → 2000-01-01T00:00:00 UTC.
689        let p = T2TimestampPayload {
690            bw: Bandwidth::Mhz8,
691            seconds_since_2000: 0,
692            subseconds: 0,
693            utco: 0,
694        };
695        let dt = p.emission_time_utc().expect("should decode");
696        assert_eq!((dt.year(), dt.month(), dt.day()), (2000, 1, 1));
697        assert_eq!((dt.hour(), dt.minute(), dt.second()), (0, 0, 0));
698
699        // utco = 37: epoch_2000 + 0s − 37s = 1999-12-31T23:59:23 UTC.
700        let p2 = T2TimestampPayload {
701            bw: Bandwidth::Mhz8,
702            seconds_since_2000: 0,
703            subseconds: 0,
704            utco: 37,
705        };
706        let dt2 = p2.emission_time_utc().expect("should decode");
707        assert_eq!((dt2.year(), dt2.month(), dt2.day()), (1999, 12, 31));
708        assert_eq!((dt2.hour(), dt2.minute(), dt2.second()), (23, 59, 23));
709    }
710
711    #[cfg(feature = "chrono")]
712    #[test]
713    fn set_emission_time_utc_round_trips() {
714        use chrono::TimeZone;
715        let dt = chrono::Utc
716            .with_ymd_and_hms(2023, 6, 8, 12, 34, 56)
717            .unwrap();
718        let mut p = T2TimestampPayload {
719            bw: Bandwidth::Mhz8,
720            seconds_since_2000: 0,
721            subseconds: 0,
722            utco: 0,
723        };
724        p.set_emission_time_utc(dt, 37).unwrap();
725        let decoded = p.emission_time_utc().expect("decodes");
726        assert_eq!(decoded, dt);
727    }
728
729    #[cfg(feature = "chrono")]
730    #[test]
731    fn emission_time_utc_null_returns_none() {
732        let p = T2TimestampPayload {
733            bw: Bandwidth::Mhz8,
734            seconds_since_2000: SECONDS_SINCE_2000_MAX,
735            subseconds: SUBSECONDS_MAX,
736            utco: UTCO_MAX,
737        };
738        assert!(p.emission_time_utc().is_none());
739    }
740}