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 crate::error::{Error, Result};
7use crate::text::LangCode;
8use crate::traits::Descriptor;
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+UTC raw bytes of the DST/offset transition moment.
33    pub time_of_change_raw: [u8; 5],
34    /// 16-bit BCD HHMM next offset (applied after `time_of_change`).
35    pub next_time_offset_bcd: u16,
36}
37
38/// Decode a BCD `HHMM` offset to a signed [`chrono::Duration`] (negative when
39/// `negative`). `None` if a BCD nibble is out of range.
40#[cfg(feature = "chrono")]
41fn decode_hhmm(bcd: u16, negative: bool) -> Option<chrono::Duration> {
42    let h = dvb_common::bcd::from_bcd_byte((bcd >> 8) as u8)?;
43    let m = dvb_common::bcd::from_bcd_byte((bcd & 0xFF) as u8)?;
44    let mins = i64::from(h) * 60 + i64::from(m);
45    Some(chrono::Duration::minutes(if negative {
46        -mins
47    } else {
48        mins
49    }))
50}
51
52/// Encode a signed offset to `(negative, BCD HHMM)`. `None` if the magnitude is
53/// 100 hours or longer.
54#[cfg(feature = "chrono")]
55fn encode_hhmm(offset: chrono::Duration) -> Option<(bool, u16)> {
56    let negative = offset < chrono::Duration::zero();
57    let total_min = offset.num_minutes().unsigned_abs();
58    let h = total_min / 60;
59    let m = total_min % 60;
60    if h > 99 {
61        return None;
62    }
63    let hb = dvb_common::bcd::to_bcd_byte(h as u8)?;
64    let mb = dvb_common::bcd::to_bcd_byte(m as u8)?;
65    Some((negative, (u16::from(hb) << 8) | u16::from(mb)))
66}
67
68#[cfg(feature = "chrono")]
69impl LocalTimeOffsetEntry {
70    /// Decode `local_time_offset` (BCD `HHMM`, signed by
71    /// `local_time_offset_negative`) to a [`chrono::Duration`]. `None` if the
72    /// BCD nibbles are out of range.
73    #[must_use]
74    pub fn local_time_offset(&self) -> Option<chrono::Duration> {
75        decode_hhmm(self.local_time_offset_bcd, self.local_time_offset_negative)
76    }
77
78    /// Decode `next_time_offset` (BCD `HHMM`) to a [`chrono::Duration`]. It
79    /// shares the single `local_time_offset_negative` polarity bit (EN 300 468
80    /// §6.2.20). `None` if the BCD nibbles are out of range.
81    #[must_use]
82    pub fn next_time_offset(&self) -> Option<chrono::Duration> {
83        decode_hhmm(self.next_time_offset_bcd, self.local_time_offset_negative)
84    }
85
86    /// Decode `time_of_change_raw` (16-bit MJD + 24-bit BCD UTC) to a UTC
87    /// datetime. `None` if the date/time fields are out of range.
88    #[must_use]
89    pub fn time_of_change(&self) -> Option<chrono::DateTime<chrono::Utc>> {
90        dvb_common::time::decode_mjd_bcd_utc(self.time_of_change_raw)
91    }
92
93    /// Set the `time_of_change`, encoding it into the 40-bit raw field.
94    ///
95    /// # Errors
96    /// [`ValueOutOfRange`](crate::Error::ValueOutOfRange) if the date is
97    /// outside the representable 16-bit MJD range.
98    pub fn set_time_of_change(&mut self, dt: chrono::DateTime<chrono::Utc>) -> Result<()> {
99        self.time_of_change_raw =
100            dvb_common::time::encode_mjd_bcd_utc(dt).ok_or(Error::ValueOutOfRange {
101                field: "LocalTimeOffsetEntry::time_of_change",
102                reason: "date not representable in 16-bit MJD",
103            })?;
104        Ok(())
105    }
106
107    /// Set both offsets and the shared polarity bit from signed durations.
108    ///
109    /// The wire format carries one polarity bit for both offsets, so `local`
110    /// and `next` must share a sign (zero matches either).
111    ///
112    /// # Errors
113    /// [`ValueOutOfRange`](crate::Error::ValueOutOfRange) if the two
114    /// offsets disagree in sign or a magnitude is 100 hours or longer.
115    pub fn set_offsets(&mut self, local: chrono::Duration, next: chrono::Duration) -> Result<()> {
116        let oor = |reason| Error::ValueOutOfRange {
117            field: "LocalTimeOffsetEntry offsets",
118            reason,
119        };
120        let local_neg = local < chrono::Duration::zero();
121        let next_neg = next < chrono::Duration::zero();
122        if local_neg != next_neg && !local.is_zero() && !next.is_zero() {
123            return Err(oor("local and next offsets must share a sign"));
124        }
125        let (lneg, lbcd) = encode_hhmm(local).ok_or(oor("local offset magnitude too large"))?;
126        let (nneg, nbcd) = encode_hhmm(next).ok_or(oor("next offset magnitude too large"))?;
127        self.local_time_offset_negative = lneg || nneg;
128        self.local_time_offset_bcd = lbcd;
129        self.next_time_offset_bcd = nbcd;
130        Ok(())
131    }
132}
133
134/// Local Time Offset Descriptor.
135#[derive(Debug, Clone, PartialEq, Eq)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize))]
137pub struct LocalTimeOffsetDescriptor {
138    /// Entries in wire order.
139    pub entries: Vec<LocalTimeOffsetEntry>,
140}
141
142impl<'a> Parse<'a> for LocalTimeOffsetDescriptor {
143    type Error = crate::error::Error;
144    fn parse(bytes: &'a [u8]) -> Result<Self> {
145        if bytes.len() < HEADER_LEN {
146            return Err(Error::BufferTooShort {
147                need: HEADER_LEN,
148                have: bytes.len(),
149                what: "LocalTimeOffsetDescriptor header",
150            });
151        }
152        if bytes[0] != TAG {
153            return Err(Error::InvalidDescriptor {
154                tag: bytes[0],
155                reason: "unexpected tag for local_time_offset_descriptor",
156            });
157        }
158        let length = bytes[1] as usize;
159        if length % ENTRY_LEN != 0 {
160            return Err(Error::InvalidDescriptor {
161                tag: TAG,
162                reason: "descriptor_length must be a multiple of 13",
163            });
164        }
165        let body_start = HEADER_LEN;
166        let body_end = body_start + length;
167        if bytes.len() < body_end {
168            return Err(Error::BufferTooShort {
169                need: body_end,
170                have: bytes.len(),
171                what: "LocalTimeOffsetDescriptor body",
172            });
173        }
174        let mut entries = Vec::with_capacity(length / ENTRY_LEN);
175        let mut offset = body_start;
176        while offset < body_end {
177            let country_code = LangCode([bytes[offset], bytes[offset + 1], bytes[offset + 2]]);
178            let flags = bytes[offset + 3];
179            // The reserved bit is ignored on parse (EN 300 468 §5.1: decoders
180            // shall ignore reserved bits).
181            let country_region_id = (flags & REGION_ID_MASK) >> 2;
182            let local_time_offset_negative = flags & POLARITY_MASK != 0;
183            let local_time_offset_bcd = u16::from_be_bytes([bytes[offset + 4], bytes[offset + 5]]);
184            let mut time_of_change_raw = [0u8; 5];
185            time_of_change_raw.copy_from_slice(&bytes[offset + 6..offset + 11]);
186            let next_time_offset_bcd = u16::from_be_bytes([bytes[offset + 11], bytes[offset + 12]]);
187            entries.push(LocalTimeOffsetEntry {
188                country_code,
189                country_region_id,
190                local_time_offset_negative,
191                local_time_offset_bcd,
192                time_of_change_raw,
193                next_time_offset_bcd,
194            });
195            offset += ENTRY_LEN;
196        }
197        Ok(Self { entries })
198    }
199}
200
201impl Serialize for LocalTimeOffsetDescriptor {
202    type Error = crate::error::Error;
203    fn serialized_len(&self) -> usize {
204        HEADER_LEN + ENTRY_LEN * self.entries.len()
205    }
206
207    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
208        let len = self.serialized_len();
209        if buf.len() < len {
210            return Err(Error::OutputBufferTooSmall {
211                need: len,
212                have: buf.len(),
213            });
214        }
215        buf[0] = TAG;
216        buf[1] = (len - HEADER_LEN) as u8;
217        let mut offset = HEADER_LEN;
218        for entry in &self.entries {
219            buf[offset..offset + 3].copy_from_slice(&entry.country_code.0);
220            let flags = ((entry.country_region_id << 2) & REGION_ID_MASK)
221                | RESERVED_BIT_MASK
222                | if entry.local_time_offset_negative {
223                    POLARITY_MASK
224                } else {
225                    0
226                };
227            buf[offset + 3] = flags;
228            buf[offset + 4..offset + 6].copy_from_slice(&entry.local_time_offset_bcd.to_be_bytes());
229            buf[offset + 6..offset + 11].copy_from_slice(&entry.time_of_change_raw);
230            buf[offset + 11..offset + 13]
231                .copy_from_slice(&entry.next_time_offset_bcd.to_be_bytes());
232            offset += ENTRY_LEN;
233        }
234        Ok(len)
235    }
236}
237
238impl<'a> Descriptor<'a> for LocalTimeOffsetDescriptor {
239    const TAG: u8 = TAG;
240    fn descriptor_length(&self) -> u8 {
241        (self.serialized_len() - HEADER_LEN) as u8
242    }
243}
244
245impl<'a> crate::traits::DescriptorDef<'a> for LocalTimeOffsetDescriptor {
246    const TAG: u8 = TAG;
247    const NAME: &'static str = "LOCAL_TIME_OFFSET";
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn parse_single_entry() {
256        let bytes = [
257            TAG, 13, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
258        ];
259        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
260        assert_eq!(d.entries.len(), 1);
261        assert_eq!(d.entries[0].country_code, LangCode([0x46, 0x52, 0x41]));
262        assert_eq!(d.entries[0].country_region_id, 0);
263        assert!(!d.entries[0].local_time_offset_negative);
264        assert_eq!(d.entries[0].local_time_offset_bcd, 0x0100);
265        assert_eq!(
266            d.entries[0].time_of_change_raw,
267            [0xAB, 0xCD, 0xEF, 0x12, 0x34]
268        );
269        assert_eq!(d.entries[0].next_time_offset_bcd, 0x0200);
270    }
271
272    #[test]
273    fn parse_multiple_entries_preserves_order() {
274        let bytes = [
275            TAG, 26, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
276            0x47, 0x42, 0x52, 0x06, 0x00, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x01, 0x00,
277        ];
278        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
279        assert_eq!(d.entries.len(), 2);
280        assert_eq!(d.entries[0].country_code, LangCode([0x46, 0x52, 0x41]));
281        assert_eq!(d.entries[1].country_code, LangCode([0x47, 0x42, 0x52]));
282    }
283
284    #[test]
285    fn parse_extracts_polarity_negative() {
286        let bytes = [
287            TAG, 13, 0x46, 0x52, 0x41, 0x03, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
288        ];
289        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
290        assert!(d.entries[0].local_time_offset_negative);
291    }
292
293    #[test]
294    fn parse_extracts_country_region_id() {
295        let bytes = [
296            TAG, 13, 0x46, 0x52, 0x41, 0x1A, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
297        ];
298        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
299        assert_eq!(d.entries[0].country_region_id, 6);
300    }
301
302    #[test]
303    fn parse_rejects_wrong_tag() {
304        let err = LocalTimeOffsetDescriptor::parse(&[
305            0x59, 13, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
306        ])
307        .unwrap_err();
308        assert!(matches!(err, Error::InvalidDescriptor { tag: 0x59, .. }));
309    }
310
311    #[test]
312    fn parse_rejects_length_not_multiple_of_13() {
313        let bytes = [
314            TAG, 14, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
315            0xFF,
316        ];
317        let err = LocalTimeOffsetDescriptor::parse(&bytes).unwrap_err();
318        assert!(matches!(err, Error::InvalidDescriptor { tag: TAG, .. }));
319    }
320
321    #[test]
322    fn parse_ignores_reserved_bit_not_set() {
323        // Reserved bit clear must be ignored, not rejected (EN 300 468 §5.1).
324        let bytes = [
325            TAG, 13, 0x46, 0x52, 0x41, 0x00, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
326        ];
327        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
328        assert_eq!(d.entries.len(), 1);
329        assert!(!d.entries[0].local_time_offset_negative);
330    }
331
332    #[test]
333    fn serialize_round_trip() {
334        let d = LocalTimeOffsetDescriptor {
335            entries: vec![LocalTimeOffsetEntry {
336                country_code: LangCode([0x46, 0x52, 0x41]),
337                country_region_id: 0,
338                local_time_offset_negative: false,
339                local_time_offset_bcd: 0x0100,
340                time_of_change_raw: [0xAB, 0xCD, 0xEF, 0x12, 0x34],
341                next_time_offset_bcd: 0x0200,
342            }],
343        };
344        let mut buf = vec![0u8; d.serialized_len()];
345        d.serialize_into(&mut buf).unwrap();
346        let re = LocalTimeOffsetDescriptor::parse(&buf).unwrap();
347        assert_eq!(d, re);
348    }
349
350    #[test]
351    fn empty_descriptor_valid() {
352        let bytes = [TAG, 0];
353        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
354        assert!(d.entries.is_empty());
355    }
356}