Skip to main content

bacnet_objects/
clock.rs

1//! Dependency-neutral clock data exposed to BACnet objects.
2
3use bacnet_types::primitives::{Date, Time};
4
5/// One coherent sample of the Device clock.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct ClockFrame {
8    /// Device local date.
9    pub local_date: Date,
10    /// Device local time.
11    pub local_time: Time,
12    /// Signed minutes west of UTC.
13    pub utc_offset: i16,
14    /// Whether daylight-saving time is currently applied.
15    pub daylight_savings_status: bool,
16}
17
18impl ClockFrame {
19    /// Return the BACnetDaysOfWeek bit for this frame, or `None` for an
20    /// unavailable/invalid day-of-week value.
21    pub fn day_of_week_bit(self) -> Option<u8> {
22        (1..=7)
23            .contains(&self.local_date.day_of_week)
24            .then(|| 1 << (self.local_date.day_of_week - 1))
25    }
26
27    /// Whether this frame is a fully specified, internally consistent Device
28    /// DateTime suitable for timestamping notifications.
29    pub fn is_valid_actual_datetime(self) -> bool {
30        let Some(year) = self.local_date.actual_year() else {
31            return false;
32        };
33        if !(1..=12).contains(&self.local_date.month)
34            || !(1..=7).contains(&self.local_date.day_of_week)
35        {
36            return false;
37        }
38
39        let max_day = days_in_month(year, self.local_date.month);
40        if self.local_date.day == 0 || self.local_date.day > max_day {
41            return false;
42        }
43
44        let days = days_from_civil(
45            i64::from(year),
46            i64::from(self.local_date.month),
47            i64::from(self.local_date.day),
48        );
49        let expected_day_of_week = (days + 3).rem_euclid(7) as u8 + 1;
50        if self.local_date.day_of_week != expected_day_of_week {
51            return false;
52        }
53
54        self.local_time.hour <= 23
55            && self.local_time.minute <= 59
56            && self.local_time.second <= 59
57            && self.local_time.hundredths <= 99
58    }
59}
60
61fn days_in_month(year: u16, month: u8) -> u8 {
62    match month {
63        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
64        4 | 6 | 9 | 11 => 30,
65        2 if is_leap_year(year) => 29,
66        2 => 28,
67        _ => 0,
68    }
69}
70
71fn is_leap_year(year: u16) -> bool {
72    year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
73}
74
75fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
76    let year = year - i64::from(month <= 2);
77    let era = year.div_euclid(400);
78    let year_of_era = year - era * 400;
79    let month_prime = month + if month > 2 { -3 } else { 9 };
80    let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
81    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
82    era * 146_097 + day_of_era - 719_468
83}
84
85/// Synchronous read port for a coherent Device clock sample.
86///
87/// `None` means that no wall-clock frame is available. Implementations must
88/// return all four fields from the same sample.
89pub trait ClockReader: Send + Sync {
90    /// Read one coherent frame, or report that no wall clock is available.
91    fn read_clock(&self) -> Option<ClockFrame>;
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn leap_day_frame() -> ClockFrame {
99        ClockFrame {
100            local_date: Date {
101                year: 124,
102                month: 2,
103                day: 29,
104                day_of_week: 4,
105            },
106            local_time: Time {
107                hour: 23,
108                minute: 59,
109                second: 59,
110                hundredths: 99,
111            },
112            utc_offset: 0,
113            daylight_savings_status: false,
114        }
115    }
116
117    #[test]
118    fn actual_datetime_validation_checks_calendar_weekday_and_time() {
119        let valid = leap_day_frame();
120        assert!(valid.is_valid_actual_datetime());
121
122        for invalid in [
123            ClockFrame {
124                local_date: Date {
125                    day: 30,
126                    ..valid.local_date
127                },
128                ..valid
129            },
130            ClockFrame {
131                local_date: Date {
132                    day_of_week: 5,
133                    ..valid.local_date
134                },
135                ..valid
136            },
137            ClockFrame {
138                local_time: Time {
139                    hour: Time::UNSPECIFIED,
140                    ..valid.local_time
141                },
142                ..valid
143            },
144        ] {
145            assert!(!invalid.is_valid_actual_datetime());
146        }
147    }
148}