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/// Decode a 24-bit BCD `HHMMSS` duration (`[HH, MM, SS]`) to a [`Duration`].
12///
13/// Returns `None` if any nibble is non-decimal or the minute/second fields
14/// exceed 59.
15#[must_use]
16pub fn decode_bcd_duration(raw: [u8; 3]) -> Option<Duration> {
17    let h = u64::from(from_bcd_byte(raw[0])?);
18    let m = u64::from(from_bcd_byte(raw[1])?);
19    let s = u64::from(from_bcd_byte(raw[2])?);
20    if m > 59 || s > 59 {
21        return None;
22    }
23    Some(Duration::from_secs(h * 3600 + m * 60 + s))
24}
25
26/// Encode a whole-second [`Duration`] to a 24-bit BCD `HHMMSS` (`[HH, MM, SS]`).
27///
28/// Sub-second precision is truncated. Returns `None` if the duration is 100
29/// hours or longer (`HH` only holds two BCD digits).
30#[must_use]
31pub fn encode_bcd_duration(duration: Duration) -> Option<[u8; 3]> {
32    let secs = duration.as_secs();
33    let h = secs / 3600;
34    if h > 99 {
35        return None;
36    }
37    let m = (secs % 3600) / 60;
38    let s = secs % 60;
39    Some([
40        to_bcd_byte(h as u8)?,
41        to_bcd_byte(m as u8)?,
42        to_bcd_byte(s as u8)?,
43    ])
44}
45
46/// Convert a 16-bit Modified Julian Date to `(year, month, day)`.
47///
48/// Inverse of [`ymd_to_mjd`]; MJD→calendar per ETSI EN 300 468 Annex C.
49#[cfg(feature = "chrono")]
50#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
51#[must_use]
52pub fn mjd_to_ymd(mjd: u16) -> (i32, u32, u32) {
53    let mjd = i64::from(mjd);
54    let y_prime = ((mjd as f64 - 15_078.2) / 365.25) as i64;
55    let m_prime = ((mjd as f64 - 14_956.1 - (y_prime as f64 * 365.25).floor()) / 30.6001) as i64;
56    let d = mjd
57        - 14_956
58        - (y_prime as f64 * 365.25).floor() as i64
59        - (m_prime as f64 * 30.6001).floor() as i64;
60    let k = i64::from(m_prime == 14 || m_prime == 15);
61    let y = y_prime + k + 1900;
62    let m = m_prime - 1 - k * 12;
63    (y as i32, m as u32, d as u32)
64}
65
66/// Convert a `(year, month, day)` date to a 16-bit Modified Julian Date.
67///
68/// Forward of [`mjd_to_ymd`], calendar→MJD per ETSI EN 300 468 Annex C. Returns
69/// `None` if the field is out of range or the date is not representable in a
70/// 16-bit MJD.
71#[cfg(feature = "chrono")]
72#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
73#[must_use]
74pub fn ymd_to_mjd(year: i32, month: u32, day: u32) -> Option<u16> {
75    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
76        return None;
77    }
78    let l = if month <= 2 { 1.0 } else { 0.0 };
79    let y = f64::from(year - 1900);
80    let m = f64::from(month);
81    let mjd = 14_956.0
82        + f64::from(day)
83        + ((y - l) * 365.25).floor()
84        + ((m + 1.0 + l * 12.0) * 30.6001).floor();
85    if (0.0..=f64::from(u16::MAX)).contains(&mjd) {
86        Some(mjd as u16)
87    } else {
88        None
89    }
90}
91
92/// Decode a 5-byte DVB UTC time (16-bit MJD + 24-bit BCD `HHMMSS`) to a
93/// [`chrono::DateTime<chrono::Utc>`].
94///
95/// Returns `None` if the BCD nibbles are out of range or the date/time is
96/// invalid.
97#[cfg(feature = "chrono")]
98#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
99#[must_use]
100pub fn decode_mjd_bcd_utc(raw: [u8; 5]) -> Option<chrono::DateTime<chrono::Utc>> {
101    use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
102    let mjd = u16::from_be_bytes([raw[0], raw[1]]);
103    let (y, m, d) = mjd_to_ymd(mjd);
104    let h = from_bcd_byte(raw[2])?;
105    let mi = from_bcd_byte(raw[3])?;
106    let s = from_bcd_byte(raw[4])?;
107    let date = NaiveDate::from_ymd_opt(y, m, d)?;
108    let time = NaiveTime::from_hms_opt(u32::from(h), u32::from(mi), u32::from(s))?;
109    chrono::Utc
110        .from_local_datetime(&NaiveDateTime::new(date, time))
111        .single()
112}
113
114/// Encode a [`chrono::DateTime<chrono::Utc>`] to a 5-byte DVB UTC time
115/// (16-bit MJD + 24-bit BCD `HHMMSS`).
116///
117/// Sub-second precision is truncated. Returns `None` if the date is not
118/// representable in a 16-bit MJD.
119#[cfg(feature = "chrono")]
120#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
121#[must_use]
122pub fn encode_mjd_bcd_utc(dt: chrono::DateTime<chrono::Utc>) -> Option<[u8; 5]> {
123    use chrono::{Datelike, Timelike};
124    let naive = dt.naive_utc();
125    let mjd = ymd_to_mjd(naive.year(), naive.month(), naive.day())?;
126    let [m0, m1] = mjd.to_be_bytes();
127    Some([
128        m0,
129        m1,
130        to_bcd_byte(naive.hour() as u8)?,
131        to_bcd_byte(naive.minute() as u8)?,
132        to_bcd_byte(naive.second() as u8)?,
133    ])
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn duration_round_trips() {
142        for &(h, m, s) in &[(0u64, 0u64, 0u64), (1, 30, 45), (99, 59, 59), (2, 0, 0)] {
143            let secs = h * 3600 + m * 60 + s;
144            let raw = encode_bcd_duration(Duration::from_secs(secs)).expect("encodes");
145            assert_eq!(decode_bcd_duration(raw), Some(Duration::from_secs(secs)));
146        }
147    }
148
149    #[test]
150    fn duration_decode_known_vector() {
151        // 0x01 0x30 0x45 = 01:30:45 = 5445 s.
152        assert_eq!(
153            decode_bcd_duration([0x01, 0x30, 0x45]),
154            Some(Duration::from_secs(5445))
155        );
156    }
157
158    #[test]
159    fn duration_rejects_over_99h_and_bad_fields() {
160        assert_eq!(encode_bcd_duration(Duration::from_secs(100 * 3600)), None);
161        assert_eq!(decode_bcd_duration([0x01, 0x75, 0x00]), None); // 75 minutes
162        assert_eq!(decode_bcd_duration([0x01, 0x00, 0x1A]), None); // bad nibble
163    }
164
165    #[cfg(feature = "chrono")]
166    #[test]
167    fn ymd_to_mjd_matches_chrono_epoch_arithmetic() {
168        use chrono::NaiveDate;
169        // MJD epoch is 1858-11-17.
170        let epoch = NaiveDate::from_ymd_opt(1858, 11, 17).unwrap();
171        for &(y, m, d) in &[(1993, 10, 13), (2000, 1, 1), (2023, 6, 8), (1900, 3, 1)] {
172            let date = NaiveDate::from_ymd_opt(y, m, d).unwrap();
173            let expected = (date - epoch).num_days() as u16;
174            assert_eq!(ymd_to_mjd(y, m, d), Some(expected), "{y}-{m}-{d}");
175        }
176    }
177
178    #[cfg(feature = "chrono")]
179    #[test]
180    fn mjd_ymd_round_trips() {
181        for mjd in [40_587u16, 49_273, 51_544, 59_945, 60_000] {
182            let (y, m, d) = mjd_to_ymd(mjd);
183            assert_eq!(ymd_to_mjd(y, m, d), Some(mjd), "mjd {mjd}");
184        }
185    }
186
187    #[cfg(feature = "chrono")]
188    #[test]
189    fn utc_round_trips() {
190        let raw = [0xE4, 0x09, 0x12, 0x34, 0x56];
191        let dt = decode_mjd_bcd_utc(raw).expect("decodes");
192        assert_eq!(encode_mjd_bcd_utc(dt), Some(raw));
193    }
194
195    #[cfg(feature = "chrono")]
196    #[test]
197    fn utc_decode_known_vector() {
198        use chrono::{Datelike, Timelike};
199        // MJD 0xE409 = 58377, BCD 12:34:56.
200        let dt = decode_mjd_bcd_utc([0xE4, 0x09, 0x12, 0x34, 0x56]).expect("decodes");
201        assert_eq!((dt.hour(), dt.minute(), dt.second()), (12, 34, 56));
202        assert_eq!(dt.year(), 2018);
203    }
204}