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        let mut offset = 0;
209        while offset < body.len() {
210            let country_code = LangCode([body[offset], body[offset + 1], body[offset + 2]]);
211            let flags = body[offset + 3];
212            let country_region_id = (flags & REGION_ID_MASK) >> 2;
213            let local_time_offset_negative = flags & POLARITY_MASK != 0;
214            let local_time_offset_bcd = u16::from_be_bytes([body[offset + 4], body[offset + 5]]);
215            let mut time_of_change_raw = [0u8; 5];
216            time_of_change_raw.copy_from_slice(&body[offset + 6..offset + 11]);
217            let next_time_offset_bcd = u16::from_be_bytes([body[offset + 11], body[offset + 12]]);
218            entries.push(LocalTimeOffsetEntry {
219                country_code,
220                country_region_id,
221                local_time_offset_negative,
222                local_time_offset_bcd,
223                time_of_change_raw,
224                next_time_offset_bcd,
225            });
226            offset += ENTRY_LEN;
227        }
228        Ok(Self { entries })
229    }
230}
231
232impl Serialize for LocalTimeOffsetDescriptor {
233    type Error = crate::error::Error;
234    fn serialized_len(&self) -> usize {
235        HEADER_LEN + ENTRY_LEN * self.entries.len()
236    }
237
238    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
239        let len = self.serialized_len();
240        if buf.len() < len {
241            return Err(Error::OutputBufferTooSmall {
242                need: len,
243                have: buf.len(),
244            });
245        }
246        buf[0] = TAG;
247        buf[1] = (len - HEADER_LEN) as u8;
248        let mut offset = HEADER_LEN;
249        for entry in &self.entries {
250            buf[offset..offset + 3].copy_from_slice(&entry.country_code.0);
251            let flags = ((entry.country_region_id << 2) & REGION_ID_MASK)
252                | RESERVED_BIT_MASK
253                | if entry.local_time_offset_negative {
254                    POLARITY_MASK
255                } else {
256                    0
257                };
258            buf[offset + 3] = flags;
259            buf[offset + 4..offset + 6].copy_from_slice(&entry.local_time_offset_bcd.to_be_bytes());
260            buf[offset + 6..offset + 11].copy_from_slice(&entry.time_of_change_raw);
261            buf[offset + 11..offset + 13]
262                .copy_from_slice(&entry.next_time_offset_bcd.to_be_bytes());
263            offset += ENTRY_LEN;
264        }
265        Ok(len)
266    }
267}
268impl<'a> crate::traits::DescriptorDef<'a> for LocalTimeOffsetDescriptor {
269    const TAG: u8 = TAG;
270    const NAME: &'static str = "LOCAL_TIME_OFFSET";
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn parse_single_entry() {
279        let bytes = [
280            TAG, 13, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
281        ];
282        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
283        assert_eq!(d.entries.len(), 1);
284        assert_eq!(d.entries[0].country_code, LangCode([0x46, 0x52, 0x41]));
285        assert_eq!(d.entries[0].country_region_id, 0);
286        assert!(!d.entries[0].local_time_offset_negative);
287        assert_eq!(d.entries[0].local_time_offset_bcd, 0x0100);
288        assert_eq!(
289            d.entries[0].time_of_change_raw(),
290            [0xAB, 0xCD, 0xEF, 0x12, 0x34]
291        );
292        assert_eq!(d.entries[0].next_time_offset_bcd, 0x0200);
293    }
294
295    #[test]
296    fn time_of_change_parts_decoded() {
297        let bytes = [
298            TAG, 13, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
299        ];
300        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
301        let (mjd, h, m, s) = d.entries[0].time_of_change_parts();
302        assert_eq!(mjd, 0xABCD);
303        assert_eq!(h, None);
304        assert_eq!(m, Some(12));
305        assert_eq!(s, Some(34));
306    }
307
308    #[test]
309    fn time_of_change_parts_valid_bcd() {
310        let entry = LocalTimeOffsetEntry {
311            country_code: LangCode([0x47, 0x42, 0x52]),
312            country_region_id: 0,
313            local_time_offset_negative: false,
314            local_time_offset_bcd: 0x0000,
315            time_of_change_raw: [0xC0, 0x3E, 0x12, 0x30, 0x00],
316            next_time_offset_bcd: 0x0100,
317        };
318        let (mjd, h, m, s) = entry.time_of_change_parts();
319        assert_eq!(mjd, 0xC03E);
320        assert_eq!(h, Some(12));
321        assert_eq!(m, Some(30));
322        assert_eq!(s, Some(0));
323    }
324
325    #[test]
326    fn parse_multiple_entries_preserves_order() {
327        let bytes = [
328            TAG, 26, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
329            0x47, 0x42, 0x52, 0x06, 0x00, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x01, 0x00,
330        ];
331        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
332        assert_eq!(d.entries.len(), 2);
333        assert_eq!(d.entries[0].country_code, LangCode([0x46, 0x52, 0x41]));
334        assert_eq!(d.entries[1].country_code, LangCode([0x47, 0x42, 0x52]));
335    }
336
337    #[test]
338    fn parse_extracts_polarity_negative() {
339        let bytes = [
340            TAG, 13, 0x46, 0x52, 0x41, 0x03, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
341        ];
342        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
343        assert!(d.entries[0].local_time_offset_negative);
344    }
345
346    #[test]
347    fn parse_extracts_country_region_id() {
348        let bytes = [
349            TAG, 13, 0x46, 0x52, 0x41, 0x1A, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
350        ];
351        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
352        assert_eq!(d.entries[0].country_region_id, 6);
353    }
354
355    #[test]
356    fn parse_rejects_wrong_tag() {
357        let err = LocalTimeOffsetDescriptor::parse(&[
358            0x59, 13, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
359        ])
360        .unwrap_err();
361        assert!(matches!(err, Error::InvalidDescriptor { tag: 0x59, .. }));
362    }
363
364    #[test]
365    fn parse_rejects_length_not_multiple_of_13() {
366        let bytes = [
367            TAG, 14, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
368            0xFF,
369        ];
370        let err = LocalTimeOffsetDescriptor::parse(&bytes).unwrap_err();
371        assert!(matches!(err, Error::InvalidDescriptor { tag: TAG, .. }));
372    }
373
374    #[test]
375    fn parse_ignores_reserved_bit_not_set() {
376        let bytes = [
377            TAG, 13, 0x46, 0x52, 0x41, 0x00, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
378        ];
379        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
380        assert_eq!(d.entries.len(), 1);
381        assert!(!d.entries[0].local_time_offset_negative);
382    }
383
384    #[test]
385    fn serialize_round_trip() {
386        let e = LocalTimeOffsetEntry {
387            country_code: LangCode([0x46, 0x52, 0x41]),
388            country_region_id: 0,
389            local_time_offset_negative: false,
390            local_time_offset_bcd: 0x0100,
391            time_of_change_raw: [0xAB, 0xCD, 0xEF, 0x12, 0x34],
392            next_time_offset_bcd: 0x0200,
393        };
394        let d = LocalTimeOffsetDescriptor { entries: vec![e] };
395        let mut buf = vec![0u8; d.serialized_len()];
396        d.serialize_into(&mut buf).unwrap();
397        let re = LocalTimeOffsetDescriptor::parse(&buf).unwrap();
398        assert_eq!(d, re);
399    }
400
401    #[test]
402    fn set_time_of_change_raw_updates_field() {
403        let mut e = LocalTimeOffsetEntry {
404            country_code: LangCode([0x46, 0x52, 0x41]),
405            country_region_id: 0,
406            local_time_offset_negative: false,
407            local_time_offset_bcd: 0x0100,
408            time_of_change_raw: [0; 5],
409            next_time_offset_bcd: 0x0200,
410        };
411        e.set_time_of_change_raw([0xAB, 0xCD, 0xEF, 0x12, 0x34]);
412        assert_eq!(e.time_of_change_raw(), [0xAB, 0xCD, 0xEF, 0x12, 0x34]);
413    }
414
415    #[test]
416    fn empty_descriptor_valid() {
417        let bytes = [TAG, 0];
418        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
419        assert!(d.entries.is_empty());
420    }
421}