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