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#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn duration_round_trips() {
252        for &(h, m, s) in &[(0u64, 0u64, 0u64), (1, 30, 45), (99, 59, 59), (2, 0, 0)] {
253            let secs = h * 3600 + m * 60 + s;
254            let raw = encode_bcd_duration(Duration::from_secs(secs)).expect("encodes");
255            assert_eq!(decode_bcd_duration(raw), Some(Duration::from_secs(secs)));
256        }
257    }
258
259    #[test]
260    fn duration_decode_known_vector() {
261        // 0x01 0x30 0x45 = 01:30:45 = 5445 s.
262        assert_eq!(
263            decode_bcd_duration([0x01, 0x30, 0x45]),
264            Some(Duration::from_secs(5445))
265        );
266    }
267
268    #[test]
269    fn duration_rejects_over_99h_and_bad_fields() {
270        assert_eq!(encode_bcd_duration(Duration::from_secs(100 * 3600)), None);
271        assert_eq!(decode_bcd_duration([0x01, 0x75, 0x00]), None); // 75 minutes
272        assert_eq!(decode_bcd_duration([0x01, 0x00, 0x1A]), None); // bad nibble
273    }
274
275    #[cfg(feature = "chrono")]
276    #[test]
277    fn ymd_to_mjd_matches_chrono_epoch_arithmetic() {
278        use chrono::NaiveDate;
279        // MJD epoch is 1858-11-17.
280        let epoch = NaiveDate::from_ymd_opt(1858, 11, 17).unwrap();
281        for &(y, m, d) in &[(1993, 10, 13), (2000, 1, 1), (2023, 6, 8), (1900, 3, 1)] {
282            let date = NaiveDate::from_ymd_opt(y, m, d).unwrap();
283            let expected = (date - epoch).num_days() as u16;
284            assert_eq!(ymd_to_mjd(y, m, d), Some(expected), "{y}-{m}-{d}");
285        }
286    }
287
288    #[cfg(feature = "chrono")]
289    #[test]
290    fn mjd_ymd_round_trips() {
291        for mjd in [40_587u16, 49_273, 51_544, 59_945, 60_000] {
292            let (y, m, d) = mjd_to_ymd(mjd);
293            assert_eq!(ymd_to_mjd(y, m, d), Some(mjd), "mjd {mjd}");
294        }
295    }
296
297    #[cfg(feature = "chrono")]
298    #[test]
299    fn utc_round_trips() {
300        let raw = [0xE4, 0x09, 0x12, 0x34, 0x56];
301        let dt = decode_mjd_bcd_utc(raw).expect("decodes");
302        assert_eq!(encode_mjd_bcd_utc(dt), Some(raw));
303    }
304
305    #[cfg(feature = "chrono")]
306    #[test]
307    fn utc_decode_known_vector() {
308        use chrono::{Datelike, Timelike};
309        // MJD 0xE409 = 58377, BCD 12:34:56.
310        let dt = decode_mjd_bcd_utc([0xE4, 0x09, 0x12, 0x34, 0x56]).expect("decodes");
311        assert_eq!((dt.hour(), dt.minute(), dt.second()), (12, 34, 56));
312        assert_eq!(dt.year(), 2018);
313    }
314
315    #[test]
316    fn mjd_bcd_round_trips() {
317        for &(y, m, d, h, mi, s) in &[
318            (2023u16, 1u8, 1u8, 12u8, 34u8, 56u8),
319            (2000, 1, 1, 0, 0, 0),
320            (2023, 6, 8, 23, 59, 59),
321        ] {
322            let dt = MjdBcdDateTime {
323                year: y,
324                month: m,
325                day: d,
326                hour: h,
327                minute: mi,
328                second: s,
329            };
330            let raw = encode_mjd_bcd(dt).expect("encodes");
331            let re = decode_mjd_bcd(raw).expect("decodes");
332            assert_eq!(re, dt);
333        }
334    }
335
336    #[test]
337    fn mjd_bcd_rejects_invalid_bcd() {
338        assert_eq!(decode_mjd_bcd([0xE4, 0x09, 0x1A, 0x34, 0x56]), None);
339        assert_eq!(decode_mjd_bcd([0xE4, 0x09, 0x12, 0x75, 0x56]), None);
340    }
341
342    #[test]
343    fn mjd_bcd_matches_chrono_when_available() {
344        let raw = [0xE4, 0x09, 0x12, 0x34, 0x56];
345        let plain = decode_mjd_bcd(raw).expect("decodes");
346        #[cfg(feature = "chrono")]
347        {
348            use chrono::{Datelike, Timelike};
349            let chrono_dt = decode_mjd_bcd_utc(raw).expect("decodes");
350            assert_eq!(plain.year as i32, chrono_dt.year());
351            assert_eq!(plain.month as u32, chrono_dt.month());
352            assert_eq!(plain.day as u32, chrono_dt.day());
353            assert_eq!(plain.hour as u32, chrono_dt.hour());
354            assert_eq!(plain.minute as u32, chrono_dt.minute());
355            assert_eq!(plain.second as u32, chrono_dt.second());
356        }
357        // Even without chrono, the plain decode must produce 2018-09-16 12:34:56.
358        assert_eq!(plain.year, 2018);
359        assert_eq!(plain.month, 9);
360        assert_eq!(plain.day, 16);
361    }
362}