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