Skip to main content

dvb_common/
time.rs

1//! UTC time and duration codecs for DVB wire fields.
2//!
3//! DVB carries wall-clock time as a 16-bit Modified Julian Date plus 24-bit BCD
4//! HHMMSS (EN 300 468 Annex C), and event durations as 24-bit BCD HHMMSS. The
5//! duration codec is dependency-free; the MJD↔calendar conversion needs a date
6//! library and so lives behind the `chrono` feature.
7
8use crate::bcd::{from_bcd_byte, to_bcd_byte};
9use core::time::Duration;
10
11/// Decoded 5-byte DVB UTC time (16-bit MJD + 24-bit BCD `HHMMSS`).
12///
13/// Produced by [`decode_mjd_bcd`]; field values are validated (months 1–12,
14/// days 1–31, hours 0–23, minutes/seconds 0–59). The year is the full
15/// calendar year (e.g. 2023, not an offset).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct MjdBcdDateTime {
18    /// Calendar year (full, e.g. 2023).
19    pub year: u16,
20    /// Month of year (1–12).
21    pub month: u8,
22    /// Day of month (1–31).
23    pub day: u8,
24    /// Hour of day (0–23).
25    pub hour: u8,
26    /// Minute of hour (0–59).
27    pub minute: u8,
28    /// Second of minute (0–59).
29    pub second: u8,
30}
31
32/// Decode a 5-byte DVB UTC time (16-bit MJD + 24-bit BCD `HHMMSS`) to a
33/// plain [`MjdBcdDateTime`].
34///
35/// Unlike [`decode_mjd_bcd_utc`], this is dependency-free (no `chrono`
36/// feature required). Returns `None` if the BCD nibbles are out of range
37/// or the minute/second fields exceed 59. The MJD→calendar conversion
38/// follows ETSI EN 300 468 Annex C.
39#[must_use]
40pub fn decode_mjd_bcd(raw: [u8; 5]) -> Option<MjdBcdDateTime> {
41    let (mjd_bytes, _) = raw.split_first_chunk::<2>().unwrap();
42    let mjd = u16::from_be_bytes(*mjd_bytes);
43    let h = from_bcd_byte(raw[2])?;
44    let mi = from_bcd_byte(raw[3])?;
45    let s = from_bcd_byte(raw[4])?;
46    if mi > 59 || s > 59 || h > 23 {
47        return None;
48    }
49    let (year, month, day) = mjd_to_ymd_nogate(mjd)?;
50    Some(MjdBcdDateTime {
51        year,
52        month,
53        day,
54        hour: h,
55        minute: mi,
56        second: s,
57    })
58}
59
60/// Encode a [`MjdBcdDateTime`] to a 5-byte DVB UTC time.
61///
62/// Returns `None` if any field is out of the representable range.
63#[must_use]
64pub fn encode_mjd_bcd(dt: MjdBcdDateTime) -> Option<[u8; 5]> {
65    let mjd = ymd_to_mjd_nogate(i32::from(dt.year), u32::from(dt.month), u32::from(dt.day))?;
66    let [m0, m1] = mjd.to_be_bytes();
67    Some([
68        m0,
69        m1,
70        to_bcd_byte(dt.hour)?,
71        to_bcd_byte(dt.minute)?,
72        to_bcd_byte(dt.second)?,
73    ])
74}
75
76/// Convert a 16-bit Modified Julian Date to `(year, month, day)`.
77///
78/// MJD→calendar per ETSI EN 300 468 Annex C. This is the dependency-free
79/// version of the chrono-gated [`mjd_to_ymd`].
80fn mjd_to_ymd_nogate(mjd: u16) -> Option<(u16, u8, u8)> {
81    let mjd = i64::from(mjd);
82    let y_prime = ((mjd as f64 - 15_078.2) / 365.25) as i64;
83    let m_prime = ((mjd as f64 - 14_956.1 - libm::floor(y_prime as f64 * 365.25)) / 30.6001) as i64;
84    let d = mjd
85        - 14_956
86        - libm::floor(y_prime as f64 * 365.25) as i64
87        - libm::floor(m_prime as f64 * 30.6001) as i64;
88    let k = i64::from(m_prime == 14 || m_prime == 15);
89    let y = y_prime + k + 1900;
90    let m = m_prime - 1 - k * 12;
91    let y_u16 = u16::try_from(y).ok()?;
92    let m_u8 = u8::try_from(m).ok()?;
93    let d_u8 = u8::try_from(d).ok()?;
94    if !(1..=12).contains(&m_u8) || !(1..=31).contains(&d_u8) {
95        return None;
96    }
97    Some((y_u16, m_u8, d_u8))
98}
99
100/// Convert a `(year, month, day)` date to a 16-bit Modified Julian Date.
101///
102/// Calendar→MJD per ETSI EN 300 468 Annex C. This is the dependency-free
103/// version of the chrono-gated [`ymd_to_mjd`].
104fn ymd_to_mjd_nogate(year: i32, month: u32, day: u32) -> Option<u16> {
105    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
106        return None;
107    }
108    let l = if month <= 2 { 1.0 } else { 0.0 };
109    let y = f64::from(year - 1900);
110    let m = f64::from(month);
111    let mjd = 14_956.0
112        + f64::from(day)
113        + libm::floor((y - l) * 365.25)
114        + libm::floor((m + 1.0 + l * 12.0) * 30.6001);
115    if (0.0..=f64::from(u16::MAX)).contains(&mjd) {
116        Some(mjd as u16)
117    } else {
118        None
119    }
120}
121
122/// Decode a 24-bit BCD `HHMMSS` duration (`[HH, MM, SS]`) to a [`Duration`].
123///
124/// Returns `None` if any nibble is non-decimal or the minute/second fields
125/// exceed 59.
126#[must_use]
127pub fn decode_bcd_duration(raw: [u8; 3]) -> Option<Duration> {
128    let h = u64::from(from_bcd_byte(raw[0])?);
129    let m = u64::from(from_bcd_byte(raw[1])?);
130    let s = u64::from(from_bcd_byte(raw[2])?);
131    if m > 59 || s > 59 {
132        return None;
133    }
134    Some(Duration::from_secs(h * 3600 + m * 60 + s))
135}
136
137/// Encode a whole-second [`Duration`] to a 24-bit BCD `HHMMSS` (`[HH, MM, SS]`).
138///
139/// Sub-second precision is truncated. Returns `None` if the duration is 100
140/// hours or longer (`HH` only holds two BCD digits).
141#[must_use]
142pub fn encode_bcd_duration(duration: Duration) -> Option<[u8; 3]> {
143    let secs = duration.as_secs();
144    let h = secs / 3600;
145    if h > 99 {
146        return None;
147    }
148    let m = (secs % 3600) / 60;
149    let s = secs % 60;
150    Some([
151        to_bcd_byte(h as u8)?,
152        to_bcd_byte(m as u8)?,
153        to_bcd_byte(s as u8)?,
154    ])
155}
156
157/// Convert a 16-bit Modified Julian Date to `(year, month, day)`.
158///
159/// Inverse of [`ymd_to_mjd`]; MJD→calendar per ETSI EN 300 468 Annex C.
160#[cfg(feature = "chrono")]
161#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
162#[must_use]
163pub fn mjd_to_ymd(mjd: u16) -> (i32, u32, u32) {
164    let mjd = i64::from(mjd);
165    let y_prime = ((mjd as f64 - 15_078.2) / 365.25) as i64;
166    let m_prime = ((mjd as f64 - 14_956.1 - libm::floor(y_prime as f64 * 365.25)) / 30.6001) as i64;
167    let d = mjd
168        - 14_956
169        - libm::floor(y_prime as f64 * 365.25) as i64
170        - libm::floor(m_prime as f64 * 30.6001) as i64;
171    let k = i64::from(m_prime == 14 || m_prime == 15);
172    let y = y_prime + k + 1900;
173    let m = m_prime - 1 - k * 12;
174    (y as i32, m as u32, d as u32)
175}
176
177/// Convert a `(year, month, day)` date to a 16-bit Modified Julian Date.
178///
179/// Forward of [`mjd_to_ymd`], calendar→MJD per ETSI EN 300 468 Annex C. Returns
180/// `None` if the field is out of range or the date is not representable in a
181/// 16-bit MJD.
182#[cfg(feature = "chrono")]
183#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
184#[must_use]
185pub fn ymd_to_mjd(year: i32, month: u32, day: u32) -> Option<u16> {
186    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
187        return None;
188    }
189    let l = if month <= 2 { 1.0 } else { 0.0 };
190    let y = f64::from(year - 1900);
191    let m = f64::from(month);
192    let mjd = 14_956.0
193        + f64::from(day)
194        + libm::floor((y - l) * 365.25)
195        + libm::floor((m + 1.0 + l * 12.0) * 30.6001);
196    if (0.0..=f64::from(u16::MAX)).contains(&mjd) {
197        Some(mjd as u16)
198    } else {
199        None
200    }
201}
202
203/// Decode a 5-byte DVB UTC time (16-bit MJD + 24-bit BCD `HHMMSS`) to a
204/// [`chrono::DateTime<chrono::Utc>`].
205///
206/// Returns `None` if the BCD nibbles are out of range or the date/time is
207/// invalid.
208#[cfg(feature = "chrono")]
209#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
210#[must_use]
211pub fn decode_mjd_bcd_utc(raw: [u8; 5]) -> Option<chrono::DateTime<chrono::Utc>> {
212    use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
213    let (mjd_bytes, _) = raw.split_first_chunk::<2>().unwrap();
214    let mjd = u16::from_be_bytes(*mjd_bytes);
215    let (y, m, d) = mjd_to_ymd(mjd);
216    let h = from_bcd_byte(raw[2])?;
217    let mi = from_bcd_byte(raw[3])?;
218    let s = from_bcd_byte(raw[4])?;
219    let date = NaiveDate::from_ymd_opt(y, m, d)?;
220    let time = NaiveTime::from_hms_opt(u32::from(h), u32::from(mi), u32::from(s))?;
221    chrono::Utc
222        .from_local_datetime(&NaiveDateTime::new(date, time))
223        .single()
224}
225
226/// Encode a [`chrono::DateTime<chrono::Utc>`] to a 5-byte DVB UTC time
227/// (16-bit MJD + 24-bit BCD `HHMMSS`).
228///
229/// Sub-second precision is truncated. Returns `None` if the date is not
230/// representable in a 16-bit MJD.
231#[cfg(feature = "chrono")]
232#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
233#[must_use]
234pub fn encode_mjd_bcd_utc(dt: chrono::DateTime<chrono::Utc>) -> Option<[u8; 5]> {
235    use chrono::{Datelike, Timelike};
236    let naive = dt.naive_utc();
237    let mjd = ymd_to_mjd(naive.year(), naive.month(), naive.day())?;
238    let [m0, m1] = mjd.to_be_bytes();
239    Some([
240        m0,
241        m1,
242        to_bcd_byte(naive.hour() as u8)?,
243        to_bcd_byte(naive.minute() as u8)?,
244        to_bcd_byte(naive.second() as u8)?,
245    ])
246}
247
248/// Unix timestamp (seconds since Unix epoch 1970-01-01T00:00:00 UTC) of the
249/// T2-MI / DVB time-of-day epoch: 2000-01-01T00:00:00 UTC.
250///
251/// Computed as: days from 1970-01-01 to 2000-01-01 = 10957 days
252/// (30 years, 8 of which are leap years: 1972, 1976, …, 1996 → 365×30 + 8 = 10958 - 1 day;
253/// precise count 1970→2000 is 10957 days). 10957 × 86400 = 946684800.
254pub const SECS_2000_EPOCH: i64 = 946_684_800;
255
256/// Decode a T2-MI `seconds_since_2000` / subsecond-nanoseconds pair to a
257/// [`chrono::DateTime<chrono::Utc>`], applying a `utco` leap-second offset.
258///
259/// The T2-MI spec (ETSI TS 102 773 §5.2.7) defines:
260/// - `seconds_since_2000`: whole seconds since 2000-01-01T00:00:00 UTC (the
261///   timestamp's own time base, not civil UTC).
262/// - `utco`: the number of leap seconds to **subtract** from the timestamp's
263///   time base to obtain civil UTC. At publication of the spec this was 34 s.
264/// - `subsec_nanos`: sub-second component in nanoseconds (caller converts from
265///   the bandwidth-dependent `subseconds` field).
266///
267/// Returns `None` if the resulting timestamp is out of range.
268#[cfg(feature = "chrono")]
269#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
270#[must_use]
271pub fn decode_seconds_since_2000_utc(
272    seconds_since_2000: u64,
273    subsec_nanos: u32,
274    utco: u16,
275) -> Option<chrono::DateTime<chrono::Utc>> {
276    use chrono::TimeZone;
277    // Convert to Unix timestamp: add the 2000-epoch offset, subtract utco.
278    let unix_secs = SECS_2000_EPOCH
279        .checked_add(i64::try_from(seconds_since_2000).ok()?)?
280        .checked_sub(i64::from(utco))?;
281    chrono::Utc.timestamp_opt(unix_secs, subsec_nanos).single()
282}
283
284/// Encode a [`chrono::DateTime<chrono::Utc>`] to `seconds_since_2000` with a
285/// `utco` leap-second offset.
286///
287/// Inverse of [`decode_seconds_since_2000_utc`]. Returns `None` if the date
288/// is before the 2000 epoch or `seconds_since_2000` would not fit in 40 bits.
289#[cfg(feature = "chrono")]
290#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
291#[must_use]
292pub fn encode_seconds_since_2000_utc(
293    dt: chrono::DateTime<chrono::Utc>,
294    utco: u16,
295) -> Option<(u64, u32)> {
296    use chrono::Timelike;
297    let unix_secs = dt.timestamp();
298    // seconds_since_2000 = (unix_secs - SECS_2000_EPOCH) + utco
299    let raw = unix_secs
300        .checked_sub(SECS_2000_EPOCH)?
301        .checked_add(i64::from(utco))?;
302    let secs = u64::try_from(raw).ok()?;
303    // 40-bit field maximum
304    if secs > 0xFF_FFFF_FFFF {
305        return None;
306    }
307    Some((secs, dt.nanosecond()))
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn duration_round_trips() {
316        for &(h, m, s) in &[(0u64, 0u64, 0u64), (1, 30, 45), (99, 59, 59), (2, 0, 0)] {
317            let secs = h * 3600 + m * 60 + s;
318            let raw = encode_bcd_duration(Duration::from_secs(secs)).expect("encodes");
319            assert_eq!(decode_bcd_duration(raw), Some(Duration::from_secs(secs)));
320        }
321    }
322
323    #[test]
324    fn duration_decode_known_vector() {
325        // 0x01 0x30 0x45 = 01:30:45 = 5445 s.
326        assert_eq!(
327            decode_bcd_duration([0x01, 0x30, 0x45]),
328            Some(Duration::from_secs(5445))
329        );
330    }
331
332    #[test]
333    fn duration_rejects_over_99h_and_bad_fields() {
334        assert_eq!(encode_bcd_duration(Duration::from_secs(100 * 3600)), None);
335        assert_eq!(decode_bcd_duration([0x01, 0x75, 0x00]), None); // 75 minutes
336        assert_eq!(decode_bcd_duration([0x01, 0x00, 0x1A]), None); // bad nibble
337    }
338
339    #[cfg(feature = "chrono")]
340    #[test]
341    fn ymd_to_mjd_matches_chrono_epoch_arithmetic() {
342        use chrono::NaiveDate;
343        // MJD epoch is 1858-11-17.
344        let epoch = NaiveDate::from_ymd_opt(1858, 11, 17).unwrap();
345        for &(y, m, d) in &[(1993, 10, 13), (2000, 1, 1), (2023, 6, 8), (1900, 3, 1)] {
346            let date = NaiveDate::from_ymd_opt(y, m, d).unwrap();
347            let expected = (date - epoch).num_days() as u16;
348            assert_eq!(ymd_to_mjd(y, m, d), Some(expected), "{y}-{m}-{d}");
349        }
350    }
351
352    #[cfg(feature = "chrono")]
353    #[test]
354    fn mjd_ymd_round_trips() {
355        for mjd in [40_587u16, 49_273, 51_544, 59_945, 60_000] {
356            let (y, m, d) = mjd_to_ymd(mjd);
357            assert_eq!(ymd_to_mjd(y, m, d), Some(mjd), "mjd {mjd}");
358        }
359    }
360
361    #[cfg(feature = "chrono")]
362    #[test]
363    fn utc_round_trips() {
364        let raw = [0xE4, 0x09, 0x12, 0x34, 0x56];
365        let dt = decode_mjd_bcd_utc(raw).expect("decodes");
366        assert_eq!(encode_mjd_bcd_utc(dt), Some(raw));
367    }
368
369    #[cfg(feature = "chrono")]
370    #[test]
371    fn utc_decode_known_vector() {
372        use chrono::{Datelike, Timelike};
373        // MJD 0xE409 = 58377, BCD 12:34:56.
374        let dt = decode_mjd_bcd_utc([0xE4, 0x09, 0x12, 0x34, 0x56]).expect("decodes");
375        assert_eq!((dt.hour(), dt.minute(), dt.second()), (12, 34, 56));
376        assert_eq!(dt.year(), 2018);
377    }
378
379    #[test]
380    fn mjd_bcd_round_trips() {
381        for &(y, m, d, h, mi, s) in &[
382            (2023u16, 1u8, 1u8, 12u8, 34u8, 56u8),
383            (2000, 1, 1, 0, 0, 0),
384            (2023, 6, 8, 23, 59, 59),
385        ] {
386            let dt = MjdBcdDateTime {
387                year: y,
388                month: m,
389                day: d,
390                hour: h,
391                minute: mi,
392                second: s,
393            };
394            let raw = encode_mjd_bcd(dt).expect("encodes");
395            let re = decode_mjd_bcd(raw).expect("decodes");
396            assert_eq!(re, dt);
397        }
398    }
399
400    #[test]
401    fn mjd_bcd_rejects_invalid_bcd() {
402        assert_eq!(decode_mjd_bcd([0xE4, 0x09, 0x1A, 0x34, 0x56]), None);
403        assert_eq!(decode_mjd_bcd([0xE4, 0x09, 0x12, 0x75, 0x56]), None);
404    }
405
406    #[test]
407    fn mjd_bcd_matches_chrono_when_available() {
408        let raw = [0xE4, 0x09, 0x12, 0x34, 0x56];
409        let plain = decode_mjd_bcd(raw).expect("decodes");
410        #[cfg(feature = "chrono")]
411        {
412            use chrono::{Datelike, Timelike};
413            let chrono_dt = decode_mjd_bcd_utc(raw).expect("decodes");
414            assert_eq!(plain.year as i32, chrono_dt.year());
415            assert_eq!(plain.month as u32, chrono_dt.month());
416            assert_eq!(plain.day as u32, chrono_dt.day());
417            assert_eq!(plain.hour as u32, chrono_dt.hour());
418            assert_eq!(plain.minute as u32, chrono_dt.minute());
419            assert_eq!(plain.second as u32, chrono_dt.second());
420        }
421        // Even without chrono, the plain decode must produce 2018-09-16 12:34:56.
422        assert_eq!(plain.year, 2018);
423        assert_eq!(plain.month, 9);
424        assert_eq!(plain.day, 16);
425    }
426
427    #[cfg(feature = "chrono")]
428    #[test]
429    fn secs_2000_epoch_is_correct() {
430        use chrono::{Datelike, TimeZone, Timelike};
431        // SECS_2000_EPOCH must decode to 2000-01-01T00:00:00 UTC.
432        let dt = chrono::Utc.timestamp_opt(SECS_2000_EPOCH, 0).unwrap();
433        assert_eq!((dt.year(), dt.month(), dt.day()), (2000, 1, 1));
434        assert_eq!((dt.hour(), dt.minute(), dt.second()), (0, 0, 0));
435    }
436
437    #[cfg(feature = "chrono")]
438    #[test]
439    fn decode_seconds_since_2000_utc_known_value() {
440        use chrono::{Datelike, Timelike};
441        // seconds_since_2000 = 0, utco = 0 → 2000-01-01T00:00:00 UTC.
442        let dt = decode_seconds_since_2000_utc(0, 0, 0).expect("decodes");
443        assert_eq!((dt.year(), dt.month(), dt.day()), (2000, 1, 1));
444        assert_eq!((dt.hour(), dt.minute(), dt.second()), (0, 0, 0));
445    }
446
447    #[cfg(feature = "chrono")]
448    #[test]
449    fn encode_decode_seconds_since_2000_utc_round_trips() {
450        use chrono::TimeZone;
451        // 2023-06-08T12:34:56 UTC, utco = 37 (current at time of writing).
452        let dt = chrono::Utc
453            .with_ymd_and_hms(2023, 6, 8, 12, 34, 56)
454            .unwrap();
455        let (secs, nanos) = encode_seconds_since_2000_utc(dt, 37).expect("encodes");
456        let decoded = decode_seconds_since_2000_utc(secs, nanos, 37).expect("decodes");
457        assert_eq!(decoded, dt);
458    }
459}