dvb-si 13.0.0

ETSI EN 300 468 DVB Service Information parser + builder. MPEG-2 PSI included.
Documentation
//! 40-bit MJD + BCD UTC timestamp field — ETSI EN 300 468 Annex C.
//!
//! The "16 bits giving the 16 LSBs of MJD followed by 24 bits coded as 6
//! digits in 4-bit BCD" encoding used by the UNT `scheduling_descriptor`
//! (ETSI TS 102 006 §9.5.2.9 Table 31) and the RNT RAR descriptors (ETSI
//! TS 102 323 §5.3.5/§5.3.6 Tables 12/13), as well as the TDT/TOT/EIT. The
//! value keeps the wire bytes (a non-decimal BCD nibble from a sloppy sender
//! must round-trip) and decodes on demand.

use crate::error::{Error, Result};

/// Wire width of the field in bytes (16-bit MJD + 24-bit BCD `HHMMSS`).
pub const MJD_BCD_LEN: usize = 5;

/// A 40-bit MJD + BCD UTC timestamp, kept as received.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct MjdBcdTime([u8; MJD_BCD_LEN]);

impl MjdBcdTime {
    /// Wrap the 5 wire bytes of the field.
    #[must_use]
    pub const fn from_raw(raw: [u8; MJD_BCD_LEN]) -> Self {
        Self(raw)
    }

    /// The 5 wire bytes of the field, verbatim.
    #[must_use]
    pub const fn raw(&self) -> [u8; MJD_BCD_LEN] {
        self.0
    }

    /// Decode to `(mjd, hours, minutes, seconds)` without `chrono`. Each BCD
    /// component is `None` if its nibbles are not decimal digits.
    #[must_use]
    pub fn parts(&self) -> (u16, Option<u8>, Option<u8>, Option<u8>) {
        (
            u16::from_be_bytes([self.0[0], self.0[1]]),
            broadcast_common::bcd::from_bcd_byte(self.0[2]),
            broadcast_common::bcd::from_bcd_byte(self.0[3]),
            broadcast_common::bcd::from_bcd_byte(self.0[4]),
        )
    }

    /// Decode to a UTC datetime. `None` if a field is out of range.
    #[cfg(feature = "chrono")]
    #[must_use]
    pub fn to_datetime(&self) -> Option<chrono::DateTime<chrono::Utc>> {
        broadcast_common::time::decode_mjd_bcd_utc(self.0)
    }

    /// Encode a UTC datetime; the inverse of [`to_datetime`](Self::to_datetime).
    ///
    /// # Errors
    /// [`Error::ValueOutOfRange`] if the date is outside the 16-bit MJD range.
    #[cfg(feature = "chrono")]
    pub fn from_datetime(dt: chrono::DateTime<chrono::Utc>) -> Result<Self> {
        broadcast_common::time::encode_mjd_bcd_utc(dt)
            .map(Self::from_raw)
            .ok_or(Error::ValueOutOfRange {
                field: "MjdBcdTime",
                reason: "date not representable in 16-bit MJD",
            })
    }

    /// Read the field from `bytes[off..off + 5]`, or `BufferTooShort` naming
    /// `what`.
    pub(crate) fn read(bytes: &[u8], off: usize, what: &'static str) -> Result<Self> {
        match bytes
            .get(off..)
            .and_then(|s| s.first_chunk::<MJD_BCD_LEN>())
        {
            Some(b) => Ok(Self(*b)),
            None => Err(Error::BufferTooShort(
                broadcast_common::error::BufferTooShort {
                    need: off.saturating_add(MJD_BCD_LEN),
                    have: bytes.len(),
                    what,
                },
            )),
        }
    }

    /// Write the field to `buf[off..off + 5]` (caller checked the length).
    pub(crate) fn write(&self, buf: &mut [u8], off: usize) {
        buf[off..off + MJD_BCD_LEN].copy_from_slice(&self.0);
    }
}

#[cfg(feature = "serde")]
impl serde::Serialize for MjdBcdTime {
    fn serialize<S: serde::Serializer>(&self, s: S) -> core::result::Result<S::Ok, S::Error> {
        use serde::ser::SerializeStruct;
        let (mjd, hour, minute, second) = self.parts();
        let mut st = s.serialize_struct("MjdBcdTime", 4)?;
        st.serialize_field("mjd", &mjd)?;
        st.serialize_field("hour", &hour)?;
        st.serialize_field("minute", &minute)?;
        st.serialize_field("second", &second)?;
        st.end()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parts_decode_and_raw_round_trip() {
        // EN 300 468 Annex C example: 1993-10-13 12:45:00 = MJD 0xC079 12 45 00.
        let t = MjdBcdTime::from_raw([0xC0, 0x79, 0x12, 0x45, 0x00]);
        assert_eq!(t.parts(), (0xC079, Some(12), Some(45), Some(0)));
        assert_eq!(MjdBcdTime::from_raw(t.raw()), t);
    }

    #[test]
    fn non_decimal_bcd_is_kept_not_rejected() {
        let t = MjdBcdTime::from_raw([0xC0, 0x79, 0xFF, 0x45, 0x00]);
        assert_eq!(t.parts().1, None);
        assert_eq!(t.raw()[2], 0xFF);
    }

    #[test]
    fn read_rejects_short_buffer() {
        assert!(matches!(
            MjdBcdTime::read(&[0; 4], 0, "t"),
            Err(Error::BufferTooShort(
                broadcast_common::error::BufferTooShort {
                    need: 5,
                    have: 4,
                    what: "t"
                }
            ))
        ));
    }

    #[cfg(feature = "chrono")]
    #[test]
    fn from_datetime_encodes_a_known_vector_and_inverts_to_datetime() {
        use chrono::TimeZone;
        let dt = chrono::Utc
            .with_ymd_and_hms(2023, 1, 1, 12, 34, 56)
            .unwrap();
        let t = MjdBcdTime::from_datetime(dt).unwrap();
        assert_eq!(t.raw(), [0xEA, 0x29, 0x12, 0x34, 0x56]);
        assert_eq!(t.to_datetime(), Some(dt));
        let far_future = chrono::Utc.with_ymd_and_hms(9999, 12, 31, 0, 0, 0).unwrap();
        assert!(matches!(
            MjdBcdTime::from_datetime(far_future),
            Err(Error::ValueOutOfRange { .. })
        ));
    }
}