bacnet_objects/notification_class/mod.rs
1//! NotificationClass object: priorities, acknowledgment requirements, and recipients.
2//!
3//! # Recipient-list day/time convention
4//!
5//! `RECIPIENT_LIST` entries are `BACnetDestination` notification destinations.
6//! Their `valid_days` is a [`DaysOfWeek`] (Monday first, Clause 21) and their
7//! `transitions` an [`EventTransitionBits`]. The recipient filters take the
8//! current day as a `DaysOfWeek` flag, which [`local_day_and_time`] derives.
9//! Both bit strings convert to their MSB-first Clause 20.2.10 wire octets
10//! through `to_bacnet`/`from_bacnet`.
11//!
12//! `from_time`/`to_time` are BACnet `Time` values interpreted in the device's
13//! *local* time, derived from the wall clock plus the Device object's
14//! `UTC_Offset` property (signed minutes) at the sender. A window with
15//! `to_time < from_time` (e.g. 22:00–02:00) crosses midnight and is active
16//! outside the `[from, to]` interval; see `time_in_window`.
17//!
18//! # Restarts
19//!
20//! Clause 12.21.8 asks for the Recipient_List to survive a restart. A class
21//! built with [`NotificationClass::with_persistence`] saves a written list in
22//! a [`NotificationClassPersistence`] before serving it, and restores it when
23//! built again; one built with [`NotificationClass::new`] keeps the list in
24//! memory only. A written, saved list wins over the destinations the
25//! application configures with [`add_destination`], which are never saved.
26//! See the `saving` module for when saves run.
27//!
28//! [`add_destination`]: NotificationClass::add_destination
29
30use bacnet_types::bitstring::{DaysOfWeek, EventTransitionBits};
31use bacnet_types::constructed::{BACnetDestination, BACnetRecipient};
32use bacnet_types::enums::{EventState, ObjectType, PropertyIdentifier, Reliability};
33use bacnet_types::error::Error;
34use bacnet_types::primitives::{ObjectIdentifier, PropertyValue, StatusFlags, Time};
35use std::borrow::Cow;
36
37use crate::common::{self, read_common_properties};
38use crate::database::ObjectDatabase;
39use crate::durable::DurableWrites;
40use crate::event::EventTransition;
41use crate::traits::BACnetObject;
42
43mod enrollment_summary;
44mod metadata;
45mod persistence;
46pub(crate) mod recipient_list;
47mod saving;
48#[doc(hidden)]
49pub use enrollment_summary::{
50 resolve_enrollment_summary_class_internal, EnrollmentSummaryClassProjection,
51 EnrollmentSummaryClassProjectionError,
52};
53pub use persistence::{
54 FileNotificationClassPersistence, NotificationClassPersistence, NotificationClassSnapshot,
55};
56pub use recipient_list::MAX_RECIPIENT_LIST_DESTINATIONS;
57
58/// BACnet NotificationClass object.
59///
60/// Stores notification routing configuration: which priorities, acknowledgement
61/// requirements, and recipient destinations apply to event notifications
62/// referencing this class number.
63pub struct NotificationClass {
64 oid: ObjectIdentifier,
65 name: String,
66 description: String,
67 status_flags: StatusFlags,
68 reliability: Reliability,
69 /// The notification class number.
70 pub notification_class: u32,
71 /// Priority: [TO_OFFNORMAL, TO_FAULT, TO_NORMAL]. Default [255, 255, 255].
72 pub priority: [u8; 3],
73 /// Transitions whose notifications require acknowledgment. Default empty.
74 pub ack_required: EventTransitionBits,
75 /// Recipient list, at most [`MAX_RECIPIENT_LIST_DESTINATIONS`] long.
76 recipient_list: Vec<BACnetDestination>,
77 /// Where a written Recipient_List is saved, with persistence.
78 storage: Option<saving::Storage>,
79 /// Recipient_List writes taken, so a staged write can tell whether
80 /// another came between.
81 list_writes: u64,
82 /// A write set Recipient_List, now or before a restart, so storage keeps
83 /// it and configured destinations no longer apply.
84 recipient_list_written: bool,
85}
86
87impl NotificationClass {
88 /// Create a new NotificationClass object. Its Recipient_List is kept in
89 /// memory only; see [`with_persistence`](Self::with_persistence).
90 ///
91 /// The `notification_class` number defaults to the instance number.
92 pub fn new(instance: u32, name: impl Into<String>) -> Result<Self, Error> {
93 let oid = ObjectIdentifier::new(ObjectType::NOTIFICATION_CLASS, instance)?;
94 Ok(Self {
95 oid,
96 name: name.into(),
97 description: String::new(),
98 status_flags: StatusFlags::empty(),
99 reliability: Reliability::NO_FAULT_DETECTED,
100 notification_class: instance,
101 priority: [255, 255, 255],
102 ack_required: EventTransitionBits::empty(),
103 recipient_list: Vec::new(),
104 storage: None,
105 list_writes: 0,
106 recipient_list_written: false,
107 })
108 }
109
110 /// Set the description string.
111 pub fn set_description(&mut self, desc: impl Into<String>) {
112 self.description = desc.into();
113 }
114
115 /// Add a destination to the recipient list.
116 ///
117 /// Refuses, as a network write would, an address recipient whose MAC is
118 /// longer than [`BACnetAddress::MAX_MAC_LEN`] octets (PROPERTY /
119 /// INVALID_DATA_TYPE, #1124) and a destination past the
120 /// [`MAX_RECIPIENT_LIST_DESTINATIONS`] cap (RESOURCES /
121 /// NO_SPACE_TO_WRITE_PROPERTY).
122 ///
123 /// This configures the list the application starts with; it is not
124 /// saved. On a class [`with_persistence`](Self::with_persistence) whose
125 /// storage holds a written Recipient_List
126 /// ([`recipient_list_saved`](Self::recipient_list_saved)), the saved list
127 /// wins: the destination is checked but not added.
128 ///
129 /// [`BACnetAddress::MAX_MAC_LEN`]: bacnet_types::constructed::BACnetAddress::MAX_MAC_LEN
130 pub fn add_destination(&mut self, dest: BACnetDestination) -> Result<(), Error> {
131 recipient_list::check_added(&dest)?;
132 if self.recipient_list_saved() {
133 tracing::debug!(
134 class = %self.oid,
135 "Saved Recipient_List kept over a configured destination"
136 );
137 return Ok(());
138 }
139 if self.recipient_list.len() >= MAX_RECIPIENT_LIST_DESTINATIONS {
140 return Err(recipient_list::no_space_error());
141 }
142 self.recipient_list.push(dest);
143 Ok(())
144 }
145
146 /// The Recipient_List destinations, in list order.
147 pub fn recipient_list(&self) -> &[BACnetDestination] {
148 &self.recipient_list
149 }
150}
151
152impl BACnetObject for NotificationClass {
153 fn object_identifier(&self) -> ObjectIdentifier {
154 self.oid
155 }
156
157 fn object_name(&self) -> &str {
158 &self.name
159 }
160
161 fn read_property(
162 &self,
163 property: PropertyIdentifier,
164 array_index: Option<u32>,
165 ) -> Result<PropertyValue, Error> {
166 // Table 12-24 has no Out_Of_Service (#1064), and Clause 12.21 holds the
167 // OUT_OF_SERVICE flag FALSE.
168 if let Some(result) =
169 read_common_properties!(self, property, array_index, no_out_of_service)
170 {
171 return result;
172 }
173 match property {
174 p if p == PropertyIdentifier::OBJECT_TYPE => Ok(PropertyValue::Enumerated(
175 ObjectType::NOTIFICATION_CLASS.to_raw(),
176 )),
177 p if p == PropertyIdentifier::EVENT_STATE => {
178 Ok(PropertyValue::Enumerated(EventState::NORMAL.to_raw()))
179 }
180 p if p == PropertyIdentifier::NOTIFICATION_CLASS => {
181 Ok(PropertyValue::Unsigned(self.notification_class as u64))
182 }
183 p if p == PropertyIdentifier::PRIORITY => match array_index {
184 Some(0) => Ok(PropertyValue::Unsigned(3)),
185 Some(idx) if (1..=3).contains(&idx) => Ok(PropertyValue::Unsigned(
186 self.priority[(idx - 1) as usize] as u64,
187 )),
188 None => Ok(PropertyValue::List(vec![
189 PropertyValue::Unsigned(self.priority[0] as u64),
190 PropertyValue::Unsigned(self.priority[1] as u64),
191 PropertyValue::Unsigned(self.priority[2] as u64),
192 ])),
193 _ => Err(common::invalid_array_index_error()),
194 },
195 p if p == PropertyIdentifier::ACK_REQUIRED => Ok(PropertyValue::BitString {
196 unused_bits: 5,
197 data: vec![self.ack_required.to_bacnet()],
198 }),
199 p if p == PropertyIdentifier::RECIPIENT_LIST => {
200 // Full ASN.1 framing: BACnetLIST of BACnetDestination — each
201 // entry a 7-element application-tagged SEQUENCE with the
202 // recipient discriminated by context tag (device [0]
203 // primitive / address [1] constructed), Clause 12.21 + 21.
204 let mut buf = bytes::BytesMut::new();
205 bacnet_encoding::constructed::encode_destination_list(
206 &mut buf,
207 &self.recipient_list,
208 )?;
209 Ok(PropertyValue::ApplicationData(buf.to_vec()))
210 }
211 _ => Err(common::unknown_property_error()),
212 }
213 }
214
215 fn write_property(
216 &mut self,
217 property: PropertyIdentifier,
218 array_index: Option<u32>,
219 value: PropertyValue,
220 _priority: Option<u8>,
221 ) -> Result<(), Error> {
222 if property == PropertyIdentifier::NOTIFICATION_CLASS {
223 if let PropertyValue::Unsigned(v) = value {
224 self.notification_class = common::u64_to_u32(v)?;
225 return Ok(());
226 }
227 return Err(common::invalid_data_type_error());
228 }
229 if property == PropertyIdentifier::RECIPIENT_LIST {
230 // Indexed (array-element) access: Recipient_List is a BACnetLIST
231 // (Table 12-24), not an array, so Clause 12.1.5.2 makes ReadRange
232 // the only positional access. The RP/RPM/WP/WPM handlers gate
233 // indexed access to PROPERTY / PROPERTY_IS_NOT_AN_ARRAY
234 // (Clause 15.5.1.3 / 15.9.1.3) via `is_array_property`; mirror
235 // the same classification here for direct object-layer calls.
236 if array_index.is_some() {
237 return Err(common::property_is_not_an_array_error());
238 }
239 return self.write_recipient_list(value);
240 }
241 if let Some(result) = common::write_description(&mut self.description, property, &value) {
242 return result;
243 }
244 Err(crate::common::unhandled_write_error(
245 self.property_metadata().as_ref(),
246 property,
247 array_index,
248 ))
249 }
250
251 fn property_metadata(&self) -> Cow<'_, [crate::property_metadata::PropertyMetadata]> {
252 metadata::for_object(self)
253 }
254
255 fn property_list(&self) -> Cow<'static, [PropertyIdentifier]> {
256 crate::property_metadata::property_list_from_metadata(self.property_metadata().as_ref())
257 }
258
259 fn advance_monotonic_time_internal(&mut self, now: std::time::Duration) -> bool {
260 self.expire_staged_write(now);
261 false
262 }
263
264 fn durable_writes_internal(&mut self) -> Option<&mut dyn DurableWrites> {
265 Some(self)
266 }
267}
268
269/// Convert a `Time` to centiseconds (hundredths of a second since midnight).
270fn time_to_centiseconds(t: &Time) -> u32 {
271 let h = if t.hour == Time::UNSPECIFIED {
272 0
273 } else {
274 t.hour as u32
275 };
276 let m = if t.minute == Time::UNSPECIFIED {
277 0
278 } else {
279 t.minute as u32
280 };
281 let s = if t.second == Time::UNSPECIFIED {
282 0
283 } else {
284 t.second as u32
285 };
286 let cs = if t.hundredths == Time::UNSPECIFIED {
287 0
288 } else {
289 t.hundredths as u32
290 };
291 h * 360_000 + m * 6_000 + s * 100 + cs
292}
293
294/// Check if `current` falls within the `[from, to]` time window.
295///
296/// If either bound has an unspecified hour (0xFF), the window is treated as
297/// "all day". A window whose `to` is earlier than its `from` (e.g.
298/// 22:00–02:00) crosses midnight: it is active from `from` up to midnight and
299/// again from midnight up to `to`, i.e. when `current >= from || current <= to`.
300/// This matches the ASHRAE 135-2020 reading that `To_Time` is the end of the
301/// active period; a wrap-around pair denotes an overnight schedule.
302fn time_in_window(current: &Time, from: &Time, to: &Time) -> bool {
303 if from.hour == Time::UNSPECIFIED || to.hour == Time::UNSPECIFIED {
304 return true;
305 }
306 let cur = time_to_centiseconds(current);
307 let from_cs = time_to_centiseconds(from);
308 let to_cs = time_to_centiseconds(to);
309 if to_cs < from_cs {
310 // Overnight window crossing midnight.
311 cur >= from_cs || cur <= to_cs
312 } else {
313 cur >= from_cs && cur <= to_cs
314 }
315}
316
317/// Derive the local day of the week and time of day for recipient filtering.
318///
319/// `utc_secs` is seconds since the Unix epoch (1970-01-01, a Thursday).
320/// `utc_offset_minutes` is the Device object's `UTC_Offset` (signed minutes
321/// west of UTC); 0 keeps UTC. The day comes back as the single matching
322/// [`DaysOfWeek`] flag: the `+3` makes Monday day 0 because the epoch was a
323/// Thursday. The returned `Time` is the local time of day (hundredths are
324/// supplied by the caller via `subsec`).
325pub fn local_day_and_time(utc_secs: u64, utc_offset_minutes: i32) -> (DaysOfWeek, Time) {
326 // BACnet UTC_Offset is signed minutes west of UTC, so local standard time
327 // subtracts it. Saturation only affects values close to the Unix epoch.
328 let local_secs = utc_secs.saturating_add_signed(-i64::from(utc_offset_minutes) * 60);
329 let dow = (local_secs / 86400 + 3) % 7;
330 let today = DaysOfWeek::from_bits_truncate(1 << dow);
331 let day_secs = (local_secs % 86400) as u32;
332 let current_time = Time {
333 hour: (day_secs / 3600) as u8,
334 minute: ((day_secs % 3600) / 60) as u8,
335 second: (day_secs % 60) as u8,
336 hundredths: 0,
337 };
338 (today, current_time)
339}
340
341/// Resolve the NotificationClass object whose `Notification_Class` property
342/// equals `notification_class`.
343///
344/// Tries a direct OID lookup first (instance == notification_class is the
345/// common case), then falls back to scanning every NotificationClass object.
346/// Returns `None` when no matching class is configured.
347fn find_notification_class(
348 db: &ObjectDatabase,
349 notification_class: u32,
350) -> Option<&dyn BACnetObject> {
351 // Try direct OID lookup first (instance == notification_class is the common case)
352 if let Ok(nc_oid) = ObjectIdentifier::new(ObjectType::NOTIFICATION_CLASS, notification_class) {
353 if let Some(obj) = db.get(&nc_oid) {
354 if matches!(
355 obj.read_property(PropertyIdentifier::NOTIFICATION_CLASS, None),
356 Ok(PropertyValue::Unsigned(n)) if n as u32 == notification_class
357 ) {
358 return Some(obj);
359 }
360 }
361 }
362
363 // Fall back to scanning all NotificationClass objects
364 db.find_by_type(ObjectType::NOTIFICATION_CLASS)
365 .iter()
366 .find_map(|oid| {
367 let obj = db.get(oid)?;
368 match obj.read_property(PropertyIdentifier::NOTIFICATION_CLASS, None) {
369 Ok(PropertyValue::Unsigned(n)) if n as u32 == notification_class => Some(obj),
370 _ => None,
371 }
372 })
373}
374
375/// Resolve the per-transition `Priority` and `Ack_Required` for an event
376/// notification from the referenced NotificationClass.
377///
378/// Per ASHRAE 135-2020 Clause 12.21, the `Priority` and `Ack_Required`
379/// projected into an `EventNotification` come from the NotificationClass
380/// referenced by the event-generating object's `Notification_Class` property,
381/// selected by the transition coordinate (TO_OFFNORMAL, TO_FAULT, or
382/// TO_NORMAL). `Priority` is a 3-element array ordered
383/// `[TO_OFFNORMAL, TO_FAULT, TO_NORMAL]`, indexed by [`EventTransition::index`];
384/// `Ack_Required` is a `BACnetEventTransitionBits` string, tested with
385/// [`EventTransition::bit_mask`].
386///
387/// When no NotificationClass matches the given number (the object's
388/// `Notification_Class` was never configured or points at a missing class),
389/// the spec leaves the projection undefined; we fall back to the BACnet
390/// defaults — `Priority = 255` (lowest) and `Ack_Required = false`.
391///
392/// Those defaults no longer reach the wire on the server's send path. A class
393/// that does not exist also names no recipients, and the sender distributes
394/// nothing when the recipient set is empty, so the fallback survives only for
395/// direct callers of this function.
396pub fn resolve_transition_priority_ack(
397 db: &ObjectDatabase,
398 notification_class: u32,
399 transition: EventTransition,
400) -> (u8, bool) {
401 let Some(nc) = find_notification_class(db, notification_class) else {
402 return (255, false);
403 };
404 let idx = transition.index();
405
406 // PRIORITY is a 3-element array; index 0 is the array length, 1..=3 the
407 // per-transition values. Read the slot directly, defaulting to 255 when
408 // the property is absent or malformed (matches the NotificationClass
409 // default and the missing-class fallback).
410 let priority = nc
411 .read_property(PropertyIdentifier::PRIORITY, Some(idx as u32 + 1))
412 .ok()
413 .and_then(|v| match v {
414 PropertyValue::Unsigned(n) => Some(n as u8),
415 _ => None,
416 })
417 .unwrap_or(255);
418
419 let ack_required = match nc.read_property(PropertyIdentifier::ACK_REQUIRED, None) {
420 Ok(PropertyValue::BitString { data, .. }) => {
421 EventTransitionBits::from_bacnet(&data).intersects(transition.bit_mask())
422 }
423 _ => false,
424 };
425
426 (priority, ack_required)
427}
428
429/// The complete outcome of looking up and selecting Notification Class recipients.
430///
431/// Broadcast is not an outcome. It is represented only by an address inside
432/// [`Matched`](Self::Matched), after that configured destination passes the
433/// day, time, and transition filters. Device recipients are also successful
434/// matches here; resolving them to network addresses is a later routing step.
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub enum RecipientLookupOutcome {
437 /// No Notification Class has the requested class number.
438 NotificationClassMissing,
439 /// The class exists, but its recipient-list property could not be read.
440 RecipientListUnavailable,
441 /// The complete recipient-list value could not be decoded.
442 RecipientListInvalid,
443 /// The class serves more than [`MAX_RECIPIENT_LIST_DESTINATIONS`]
444 /// destinations, which only a custom Notification Class object can do.
445 /// No destination is selected: a transition never reaches only part of a
446 /// list (#1124).
447 RecipientListTooLong,
448 /// The class contains a valid list with zero configured destinations.
449 NoConfiguredDestinations,
450 /// Destinations are configured, but none is eligible for this selection.
451 NoMatchingDestinations,
452 /// Eligible configured destinations and their delivery settings.
453 Matched(Vec<(BACnetRecipient, u32, bool)>),
454}
455
456/// Look up and select recipients for a Notification Class transition.
457///
458/// This is the canonical recipient lookup API and distinguishes configuration
459/// failures from valid empty or ineligible configuration. Selection uses the
460/// configured destination's valid days, local time window, and transitions.
461/// `today` is the current local day, as [`local_day_and_time`] returns it.
462///
463/// A malformed complete list returns
464/// [`RecipientListInvalid`](RecipientLookupOutcome::RecipientListInvalid);
465/// no decodable prefix is selected. A list longer than
466/// [`MAX_RECIPIENT_LIST_DESTINATIONS`] returns
467/// [`RecipientListTooLong`](RecipientLookupOutcome::RecipientListTooLong)
468/// without decoding the destinations past the cap. Every non-matched outcome
469/// is fail-closed and names no implicit destination.
470pub fn lookup_notification_recipients(
471 db: &ObjectDatabase,
472 notification_class: u32,
473 transition: EventTransition,
474 today: DaysOfWeek,
475 current_time: &Time,
476) -> RecipientLookupOutcome {
477 let Some(nc) = find_notification_class(db, notification_class) else {
478 return RecipientLookupOutcome::NotificationClassMissing;
479 };
480 let Ok(recipient_list_value) = nc.read_property(PropertyIdentifier::RECIPIENT_LIST, None)
481 else {
482 return RecipientLookupOutcome::RecipientListUnavailable;
483 };
484 let destinations = match routed_destinations(&recipient_list_value) {
485 Ok(destinations) => destinations,
486 Err(outcome) => return outcome,
487 };
488 if destinations.is_empty() {
489 return RecipientLookupOutcome::NoConfiguredDestinations;
490 }
491
492 let recipients = filter_destinations(destinations, transition, today, current_time);
493 if recipients.is_empty() {
494 RecipientLookupOutcome::NoMatchingDestinations
495 } else {
496 RecipientLookupOutcome::Matched(recipients)
497 }
498}
499
500/// Get notification recipients for a given class number and transition.
501///
502/// This source-compatible wrapper delegates to
503/// [`lookup_notification_recipients`] and returns the matched recipient tuples.
504/// Every other outcome maps to an empty vector, preserving the legacy API.
505pub fn get_notification_recipients(
506 db: &ObjectDatabase,
507 notification_class: u32,
508 transition: EventTransition,
509 today: DaysOfWeek,
510 current_time: &Time,
511) -> Vec<(BACnetRecipient, u32, bool)> {
512 match lookup_notification_recipients(db, notification_class, transition, today, current_time) {
513 RecipientLookupOutcome::Matched(recipients) => recipients,
514 RecipientLookupOutcome::NotificationClassMissing
515 | RecipientLookupOutcome::RecipientListUnavailable
516 | RecipientLookupOutcome::RecipientListInvalid
517 | RecipientLookupOutcome::RecipientListTooLong
518 | RecipientLookupOutcome::NoConfiguredDestinations
519 | RecipientLookupOutcome::NoMatchingDestinations => Vec::new(),
520 }
521}
522
523/// Strict variant of [`get_notification_recipients`] for fail-closed routing.
524///
525/// This source-compatible wrapper delegates to
526/// [`lookup_notification_recipients`]. It preserves `None` for an invalid or
527/// undecodable complete list, and a list past the cap, and `Some([])` for
528/// missing class, property-read failure, configured empty, and no-match
529/// outcomes. Successful matches return `Some(recipients)`.
530pub fn get_notification_recipients_strict(
531 db: &ObjectDatabase,
532 notification_class: u32,
533 transition: EventTransition,
534 today: DaysOfWeek,
535 current_time: &Time,
536) -> Option<Vec<(BACnetRecipient, u32, bool)>> {
537 match lookup_notification_recipients(db, notification_class, transition, today, current_time) {
538 RecipientLookupOutcome::RecipientListInvalid
539 | RecipientLookupOutcome::RecipientListTooLong => None,
540 RecipientLookupOutcome::Matched(recipients) => Some(recipients),
541 RecipientLookupOutcome::NotificationClassMissing
542 | RecipientLookupOutcome::RecipientListUnavailable
543 | RecipientLookupOutcome::NoConfiguredDestinations
544 | RecipientLookupOutcome::NoMatchingDestinations => Some(Vec::new()),
545 }
546}
547
548/// Strictly decode a `RECIPIENT_LIST` property value into its destinations.
549///
550/// Only the framed wire form ([`PropertyValue::ApplicationData`]) is a
551/// Recipient_List value (#1125). The FIRST malformed destination (or trailing
552/// bytes) fails the whole decode: a prefix-tolerant walk would silently route
553/// notifications to only a subset of the configured recipients.
554///
555/// This applies no cap beyond the codec's own item limit. GetEnrollmentSummary
556/// reads membership from the list as configured; routing goes through
557/// [`routed_destinations`], which holds every class to
558/// [`MAX_RECIPIENT_LIST_DESTINATIONS`].
559pub(super) fn decode_destination_list_pv(
560 value: &PropertyValue,
561) -> Result<Vec<BACnetDestination>, Error> {
562 match value {
563 PropertyValue::ApplicationData(bytes) => {
564 bacnet_encoding::constructed::decode_destination_list(bytes)
565 }
566 _ => Err(Error::decoding(
567 0,
568 "Recipient_List: expected framed application data",
569 )),
570 }
571}
572
573/// Decode a `RECIPIENT_LIST` value for routing: the framed form only, at most
574/// [`MAX_RECIPIENT_LIST_DESTINATIONS`] destinations, all or nothing (#1124).
575/// The error is the lookup outcome that names why nothing is routed.
576fn routed_destinations(
577 value: &PropertyValue,
578) -> Result<Vec<BACnetDestination>, RecipientLookupOutcome> {
579 let PropertyValue::ApplicationData(bytes) = value else {
580 return Err(RecipientLookupOutcome::RecipientListInvalid);
581 };
582 recipient_list::decode_capped(bytes).map_err(|error| match error {
583 recipient_list::CappedListError::Malformed => RecipientLookupOutcome::RecipientListInvalid,
584 recipient_list::CappedListError::PastTheCap(_) => {
585 RecipientLookupOutcome::RecipientListTooLong
586 }
587 })
588}
589
590/// Filter decoded destinations by day, time, and transition — the shared
591/// selection step of [`filter_recipient_list`] and
592/// [`lookup_notification_recipients`].
593fn filter_destinations(
594 destinations: Vec<BACnetDestination>,
595 transition: EventTransition,
596 today: DaysOfWeek,
597 current_time: &Time,
598) -> Vec<(BACnetRecipient, u32, bool)> {
599 let transition_mask = transition.bit_mask();
600 destinations
601 .into_iter()
602 .filter(|dest| dest.valid_days.intersects(today))
603 .filter(|dest| time_in_window(current_time, &dest.from_time, &dest.to_time))
604 .filter(|dest| dest.transitions.intersects(transition_mask))
605 .map(|dest| {
606 (
607 dest.recipient,
608 dest.process_identifier,
609 dest.issue_confirmed_notifications,
610 )
611 })
612 .collect()
613}
614
615/// Filter an encoded `RECIPIENT_LIST` property value by day, time, and transition.
616///
617/// Parses the value as returned by `read_property(RECIPIENT_LIST)`, the
618/// framed `BACnetLIST of BACnetDestination` form, and returns only those
619/// recipients matching the given filters. A list that fails to decode (even
620/// partially), or holds more than [`MAX_RECIPIENT_LIST_DESTINATIONS`]
621/// destinations, yields NO recipients: routing fails closed rather than
622/// notifying a silently-truncated prefix of the configured destinations.
623pub fn filter_recipient_list(
624 recipient_list_value: &PropertyValue,
625 transition: EventTransition,
626 today: DaysOfWeek,
627 current_time: &Time,
628) -> Vec<(BACnetRecipient, u32, bool)> {
629 let Ok(destinations) = routed_destinations(recipient_list_value) else {
630 return Vec::new();
631 };
632 filter_destinations(destinations, transition, today, current_time)
633}
634
635#[cfg(test)]
636mod tests;