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;