Skip to main content

dvb_si/descriptors/
local_time_offset.rs

1//! Local Time Offset Descriptor — ETSI EN 300 468 §6.2.20 (tag 0x58).
2//!
3//! Carried inside the TOT (Time Offset Table) on PID 0x0014. Signals per-
4//! country offsets from UTC plus any upcoming DST transition.
5
6use super::descriptor_body;
7use crate::error::{Error, Result};
8use crate::text::LangCode;
9use dvb_common::{Parse, Serialize};
10
11/// Descriptor tag for local_time_offset_descriptor.
12pub const TAG: u8 = 0x58;
13const HEADER_LEN: usize = 2;
14const ENTRY_LEN: usize = 13;
15const POLARITY_MASK: u8 = 0x01;
16const REGION_ID_MASK: u8 = 0xFC;
17const RESERVED_BIT_MASK: u8 = 0x02;
18
19/// One per-country offset entry.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize))]
22pub struct LocalTimeOffsetEntry {
23    /// ISO 3166 alpha country code.
24    pub country_code: LangCode,
25    /// 6-bit country_region_id for sub-national regions.
26    pub country_region_id: u8,
27    /// Polarity: false = offset is positive (local = UTC + offset),
28    /// true = offset is negative (local = UTC − offset).
29    pub local_time_offset_negative: bool,
30    /// 16-bit BCD HHMM local time offset.
31    pub local_time_offset_bcd: u16,
32    /// 40-bit MJD+BCD UTC raw bytes of the DST/offset transition moment.
33    /// Private: use [`Self::time_of_change_parts`] for decoded access, or
34    /// [`Self::time_of_change`] (chrono feature) for a `DateTime`.
35    time_of_change_raw: [u8; 5],
36    /// 16-bit BCD HHMM next offset (applied after `time_of_change`).
37    pub next_time_offset_bcd: u16,
38}
39
40impl LocalTimeOffsetEntry {
41    /// Create a new entry with all fields specified.
42    #[must_use]
43    pub fn new(
44        country_code: LangCode,
45        country_region_id: u8,
46        local_time_offset_negative: bool,
47        local_time_offset_bcd: u16,
48        time_of_change_raw: [u8; 5],
49        next_time_offset_bcd: u16,
50    ) -> Self {
51        Self {
52            country_code,
53            country_region_id,
54            local_time_offset_negative,
55            local_time_offset_bcd,
56            time_of_change_raw,
57            next_time_offset_bcd,
58        }
59    }
60
61    /// Decode `time_of_change_raw` into its MJD + BCD HHMMSS components
62    /// without requiring the `chrono` feature.
63    ///
64    /// Returns `(mjd, hours, minutes, seconds)`. Each BCD field is `None` if
65    /// its nibbles are non-decimal.
66    #[must_use]
67    pub fn time_of_change_parts(&self) -> (u16, Option<u8>, Option<u8>, Option<u8>) {
68        let mjd = u16::from_be_bytes([self.time_of_change_raw[0], self.time_of_change_raw[1]]);
69        let h = dvb_common::bcd::from_bcd_byte(self.time_of_change_raw[2]);
70        let m = dvb_common::bcd::from_bcd_byte(self.time_of_change_raw[3]);
71        let s = dvb_common::bcd::from_bcd_byte(self.time_of_change_raw[4]);
72        (mjd, h, m, s)
73    }
74
75    /// The raw 5-byte MJD+BCD time-of-change field (for serialization).
76    #[must_use]
77    pub fn time_of_change_raw(&self) -> [u8; 5] {
78        self.time_of_change_raw
79    }
80
81    /// Set the `time_of_change_raw` field directly.
82    pub fn set_time_of_change_raw(&mut self, raw: [u8; 5]) {
83        self.time_of_change_raw = raw;
84    }
85}
86
87/// Decode a BCD `HHMM` offset to a signed [`chrono::Duration`] (negative when
88/// `negative`). `None` if a BCD nibble is out of range.
89#[cfg(feature = "chrono")]
90fn decode_hhmm(bcd: u16, negative: bool) -> Option<chrono::Duration> {
91    let h = dvb_common::bcd::from_bcd_byte((bcd >> 8) as u8)?;
92    let m = dvb_common::bcd::from_bcd_byte((bcd & 0xFF) as u8)?;
93    let mins = i64::from(h) * 60 + i64::from(m);
94    Some(chrono::Duration::minutes(if negative {
95        -mins
96    } else {
97        mins
98    }))
99}
100
101/// Encode a signed offset to `(negative, BCD HHMM)`. `None` if the magnitude is
102/// 100 hours or longer.
103#[cfg(feature = "chrono")]
104fn encode_hhmm(offset: chrono::Duration) -> Option<(bool, u16)> {
105    let negative = offset < chrono::Duration::zero();
106    let total_min = offset.num_minutes().unsigned_abs();
107    let h = total_min / 60;
108    let m = total_min % 60;
109    if h > 99 {
110        return None;
111    }
112    let hb = dvb_common::bcd::to_bcd_byte(h as u8)?;
113    let mb = dvb_common::bcd::to_bcd_byte(m as u8)?;
114    Some((negative, (u16::from(hb) << 8) | u16::from(mb)))
115}
116
117#[cfg(feature = "chrono")]
118impl LocalTimeOffsetEntry {
119    /// Decode `local_time_offset` (BCD `HHMM`, signed by
120    /// `local_time_offset_negative`) to a [`chrono::Duration`]. `None` if the
121    /// BCD nibbles are out of range.
122    #[must_use]
123    pub fn local_time_offset(&self) -> Option<chrono::Duration> {
124        decode_hhmm(self.local_time_offset_bcd, self.local_time_offset_negative)
125    }
126
127    /// Decode `next_time_offset` (BCD `HHMM`) to a [`chrono::Duration`]. It
128    /// shares the single `local_time_offset_negative` polarity bit (EN 300 468
129    /// §6.2.20). `None` if the BCD nibbles are out of range.
130    #[must_use]
131    pub fn next_time_offset(&self) -> Option<chrono::Duration> {
132        decode_hhmm(self.next_time_offset_bcd, self.local_time_offset_negative)
133    }
134
135    /// Decode `time_of_change_raw` (16-bit MJD + 24-bit BCD UTC) to a UTC
136    /// datetime. `None` if the date/time fields are out of range.
137    #[must_use]
138    pub fn time_of_change(&self) -> Option<chrono::DateTime<chrono::Utc>> {
139        dvb_common::time::decode_mjd_bcd_utc(self.time_of_change_raw)
140    }
141
142    /// Set the `time_of_change`, encoding it into the 40-bit raw field.
143    ///
144    /// # Errors
145    /// [`ValueOutOfRange`](crate::Error::ValueOutOfRange) if the date is
146    /// outside the representable 16-bit MJD range.
147    pub fn set_time_of_change(&mut self, dt: chrono::DateTime<chrono::Utc>) -> Result<()> {
148        self.time_of_change_raw =
149            dvb_common::time::encode_mjd_bcd_utc(dt).ok_or(Error::ValueOutOfRange {
150                field: "LocalTimeOffsetEntry::time_of_change",
151                reason: "date not representable in 16-bit MJD",
152            })?;
153        Ok(())
154    }
155
156    /// Set both offsets and the shared polarity bit from signed durations.
157    ///
158    /// The wire format carries one polarity bit for both offsets, so `local`
159    /// and `next` must share a sign (zero matches either).
160    ///
161    /// # Errors
162    /// [`ValueOutOfRange`](crate::Error::ValueOutOfRange) if the two
163    /// offsets disagree in sign or a magnitude is 100 hours or longer.
164    pub fn set_offsets(&mut self, local: chrono::Duration, next: chrono::Duration) -> Result<()> {
165        let oor = |reason| Error::ValueOutOfRange {
166            field: "LocalTimeOffsetEntry offsets",
167            reason,
168        };
169        let local_neg = local < chrono::Duration::zero();
170        let next_neg = next < chrono::Duration::zero();
171        if local_neg != next_neg && !local.is_zero() && !next.is_zero() {
172            return Err(oor("local and next offsets must share a sign"));
173        }
174        let (lneg, lbcd) = encode_hhmm(local).ok_or(oor("local offset magnitude too large"))?;
175        let (nneg, nbcd) = encode_hhmm(next).ok_or(oor("next offset magnitude too large"))?;
176        self.local_time_offset_negative = lneg || nneg;
177        self.local_time_offset_bcd = lbcd;
178        self.next_time_offset_bcd = nbcd;
179        Ok(())
180    }
181}
182
183/// Local Time Offset Descriptor.
184#[derive(Debug, Clone, PartialEq, Eq)]
185#[cfg_attr(feature = "serde", derive(serde::Serialize))]
186pub struct LocalTimeOffsetDescriptor {
187    /// Entries in wire order.
188    pub entries: Vec<LocalTimeOffsetEntry>,
189}
190
191impl<'a> Parse<'a> for LocalTimeOffsetDescriptor {
192    type Error = crate::error::Error;
193    fn parse(bytes: &'a [u8]) -> Result<Self> {
194        let body = descriptor_body(
195            bytes,
196            TAG,
197            "LocalTimeOffsetDescriptor",
198            "unexpected tag for local_time_offset_descriptor",
199        )?;
200        if body.len() % ENTRY_LEN != 0 {
201            return Err(Error::InvalidDescriptor {
202                tag: TAG,
203                reason: "descriptor_length must be a multiple of 13",
204            });
205        }
206        let mut entries = Vec::with_capacity(body.len() / ENTRY_LEN);
207        let mut offset = 0;
208        while offset < body.len() {
209            let country_code = LangCode([body[offset], body[offset + 1], body[offset + 2]]);
210            let flags = body[offset + 3];
211            let country_region_id = (flags & REGION_ID_MASK) >> 2;
212            let local_time_offset_negative = flags & POLARITY_MASK != 0;
213            let local_time_offset_bcd = u16::from_be_bytes([body[offset + 4], body[offset + 5]]);
214            let mut time_of_change_raw = [0u8; 5];
215            time_of_change_raw.copy_from_slice(&body[offset + 6..offset + 11]);
216            let next_time_offset_bcd = u16::from_be_bytes([body[offset + 11], body[offset + 12]]);
217            entries.push(LocalTimeOffsetEntry {
218                country_code,
219                country_region_id,
220                local_time_offset_negative,
221                local_time_offset_bcd,
222                time_of_change_raw,
223                next_time_offset_bcd,
224            });
225            offset += ENTRY_LEN;
226        }
227        Ok(Self { entries })
228    }
229}
230
231impl Serialize for LocalTimeOffsetDescriptor {
232    type Error = crate::error::Error;
233    fn serialized_len(&self) -> usize {
234        HEADER_LEN + ENTRY_LEN * self.entries.len()
235    }
236
237    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
238        let len = self.serialized_len();
239        if buf.len() < len {
240            return Err(Error::OutputBufferTooSmall {
241                need: len,
242                have: buf.len(),
243            });
244        }
245        buf[0] = TAG;
246        buf[1] = (len - HEADER_LEN) as u8;
247        let mut offset = HEADER_LEN;
248        for entry in &self.entries {
249            buf[offset..offset + 3].copy_from_slice(&entry.country_code.0);
250            let flags = ((entry.country_region_id << 2) & REGION_ID_MASK)
251                | RESERVED_BIT_MASK
252                | if entry.local_time_offset_negative {
253                    POLARITY_MASK
254                } else {
255                    0
256                };
257            buf[offset + 3] = flags;
258            buf[offset + 4..offset + 6].copy_from_slice(&entry.local_time_offset_bcd.to_be_bytes());
259            buf[offset + 6..offset + 11].copy_from_slice(&entry.time_of_change_raw);
260            buf[offset + 11..offset + 13]
261                .copy_from_slice(&entry.next_time_offset_bcd.to_be_bytes());
262            offset += ENTRY_LEN;
263        }
264        Ok(len)
265    }
266}
267impl<'a> crate::traits::DescriptorDef<'a> for LocalTimeOffsetDescriptor {
268    const TAG: u8 = TAG;
269    const NAME: &'static str = "LOCAL_TIME_OFFSET";
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn parse_single_entry() {
278        let bytes = [
279            TAG, 13, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
280        ];
281        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
282        assert_eq!(d.entries.len(), 1);
283        assert_eq!(d.entries[0].country_code, LangCode([0x46, 0x52, 0x41]));
284        assert_eq!(d.entries[0].country_region_id, 0);
285        assert!(!d.entries[0].local_time_offset_negative);
286        assert_eq!(d.entries[0].local_time_offset_bcd, 0x0100);
287        assert_eq!(
288            d.entries[0].time_of_change_raw(),
289            [0xAB, 0xCD, 0xEF, 0x12, 0x34]
290        );
291        assert_eq!(d.entries[0].next_time_offset_bcd, 0x0200);
292    }
293
294    #[test]
295    fn time_of_change_parts_decoded() {
296        let bytes = [
297            TAG, 13, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
298        ];
299        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
300        let (mjd, h, m, s) = d.entries[0].time_of_change_parts();
301        assert_eq!(mjd, 0xABCD);
302        assert_eq!(h, None);
303        assert_eq!(m, Some(12));
304        assert_eq!(s, Some(34));
305    }
306
307    #[test]
308    fn time_of_change_parts_valid_bcd() {
309        let entry = LocalTimeOffsetEntry {
310            country_code: LangCode([0x47, 0x42, 0x52]),
311            country_region_id: 0,
312            local_time_offset_negative: false,
313            local_time_offset_bcd: 0x0000,
314            time_of_change_raw: [0xC0, 0x3E, 0x12, 0x30, 0x00],
315            next_time_offset_bcd: 0x0100,
316        };
317        let (mjd, h, m, s) = entry.time_of_change_parts();
318        assert_eq!(mjd, 0xC03E);
319        assert_eq!(h, Some(12));
320        assert_eq!(m, Some(30));
321        assert_eq!(s, Some(0));
322    }
323
324    #[test]
325    fn parse_multiple_entries_preserves_order() {
326        let bytes = [
327            TAG, 26, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
328            0x47, 0x42, 0x52, 0x06, 0x00, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x01, 0x00,
329        ];
330        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
331        assert_eq!(d.entries.len(), 2);
332        assert_eq!(d.entries[0].country_code, LangCode([0x46, 0x52, 0x41]));
333        assert_eq!(d.entries[1].country_code, LangCode([0x47, 0x42, 0x52]));
334    }
335
336    #[test]
337    fn parse_extracts_polarity_negative() {
338        let bytes = [
339            TAG, 13, 0x46, 0x52, 0x41, 0x03, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
340        ];
341        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
342        assert!(d.entries[0].local_time_offset_negative);
343    }
344
345    #[test]
346    fn parse_extracts_country_region_id() {
347        let bytes = [
348            TAG, 13, 0x46, 0x52, 0x41, 0x1A, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
349        ];
350        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
351        assert_eq!(d.entries[0].country_region_id, 6);
352    }
353
354    #[test]
355    fn parse_rejects_wrong_tag() {
356        let err = LocalTimeOffsetDescriptor::parse(&[
357            0x59, 13, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
358        ])
359        .unwrap_err();
360        assert!(matches!(err, Error::InvalidDescriptor { tag: 0x59, .. }));
361    }
362
363    #[test]
364    fn parse_rejects_length_not_multiple_of_13() {
365        let bytes = [
366            TAG, 14, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
367            0xFF,
368        ];
369        let err = LocalTimeOffsetDescriptor::parse(&bytes).unwrap_err();
370        assert!(matches!(err, Error::InvalidDescriptor { tag: TAG, .. }));
371    }
372
373    #[test]
374    fn parse_ignores_reserved_bit_not_set() {
375        let bytes = [
376            TAG, 13, 0x46, 0x52, 0x41, 0x00, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
377        ];
378        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
379        assert_eq!(d.entries.len(), 1);
380        assert!(!d.entries[0].local_time_offset_negative);
381    }
382
383    #[test]
384    fn serialize_round_trip() {
385        let e = LocalTimeOffsetEntry {
386            country_code: LangCode([0x46, 0x52, 0x41]),
387            country_region_id: 0,
388            local_time_offset_negative: false,
389            local_time_offset_bcd: 0x0100,
390            time_of_change_raw: [0xAB, 0xCD, 0xEF, 0x12, 0x34],
391            next_time_offset_bcd: 0x0200,
392        };
393        let d = LocalTimeOffsetDescriptor { entries: vec![e] };
394        let mut buf = vec![0u8; d.serialized_len()];
395        d.serialize_into(&mut buf).unwrap();
396        let re = LocalTimeOffsetDescriptor::parse(&buf).unwrap();
397        assert_eq!(d, re);
398    }
399
400    #[test]
401    fn set_time_of_change_raw_updates_field() {
402        let mut e = LocalTimeOffsetEntry {
403            country_code: LangCode([0x46, 0x52, 0x41]),
404            country_region_id: 0,
405            local_time_offset_negative: false,
406            local_time_offset_bcd: 0x0100,
407            time_of_change_raw: [0; 5],
408            next_time_offset_bcd: 0x0200,
409        };
410        e.set_time_of_change_raw([0xAB, 0xCD, 0xEF, 0x12, 0x34]);
411        assert_eq!(e.time_of_change_raw(), [0xAB, 0xCD, 0xEF, 0x12, 0x34]);
412    }
413
414    #[test]
415    fn empty_descriptor_valid() {
416        let bytes = [TAG, 0];
417        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
418        assert!(d.entries.is_empty());
419    }
420}