Skip to main content

bacnet_types/
calendar.rs

1//! Calendar arithmetic and calendar-entry matching.
2//!
3//! One implementation of the date rules the Calendar object (Clause 12.9) and
4//! the Schedule object (Clauses 12.24.4, 12.24.6 and 12.24.8) evaluate against
5//! the device's local date:
6//!
7//! - A Date used as a pattern (Clause 20.2.12) is checked octet by octet. An
8//!   unspecified octet (`0xFF`) matches anything; month 13 and 14 mean odd and
9//!   even months; day 32, 33 and 34 mean the last day, odd days and even days.
10//!   Every specified octet must match.
11//! - A `BACnetDateRange` (Clause 21) holds two dates, each either a specific
12//!   date or wholly unspecified. The Clause 12 note on unspecified dates makes
13//!   an unspecified start or end leave that side of the range open, so both
14//!   unspecified covers every date.
15//! - A `BACnetWeekNDay` (Clause 21) matches month, week-of-month and weekday
16//!   independently, `0xFF` matching any. Weeks 1 to 5 are days 1-7, 8-14,
17//!   15-21, 22-28 and 29-31; week 6 is the last seven days of the month, and 7,
18//!   8 and 9 are the seven days before the last 7, 14 and 21.
19//!
20//! The day being matched is a [`SpecificDate`]: a real day whose weekday is
21//! computed from year, month and day, so a weekday octet that disagrees with
22//! the rest of a received date (a local matter under Clause 20.2.12) never
23//! changes the answer.
24
25use crate::constructed::{BACnetCalendarEntry, BACnetDateRange, BACnetWeekNDay};
26use crate::primitives::{Date, Time};
27
28const ANY: u8 = 0xFF;
29
30/// Whether `year` is a Gregorian leap year.
31pub fn is_leap_year(year: u16) -> bool {
32    year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
33}
34
35/// The number of days in `month` (1 to 12) of `year`, or 0 for any other month.
36pub fn days_in_month(year: u16, month: u8) -> u8 {
37    match month {
38        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
39        4 | 6 | 9 | 11 => 30,
40        2 if is_leap_year(year) => 29,
41        2 => 28,
42        _ => 0,
43    }
44}
45
46/// One real day of the Gregorian calendar within the years a BACnet Date can
47/// carry (1900 to 2154).
48///
49/// Ordering is chronological.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
51pub struct SpecificDate {
52    year: u16,
53    month: u8,
54    day: u8,
55}
56
57impl SpecificDate {
58    /// The day `year`-`month`-`day`, or `None` when no such day exists or the
59    /// year is outside 1900 to 2154.
60    pub fn new(year: u16, month: u8, day: u8) -> Option<Self> {
61        let in_range =
62            (1900..=2154).contains(&year) && (1..=days_in_month(year, month)).contains(&day);
63        in_range.then_some(Self { year, month, day })
64    }
65
66    /// The day a Date names, or `None` unless its year, month and day are all
67    /// specified and name a real day (no special month or day values).
68    ///
69    /// The weekday octet is ignored; [`weekday`](Self::weekday) computes it.
70    pub fn from_date(date: &Date) -> Option<Self> {
71        if date.year == ANY {
72            return None;
73        }
74        Self::new(1900 + u16::from(date.year), date.month, date.day)
75    }
76
77    /// The year, 1900 to 2154.
78    pub fn year(self) -> u16 {
79        self.year
80    }
81
82    /// The month, 1 (January) to 12.
83    pub fn month(self) -> u8 {
84        self.month
85    }
86
87    /// The day of the month, from 1.
88    pub fn day(self) -> u8 {
89        self.day
90    }
91
92    /// The number of days in this day's month.
93    pub fn days_in_month(self) -> u8 {
94        days_in_month(self.year, self.month)
95    }
96
97    /// The day of the week, 1 (Monday) to 7 (Sunday), as BACnet numbers it.
98    pub fn weekday(self) -> u8 {
99        // 1970-01-01 was a Thursday.
100        (self.days_since_1970() + 3).rem_euclid(7) as u8 + 1
101    }
102
103    /// Days from 1970-01-01 to this day, negative before it: a day number
104    /// that runs on across month and year ends.
105    pub fn days_since_1970(self) -> i64 {
106        // The civil-from-days inverse.
107        let year = i64::from(self.year) - i64::from(self.month <= 2);
108        let era = year.div_euclid(400);
109        let year_of_era = year - era * 400;
110        let month = i64::from(self.month);
111        let month_prime = month + if month > 2 { -3 } else { 9 };
112        let day_of_year = (153 * month_prime + 2) / 5 + i64::from(self.day) - 1;
113        let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
114        era * 146_097 + day_of_era - 719_468
115    }
116
117    /// This day as a fully specified BACnet Date, weekday included.
118    pub fn to_date(self) -> Date {
119        Date {
120            year: (self.year - 1900) as u8,
121            month: self.month,
122            day: self.day,
123            day_of_week: self.weekday(),
124        }
125    }
126}
127
128/// Whether a month octet (a month, 13 for odd, 14 for even, or `0xFF`) admits
129/// `month`.
130fn month_matches(octet: u8, month: u8) -> bool {
131    match octet {
132        ANY => true,
133        13 => !month.is_multiple_of(2),
134        14 => month.is_multiple_of(2),
135        specific => specific == month,
136    }
137}
138
139/// Whether a weekday octet (1 to 7, or `0xFF`) admits `day`'s weekday.
140fn weekday_matches(octet: u8, day: SpecificDate) -> bool {
141    octet == ANY || octet == day.weekday()
142}
143
144/// Whether `octet` is a weekday (1 to 7) or unspecified.
145fn valid_weekday(octet: u8) -> bool {
146    octet == ANY || (1..=7).contains(&octet)
147}
148
149/// Whether `octet` is a month (1 to 12), odd or even months, or unspecified.
150fn valid_month(octet: u8) -> bool {
151    octet == ANY || (1..=14).contains(&octet)
152}
153
154impl Date {
155    /// Whether every octet is unspecified, the date that matches any date.
156    pub fn is_unspecified(&self) -> bool {
157        self.encode() == [ANY; 4]
158    }
159
160    /// Whether every octet holds a value Clause 20.2.12 defines for a date
161    /// pattern: any year; month 1 to 14; day 1 to 34; weekday 1 to 7; each of
162    /// them, or all, unspecified.
163    pub fn is_valid_pattern(&self) -> bool {
164        valid_month(self.month)
165            && (self.day == ANY || (1..=34).contains(&self.day))
166            && valid_weekday(self.day_of_week)
167    }
168
169    /// Whether this date, read as a pattern, matches `day`: each specified
170    /// octet must agree, including the odd, even and last-day values.
171    pub fn matches(&self, day: SpecificDate) -> bool {
172        let year = self.year == ANY || 1900 + u16::from(self.year) == day.year();
173        let day_of_month = match self.day {
174            ANY => true,
175            32 => day.day() == day.days_in_month(),
176            33 => !day.day().is_multiple_of(2),
177            34 => day.day().is_multiple_of(2),
178            specific => specific == day.day(),
179        };
180        year && month_matches(self.month, day.month())
181            && day_of_month
182            && weekday_matches(self.day_of_week, day)
183    }
184}
185
186/// One end of a date range: open, a day, or neither (not a valid endpoint).
187enum Endpoint {
188    Open,
189    At(SpecificDate),
190    Invalid,
191}
192
193impl Endpoint {
194    fn of(date: &Date) -> Self {
195        if date.is_unspecified() {
196            return Self::Open;
197        }
198        match SpecificDate::from_date(date) {
199            Some(day) if valid_weekday(date.day_of_week) => Self::At(day),
200            _ => Self::Invalid,
201        }
202    }
203}
204
205impl BACnetDateRange {
206    /// Whether both dates are allowed endpoints: wholly unspecified, or a
207    /// specific date (year, month and day naming a real day, with no special
208    /// values). The weekday octet may be unspecified or disagree with the
209    /// date, since it never changes which day the endpoint names.
210    pub fn is_valid(&self) -> bool {
211        !matches!(Endpoint::of(&self.start_date), Endpoint::Invalid)
212            && !matches!(Endpoint::of(&self.end_date), Endpoint::Invalid)
213    }
214
215    /// Whether `day` falls within the range, both ends included; an
216    /// unspecified end is open. A range with an invalid endpoint (see
217    /// [`is_valid`](Self::is_valid)) contains no day, and so does one whose
218    /// start is after its end.
219    pub fn contains(&self, day: SpecificDate) -> bool {
220        let after_start = match Endpoint::of(&self.start_date) {
221            Endpoint::Open => true,
222            Endpoint::At(start) => start <= day,
223            Endpoint::Invalid => return false,
224        };
225        let before_end = match Endpoint::of(&self.end_date) {
226            Endpoint::Open => true,
227            Endpoint::At(end) => day <= end,
228            Endpoint::Invalid => return false,
229        };
230        after_start && before_end
231    }
232}
233
234impl BACnetWeekNDay {
235    /// Whether each octet is in its Clause 21 range: month 1 to 14,
236    /// week-of-month 1 to 9, weekday 1 to 7, each or all unspecified.
237    pub fn is_valid(&self) -> bool {
238        valid_month(self.month)
239            && (self.week_of_month == ANY || (1..=9).contains(&self.week_of_month))
240            && valid_weekday(self.day_of_week)
241    }
242
243    /// Whether `day` matches month, week-of-month and weekday.
244    pub fn matches(&self, day: SpecificDate) -> bool {
245        let date = day.day();
246        let last = day.days_in_month();
247        let week = match self.week_of_month {
248            ANY => true,
249            week @ 1..=5 => (date - 1) / 7 + 1 == week,
250            // Week 6 ends on the last day; 7, 8 and 9 end 7, 14 and 21 days
251            // earlier. Each spans the seven days ending there.
252            week @ 6..=9 => {
253                let end = i16::from(last) - i16::from(week - 6) * 7;
254                (end - 6..=end).contains(&i16::from(date))
255            }
256            _ => false,
257        };
258        month_matches(self.month, day.month()) && week && weekday_matches(self.day_of_week, day)
259    }
260}
261
262impl BACnetCalendarEntry {
263    /// Whether the entry's values are all in range for its choice: a date
264    /// pattern ([`Date::is_valid_pattern`]), a date range
265    /// ([`BACnetDateRange::is_valid`]) or a week-n-day
266    /// ([`BACnetWeekNDay::is_valid`]).
267    pub fn is_valid(&self) -> bool {
268        match self {
269            Self::Date(date) => date.is_valid_pattern(),
270            Self::DateRange(range) => range.is_valid(),
271            Self::WeekNDay(week_n_day) => week_n_day.is_valid(),
272        }
273    }
274
275    /// Whether `day` matches the entry.
276    pub fn matches(&self, day: SpecificDate) -> bool {
277        match self {
278            Self::Date(date) => date.matches(day),
279            Self::DateRange(range) => range.contains(day),
280            Self::WeekNDay(week_n_day) => week_n_day.matches(day),
281        }
282    }
283}
284
285impl Time {
286    /// Whether every field is specified and in range: hour 0 to 23, minute
287    /// and second 0 to 59, hundredths 0 to 99.
288    pub fn is_specific(&self) -> bool {
289        self.hour <= 23 && self.minute <= 59 && self.second <= 59 && self.hundredths <= 99
290    }
291}
292
293#[cfg(test)]
294mod tests;