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+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        let body = descriptor_body(
146            bytes,
147            TAG,
148            "LocalTimeOffsetDescriptor",
149            "unexpected tag for local_time_offset_descriptor",
150        )?;
151        if body.len() % ENTRY_LEN != 0 {
152            return Err(Error::InvalidDescriptor {
153                tag: TAG,
154                reason: "descriptor_length must be a multiple of 13",
155            });
156        }
157        let mut entries = Vec::with_capacity(body.len() / ENTRY_LEN);
158        let mut offset = 0;
159        while offset < body.len() {
160            let country_code = LangCode([body[offset], body[offset + 1], body[offset + 2]]);
161            let flags = body[offset + 3];
162            let country_region_id = (flags & REGION_ID_MASK) >> 2;
163            let local_time_offset_negative = flags & POLARITY_MASK != 0;
164            let local_time_offset_bcd = u16::from_be_bytes([body[offset + 4], body[offset + 5]]);
165            let mut time_of_change_raw = [0u8; 5];
166            time_of_change_raw.copy_from_slice(&body[offset + 6..offset + 11]);
167            let next_time_offset_bcd = u16::from_be_bytes([body[offset + 11], body[offset + 12]]);
168            entries.push(LocalTimeOffsetEntry {
169                country_code,
170                country_region_id,
171                local_time_offset_negative,
172                local_time_offset_bcd,
173                time_of_change_raw,
174                next_time_offset_bcd,
175            });
176            offset += ENTRY_LEN;
177        }
178        Ok(Self { entries })
179    }
180}
181
182impl Serialize for LocalTimeOffsetDescriptor {
183    type Error = crate::error::Error;
184    fn serialized_len(&self) -> usize {
185        HEADER_LEN + ENTRY_LEN * self.entries.len()
186    }
187
188    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
189        let len = self.serialized_len();
190        if buf.len() < len {
191            return Err(Error::OutputBufferTooSmall {
192                need: len,
193                have: buf.len(),
194            });
195        }
196        buf[0] = TAG;
197        buf[1] = (len - HEADER_LEN) as u8;
198        let mut offset = HEADER_LEN;
199        for entry in &self.entries {
200            buf[offset..offset + 3].copy_from_slice(&entry.country_code.0);
201            let flags = ((entry.country_region_id << 2) & REGION_ID_MASK)
202                | RESERVED_BIT_MASK
203                | if entry.local_time_offset_negative {
204                    POLARITY_MASK
205                } else {
206                    0
207                };
208            buf[offset + 3] = flags;
209            buf[offset + 4..offset + 6].copy_from_slice(&entry.local_time_offset_bcd.to_be_bytes());
210            buf[offset + 6..offset + 11].copy_from_slice(&entry.time_of_change_raw);
211            buf[offset + 11..offset + 13]
212                .copy_from_slice(&entry.next_time_offset_bcd.to_be_bytes());
213            offset += ENTRY_LEN;
214        }
215        Ok(len)
216    }
217}
218impl<'a> crate::traits::DescriptorDef<'a> for LocalTimeOffsetDescriptor {
219    const TAG: u8 = TAG;
220    const NAME: &'static str = "LOCAL_TIME_OFFSET";
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn parse_single_entry() {
229        let bytes = [
230            TAG, 13, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
231        ];
232        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
233        assert_eq!(d.entries.len(), 1);
234        assert_eq!(d.entries[0].country_code, LangCode([0x46, 0x52, 0x41]));
235        assert_eq!(d.entries[0].country_region_id, 0);
236        assert!(!d.entries[0].local_time_offset_negative);
237        assert_eq!(d.entries[0].local_time_offset_bcd, 0x0100);
238        assert_eq!(
239            d.entries[0].time_of_change_raw,
240            [0xAB, 0xCD, 0xEF, 0x12, 0x34]
241        );
242        assert_eq!(d.entries[0].next_time_offset_bcd, 0x0200);
243    }
244
245    #[test]
246    fn parse_multiple_entries_preserves_order() {
247        let bytes = [
248            TAG, 26, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
249            0x47, 0x42, 0x52, 0x06, 0x00, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x01, 0x00,
250        ];
251        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
252        assert_eq!(d.entries.len(), 2);
253        assert_eq!(d.entries[0].country_code, LangCode([0x46, 0x52, 0x41]));
254        assert_eq!(d.entries[1].country_code, LangCode([0x47, 0x42, 0x52]));
255    }
256
257    #[test]
258    fn parse_extracts_polarity_negative() {
259        let bytes = [
260            TAG, 13, 0x46, 0x52, 0x41, 0x03, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
261        ];
262        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
263        assert!(d.entries[0].local_time_offset_negative);
264    }
265
266    #[test]
267    fn parse_extracts_country_region_id() {
268        let bytes = [
269            TAG, 13, 0x46, 0x52, 0x41, 0x1A, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
270        ];
271        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
272        assert_eq!(d.entries[0].country_region_id, 6);
273    }
274
275    #[test]
276    fn parse_rejects_wrong_tag() {
277        let err = LocalTimeOffsetDescriptor::parse(&[
278            0x59, 13, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
279        ])
280        .unwrap_err();
281        assert!(matches!(err, Error::InvalidDescriptor { tag: 0x59, .. }));
282    }
283
284    #[test]
285    fn parse_rejects_length_not_multiple_of_13() {
286        let bytes = [
287            TAG, 14, 0x46, 0x52, 0x41, 0x02, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
288            0xFF,
289        ];
290        let err = LocalTimeOffsetDescriptor::parse(&bytes).unwrap_err();
291        assert!(matches!(err, Error::InvalidDescriptor { tag: TAG, .. }));
292    }
293
294    #[test]
295    fn parse_ignores_reserved_bit_not_set() {
296        // Reserved bit clear must be ignored, not rejected (EN 300 468 §5.1).
297        let bytes = [
298            TAG, 13, 0x46, 0x52, 0x41, 0x00, 0x01, 0x00, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x02, 0x00,
299        ];
300        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
301        assert_eq!(d.entries.len(), 1);
302        assert!(!d.entries[0].local_time_offset_negative);
303    }
304
305    #[test]
306    fn serialize_round_trip() {
307        let d = LocalTimeOffsetDescriptor {
308            entries: vec![LocalTimeOffsetEntry {
309                country_code: LangCode([0x46, 0x52, 0x41]),
310                country_region_id: 0,
311                local_time_offset_negative: false,
312                local_time_offset_bcd: 0x0100,
313                time_of_change_raw: [0xAB, 0xCD, 0xEF, 0x12, 0x34],
314                next_time_offset_bcd: 0x0200,
315            }],
316        };
317        let mut buf = vec![0u8; d.serialized_len()];
318        d.serialize_into(&mut buf).unwrap();
319        let re = LocalTimeOffsetDescriptor::parse(&buf).unwrap();
320        assert_eq!(d, re);
321    }
322
323    #[test]
324    fn empty_descriptor_valid() {
325        let bytes = [TAG, 0];
326        let d = LocalTimeOffsetDescriptor::parse(&bytes).unwrap();
327        assert!(d.entries.is_empty());
328    }
329}