Skip to main content

ocpp_service/
tz.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! IANA time zones from the system zoneinfo database (TZif files, RFC 8536),
5//! including the POSIX TZ footer rule used for instants after the last
6//! listed transition. Needed because OCPP 2.1 tariff conditions are expressed
7//! in station local time, following daylight saving time (I12.FR.37).
8
9use std::collections::HashMap;
10use std::path::Path;
11use std::sync::{Arc, Mutex};
12
13#[derive(Debug, Clone, PartialEq)]
14pub struct Zone {
15    pub name: String,
16    transitions: Vec<i64>,
17    /// Index into `types` for each transition.
18    indices: Vec<u8>,
19    /// UTC offset in seconds of each local time type.
20    types: Vec<i32>,
21    footer: Option<Rule>,
22}
23
24/// POSIX TZ rule, e.g. `CET-1CEST,M3.5.0,M10.5.0/3`.
25#[derive(Debug, Clone, PartialEq)]
26struct Rule {
27    std_offset: i32,
28    dst: Option<DstRule>,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32struct DstRule {
33    offset: i32,
34    start: (DateRule, i32),
35    end: (DateRule, i32),
36}
37
38#[derive(Debug, Clone, Copy, PartialEq)]
39enum DateRule {
40    /// Mm.w.d: day `d` (0 = Sunday) of week `w` (5 = last) of month `m`.
41    MonthWeekDay(u32, u32, u32),
42    /// Jn: Julian day 1–365, February 29 never counted.
43    Julian1(u32),
44    /// n: zero-based day of year 0–365.
45    Julian0(u32),
46}
47
48impl Zone {
49    pub fn utc() -> Zone {
50        Zone {
51            name: "UTC".into(),
52            transitions: vec![],
53            indices: vec![],
54            types: vec![0],
55            footer: None,
56        }
57    }
58
59    pub fn load(dir: &Path, name: &str) -> Result<Zone, String> {
60        if name == "UTC" || name == "Etc/UTC" {
61            return Ok(Zone::utc());
62        }
63        let valid = !name.is_empty()
64            && !name.contains("..")
65            && !name.starts_with('/')
66            && name
67                .chars()
68                .all(|c| c.is_ascii_alphanumeric() || "/_+-".contains(c));
69        if !valid {
70            return Err(format!("invalid time zone name {name:?}"));
71        }
72        let bytes = std::fs::read(dir.join(name)).map_err(|e| format!("time zone {name}: {e}"))?;
73        Zone::parse(name, &bytes)
74    }
75
76    pub fn parse(name: &str, b: &[u8]) -> Result<Zone, String> {
77        let err = |m: &str| format!("time zone {name}: {m}");
78        let header = |b: &[u8]| -> Result<(u8, [usize; 6]), String> {
79            if b.len() < 44 || &b[..4] != b"TZif" {
80                return Err(err("not a TZif file"));
81            }
82            let mut counts = [0usize; 6];
83            for (i, c) in counts.iter_mut().enumerate() {
84                *c = u32::from_be_bytes(b[20 + i * 4..24 + i * 4].try_into().unwrap()) as usize;
85            }
86            Ok((b[4], counts))
87        };
88        let (version, c) = header(b)?;
89        let [isut, isstd, leap, time, typ, chars] = c;
90        let v1_len = 44 + time * 5 + typ * 6 + chars + leap * 8 + isstd + isut;
91        let (data, time_size, counts) = if version >= b'2' {
92            let rest = b.get(v1_len..).ok_or_else(|| err("truncated"))?;
93            let (_, c2) = header(rest)?;
94            (&rest[44..], 8, c2)
95        } else {
96            (&b[44..], 4, c)
97        };
98        let [isut, isstd, leap, time, typ, chars] = counts;
99        let need =
100            time * time_size + time + typ * 6 + chars + leap * (time_size + 4) + isstd + isut;
101        if data.len() < need {
102            return Err(err("truncated data block"));
103        }
104        let mut pos = 0;
105        let mut transitions = Vec::with_capacity(time);
106        for _ in 0..time {
107            let t = if time_size == 8 {
108                i64::from_be_bytes(data[pos..pos + 8].try_into().unwrap())
109            } else {
110                i32::from_be_bytes(data[pos..pos + 4].try_into().unwrap()) as i64
111            };
112            transitions.push(t);
113            pos += time_size;
114        }
115        let indices = data[pos..pos + time].to_vec();
116        pos += time;
117        let mut types = Vec::with_capacity(typ);
118        for _ in 0..typ {
119            types.push(i32::from_be_bytes(data[pos..pos + 4].try_into().unwrap()));
120            pos += 6;
121        }
122        if indices.iter().any(|&i| i as usize >= types.len()) || types.is_empty() {
123            return Err(err("invalid type index"));
124        }
125        pos += chars + leap * (time_size + 4) + isstd + isut;
126        let footer = if version >= b'2' {
127            let rest = &data[pos..];
128            let text = std::str::from_utf8(rest).unwrap_or("").trim_matches('\n');
129            if text.is_empty() {
130                None
131            } else {
132                Some(parse_rule(text).ok_or_else(|| err("invalid footer rule"))?)
133            }
134        } else {
135            None
136        };
137        Ok(Zone {
138            name: name.into(),
139            transitions,
140            indices,
141            types,
142            footer,
143        })
144    }
145
146    /// Parses a POSIX TZ string directly (e.g. for tests or fixed rules).
147    pub fn from_posix(s: &str) -> Option<Zone> {
148        let rule = parse_rule(s)?;
149        Some(Zone {
150            name: s.into(),
151            transitions: vec![],
152            indices: vec![],
153            types: vec![rule.std_offset],
154            footer: Some(rule),
155        })
156    }
157
158    /// UTC offset (seconds) at the UTC instant `t` (Unix seconds).
159    pub fn offset_at(&self, t: i64) -> i32 {
160        match self.transitions.partition_point(|&x| x <= t) {
161            0 if !self.transitions.is_empty() => self.types[0],
162            i if i == self.transitions.len() => match &self.footer {
163                Some(rule) => rule.offset_at(t),
164                None => self
165                    .indices
166                    .last()
167                    .map(|&k| self.types[k as usize])
168                    .unwrap_or(self.types[0]),
169            },
170            i => self.types[self.indices[i - 1] as usize],
171        }
172    }
173
174    /// First offset change strictly after `t`, if any within ~2 years.
175    pub fn next_transition_after(&self, t: i64) -> Option<i64> {
176        let i = self.transitions.partition_point(|&x| x <= t);
177        if i < self.transitions.len() {
178            return Some(self.transitions[i]);
179        }
180        let rule = self.footer.as_ref()?;
181        let dst = rule.dst.as_ref()?;
182        let (year, ..) = ocpp_core::DateTime::from_unix_seconds(t).to_utc_fields();
183        let mut candidates: Vec<i64> = Vec::new();
184        for y in year - 1..=year + 2 {
185            candidates
186                .push(dst.start.0.local_seconds(y) + dst.start.1 as i64 - rule.std_offset as i64);
187            candidates.push(dst.end.0.local_seconds(y) + dst.end.1 as i64 - dst.offset as i64);
188        }
189        candidates.into_iter().filter(|&c| c > t).min()
190    }
191}
192
193impl Rule {
194    fn offset_at(&self, t: i64) -> i32 {
195        let Some(dst) = &self.dst else {
196            return self.std_offset;
197        };
198        let (year, ..) =
199            ocpp_core::DateTime::from_unix_seconds(t + self.std_offset as i64).to_utc_fields();
200        let start = dst.start.0.local_seconds(year) + dst.start.1 as i64 - self.std_offset as i64;
201        let end = dst.end.0.local_seconds(year) + dst.end.1 as i64 - dst.offset as i64;
202        let in_dst = if start < end {
203            t >= start && t < end
204        } else {
205            !(t >= end && t < start)
206        };
207        if in_dst { dst.offset } else { self.std_offset }
208    }
209}
210
211impl DateRule {
212    /// Local midnight (as seconds since the Unix epoch, local clock) of the rule's day in `year`.
213    fn local_seconds(self, year: i64) -> i64 {
214        let day = |m: u32, d: u32| {
215            ocpp_core::DateTime::from_utc_fields(year, m, d, 0, 0, 0, 0)
216                .map(|t| t.unix_seconds())
217                .unwrap_or(0)
218        };
219        match self {
220            DateRule::MonthWeekDay(m, w, d) => {
221                let first = day(m, 1);
222                let first_wd = ((first.div_euclid(86_400) + 4).rem_euclid(7)) as u32; // 0 = Sunday
223                let mut dom = 1 + (d + 7 - first_wd) % 7 + (w.saturating_sub(1)) * 7;
224                while dom > days_in_month(year, m) {
225                    dom -= 7;
226                }
227                first + (dom as i64 - 1) * 86_400
228            }
229            DateRule::Julian1(n) => {
230                let leap = leap_extra(year) > 0;
231                let n = if leap && n >= 60 { n } else { n - 1 };
232                day(1, 1) + n as i64 * 86_400
233            }
234            DateRule::Julian0(n) => day(1, 1) + n as i64 * 86_400,
235        }
236    }
237}
238
239fn days_in_month(year: i64, m: u32) -> u32 {
240    match m {
241        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
242        4 | 6 | 9 | 11 => 30,
243        _ if leap_extra(year) > 0 => 29,
244        _ => 28,
245    }
246}
247
248fn leap_extra(year: i64) -> i64 {
249    if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
250        86_400
251    } else {
252        0
253    }
254}
255
256fn parse_rule(s: &str) -> Option<Rule> {
257    let mut p = Parser {
258        s: s.as_bytes(),
259        i: 0,
260    };
261    p.name()?;
262    let std_offset = -p.offset()?;
263    if p.done() {
264        return Some(Rule {
265            std_offset,
266            dst: None,
267        });
268    }
269    p.name()?;
270    let dst_offset = if p.peek().is_some_and(|c| c != b',') {
271        -p.offset()?
272    } else {
273        std_offset + 3600
274    };
275    if p.done() {
276        // No rule given: POSIX default (US rules), rarely used in TZif footers.
277        return Some(Rule {
278            std_offset,
279            dst: Some(DstRule {
280                offset: dst_offset,
281                start: (DateRule::MonthWeekDay(3, 2, 0), 7200),
282                end: (DateRule::MonthWeekDay(11, 1, 0), 7200),
283            }),
284        });
285    }
286    p.expect(b',')?;
287    let start = p.date_time()?;
288    p.expect(b',')?;
289    let end = p.date_time()?;
290    p.done().then_some(Rule {
291        std_offset,
292        dst: Some(DstRule {
293            offset: dst_offset,
294            start,
295            end,
296        }),
297    })
298}
299
300struct Parser<'a> {
301    s: &'a [u8],
302    i: usize,
303}
304
305impl Parser<'_> {
306    fn peek(&self) -> Option<u8> {
307        self.s.get(self.i).copied()
308    }
309    fn done(&self) -> bool {
310        self.i >= self.s.len()
311    }
312    fn expect(&mut self, c: u8) -> Option<()> {
313        (self.peek()? == c).then(|| self.i += 1)
314    }
315    fn name(&mut self) -> Option<()> {
316        if self.peek()? == b'<' {
317            let end = self.s[self.i..].iter().position(|&c| c == b'>')?;
318            self.i += end + 1;
319        } else {
320            let start = self.i;
321            while self.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
322                self.i += 1;
323            }
324            if self.i - start < 3 {
325                return None;
326            }
327        }
328        Some(())
329    }
330    fn number(&mut self) -> Option<i64> {
331        let start = self.i;
332        while self.peek().is_some_and(|c| c.is_ascii_digit()) {
333            self.i += 1;
334        }
335        std::str::from_utf8(&self.s[start..self.i])
336            .ok()?
337            .parse()
338            .ok()
339    }
340    /// [+-]hh[:mm[:ss]] in seconds.
341    fn offset(&mut self) -> Option<i32> {
342        let sign = match self.peek()? {
343            b'-' => {
344                self.i += 1;
345                -1
346            }
347            b'+' => {
348                self.i += 1;
349                1
350            }
351            _ => 1,
352        };
353        let mut secs = self.number()? * 3600;
354        if self.peek() == Some(b':') {
355            self.i += 1;
356            secs += self.number()? * 60;
357            if self.peek() == Some(b':') {
358                self.i += 1;
359                secs += self.number()?;
360            }
361        }
362        Some(sign * secs as i32)
363    }
364    fn date_time(&mut self) -> Option<(DateRule, i32)> {
365        let rule = match self.peek()? {
366            b'M' => {
367                self.i += 1;
368                let m = self.number()? as u32;
369                self.expect(b'.')?;
370                let w = self.number()? as u32;
371                self.expect(b'.')?;
372                let d = self.number()? as u32;
373                ((1..=12).contains(&m) && (1..=5).contains(&w) && d <= 6)
374                    .then_some(DateRule::MonthWeekDay(m, w, d))?
375            }
376            b'J' => {
377                self.i += 1;
378                DateRule::Julian1(self.number()? as u32)
379            }
380            _ => DateRule::Julian0(self.number()? as u32),
381        };
382        let time = if self.peek() == Some(b'/') {
383            self.i += 1;
384            self.offset()?
385        } else {
386            7200
387        };
388        Some((rule, time))
389    }
390}
391
392/// Local calendar time.
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub struct Local {
395    pub year: i64,
396    pub month: u32,
397    pub day: u32,
398    /// Seconds since local midnight.
399    pub second_of_day: u32,
400    /// 0 = Monday … 6 = Sunday.
401    pub weekday: u32,
402}
403
404impl Zone {
405    pub fn local(&self, t: i64) -> Local {
406        let lt = t + self.offset_at(t) as i64;
407        let (year, month, day, h, m, s, _) =
408            ocpp_core::DateTime::from_unix_seconds(lt).to_utc_fields();
409        Local {
410            year,
411            month,
412            day,
413            second_of_day: h * 3600 + m * 60 + s,
414            weekday: ((lt.div_euclid(86_400) + 3).rem_euclid(7)) as u32,
415        }
416    }
417}
418
419/// Loaded zones, shared by all connections.
420#[derive(Default)]
421pub struct Zones {
422    cache: Mutex<HashMap<String, Arc<Zone>>>,
423}
424
425impl Zones {
426    /// Returns the zone, falling back to UTC (with a warning) when it cannot be loaded.
427    pub fn get(&self, dir: &Path, name: &str) -> Arc<Zone> {
428        if let Some(z) = self
429            .cache
430            .lock()
431            .unwrap_or_else(|e| e.into_inner())
432            .get(name)
433        {
434            return z.clone();
435        }
436        let zone = Arc::new(Zone::load(dir, name).unwrap_or_else(|e| {
437            tracing::warn!(error = %e, "using UTC instead");
438            Zone::utc()
439        }));
440        self.cache
441            .lock()
442            .unwrap_or_else(|e| e.into_inner())
443            .insert(name.to_owned(), zone.clone());
444        zone
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn t(s: &str) -> i64 {
453        ocpp_core::DateTime::parse(s).unwrap().unix_seconds()
454    }
455
456    #[test]
457    fn posix_rules() {
458        let paris = Zone::from_posix("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
459        // 2024: DST from 2024-03-31 01:00Z to 2024-10-27 01:00Z.
460        assert_eq!(paris.offset_at(t("2024-03-31T00:59:59Z")), 3600);
461        assert_eq!(paris.offset_at(t("2024-03-31T01:00:00Z")), 7200);
462        assert_eq!(paris.offset_at(t("2024-10-27T00:59:59Z")), 7200);
463        assert_eq!(paris.offset_at(t("2024-10-27T01:00:00Z")), 3600);
464        assert_eq!(
465            paris.next_transition_after(t("2024-06-01T00:00:00Z")),
466            Some(t("2024-10-27T01:00:00Z"))
467        );
468        let ny = Zone::from_posix("EST5EDT,M3.2.0,M11.1.0").unwrap();
469        assert_eq!(ny.offset_at(t("2024-03-10T06:59:59Z")), -5 * 3600);
470        assert_eq!(ny.offset_at(t("2024-03-10T07:00:00Z")), -4 * 3600);
471        // Southern hemisphere: DST across the new year.
472        let syd = Zone::from_posix("AEST-10AEDT,M10.1.0,M4.1.0/3").unwrap();
473        assert_eq!(syd.offset_at(t("2024-01-15T00:00:00Z")), 11 * 3600);
474        assert_eq!(syd.offset_at(t("2024-06-15T00:00:00Z")), 10 * 3600);
475        assert_eq!(
476            Zone::from_posix("<+0330>-3:30").unwrap().offset_at(0),
477            3 * 3600 + 1800
478        );
479        let l = paris.local(t("2024-07-01T22:30:00Z"));
480        assert_eq!(
481            (l.month, l.day, l.second_of_day, l.weekday),
482            (7, 2, 1800, 1)
483        ); // Tuesday 00:30
484    }
485
486    #[test]
487    fn system_zoneinfo() {
488        let dir = Path::new("/usr/share/zoneinfo");
489        if !dir.join("Europe/Paris").exists() {
490            return;
491        }
492        let paris = Zone::load(dir, "Europe/Paris").unwrap();
493        assert_eq!(paris.offset_at(t("2024-01-15T12:00:00Z")), 3600);
494        assert_eq!(paris.offset_at(t("2024-07-15T12:00:00Z")), 7200);
495        // Far future: footer rule.
496        assert_eq!(paris.offset_at(t("2100-07-15T12:00:00Z")), 7200);
497        assert_eq!(paris.offset_at(t("1999-01-01T00:00:00Z")), 3600);
498        assert_eq!(paris.offset_at(t("1999-08-01T00:00:00Z")), 7200);
499        assert!(Zone::load(dir, "../etc/passwd").is_err());
500    }
501}