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 crate::prelude::*;
31use alloc::borrow::Cow;
32use bacnet_types::bitstring::{DaysOfWeek, EventTransitionBits};
33use bacnet_types::constructed::{BACnetDestination, BACnetRecipient};
34use bacnet_types::enums::{EventState, ObjectType, PropertyIdentifier, Reliability};
35use bacnet_types::error::Error;
36use bacnet_types::primitives::{ObjectIdentifier, PropertyValue, StatusFlags, Time};
37
38use crate::common::{self, read_common_properties};
39use crate::database::ObjectDatabase;
40use crate::durable::DurableWrites;
41use crate::event::EventTransition;
42use crate::traits::BACnetObject;
43
44mod enrollment_summary;
45mod metadata;
46mod persistence;
47pub(crate) mod recipient_list;
48mod saving;
49#[doc(hidden)]
50pub use enrollment_summary::{
51 EnrollmentSummaryClassProjection, EnrollmentSummaryClassProjectionError,
52 resolve_enrollment_summary_class_internal,
53};
54#[cfg(feature = "std")]
55pub use persistence::FileNotificationClassPersistence;
56pub use persistence::{NotificationClassPersistence, NotificationClassSnapshot};
57pub use recipient_list::MAX_RECIPIENT_LIST_DESTINATIONS;
58
59/// BACnet NotificationClass object.
60///
61/// Stores notification routing configuration: which priorities, acknowledgement
62/// requirements, and recipient destinations apply to event notifications
63/// referencing this class number.
64pub struct NotificationClass {
65 oid: ObjectIdentifier,
66 name: String,
67 description: String,
68 status_flags: StatusFlags,
69 reliability: Reliability,
70 /// The notification class number.
71 pub notification_class: u32,
72 /// Priority: [TO_OFFNORMAL, TO_FAULT, TO_NORMAL]. Default [255, 255, 255].
73 pub priority: [u8; 3],
74 /// Transitions whose notifications require acknowledgment. Default empty.
75 pub ack_required: EventTransitionBits,
76 /// Recipient list, at most [`MAX_RECIPIENT_LIST_DESTINATIONS`] long.
77 recipient_list: Vec<BACnetDestination>,
78 /// Where a written Recipient_List is saved, with persistence.
79 storage: Option<saving::Storage>,
80 /// Recipient_List writes taken, so a staged write can tell whether
81 /// another came between.
82 list_writes: u64,
83 /// A write set Recipient_List, now or before a restart, so storage keeps
84 /// it and configured destinations no longer apply.
85 recipient_list_written: bool,
86}
87
88impl NotificationClass {
89 /// Create a new NotificationClass object. Its Recipient_List is kept in
90 /// memory only; see [`with_persistence`](Self::with_persistence).
91 ///
92 /// The `notification_class` number defaults to the instance number.
93 pub fn new(instance: u32, name: impl Into<String>) -> Result<Self, Error> {
94 let oid = ObjectIdentifier::new(ObjectType::NOTIFICATION_CLASS, instance)?;
95 Ok(Self {
96 oid,
97 name: name.into(),
98 description: String::new(),
99 status_flags: StatusFlags::empty(),
100 reliability: Reliability::NO_FAULT_DETECTED,
101 notification_class: instance,
102 priority: [255, 255, 255],
103 ack_required: EventTransitionBits::empty(),
104 recipient_list: Vec::new(),
105 storage: None,
106 list_writes: 0,
107 recipient_list_written: false,
108 })
109 }
110
111 /// Set the description string.
112 pub fn set_description(&mut self, desc: impl Into<String>) {
113 self.description = desc.into();
114 }
115
116 /// Add a destination to the recipient list.
117 ///
118 /// Refuses, as a network write would, an address recipient whose MAC is
119 /// longer than [`BACnetAddress::MAX_MAC_LEN`] octets (PROPERTY /
120 /// INVALID_DATA_TYPE, #1124) and a destination past the
121 /// [`MAX_RECIPIENT_LIST_DESTINATIONS`] cap (RESOURCES /
122 /// NO_SPACE_TO_WRITE_PROPERTY).
123 ///
124 /// This configures the list the application starts with; it is not
125 /// saved. On a class [`with_persistence`](Self::with_persistence) whose
126 /// storage holds a written Recipient_List
127 /// ([`recipient_list_saved`](Self::recipient_list_saved)), the saved list
128 /// wins: the destination is checked but not added.
129 ///
130 /// [`BACnetAddress::MAX_MAC_LEN`]: bacnet_types::constructed::BACnetAddress::MAX_MAC_LEN
131 pub fn add_destination(&mut self, dest: BACnetDestination) -> Result<(), Error> {
132 recipient_list::check_added(&dest)?;
133 if self.recipient_list_saved() {
134 log_debug!(
135 class = %self.oid,
136 "Saved Recipient_List kept over a configured destination"
137 );
138 return Ok(());
139 }
140 if self.recipient_list.len() >= MAX_RECIPIENT_LIST_DESTINATIONS {
141 return Err(recipient_list::no_space_error());
142 }
143 self.recipient_list.push(dest);
144 Ok(())
145 }
146
147 /// The Recipient_List destinations, in list order.
148 pub fn recipient_list(&self) -> &[BACnetDestination] {
149 &self.recipient_list
150 }
151}
152
153impl BACnetObject for NotificationClass {
154 fn object_identifier(&self) -> ObjectIdentifier {
155 self.oid
156 }
157
158 fn object_name(&self) -> &str {
159 &self.name
160 }
161
162 fn read_property(
163 &self,
164 property: PropertyIdentifier,
165 array_index: Option<u32>,
166 ) -> Result<PropertyValue, Error> {
167 // Table 12-24 has no Out_Of_Service (#1064), and Clause 12.21 holds the
168 // OUT_OF_SERVICE flag FALSE.
169 if let Some(result) =
170 read_common_properties!(self, property, array_index, no_out_of_service)
171 {
172 return result;
173 }
174 match property {
175 p if p == PropertyIdentifier::OBJECT_TYPE => Ok(PropertyValue::Enumerated(
176 ObjectType::NOTIFICATION_CLASS.to_raw(),
177 )),
178 p if p == PropertyIdentifier::EVENT_STATE => {
179 Ok(PropertyValue::Enumerated(EventState::NORMAL.to_raw()))
180 }
181 p if p == PropertyIdentifier::NOTIFICATION_CLASS => {
182 Ok(PropertyValue::Unsigned(self.notification_class as u64))
183 }
184 p if p == PropertyIdentifier::PRIORITY => match array_index {
185 Some(0) => Ok(PropertyValue::Unsigned(3)),
186 Some(idx) if (1..=3).contains(&idx) => Ok(PropertyValue::Unsigned(
187 self.priority[(idx - 1) as usize] as u64,
188 )),
189 None => Ok(PropertyValue::List(vec![
190 PropertyValue::Unsigned(self.priority[0] as u64),
191 PropertyValue::Unsigned(self.priority[1] as u64),
192 PropertyValue::Unsigned(self.priority[2] as u64),
193 ])),
194 _ => Err(common::invalid_array_index_error()),
195 },
196 p if p == PropertyIdentifier::ACK_REQUIRED => Ok(PropertyValue::BitString {
197 unused_bits: 5,
198 data: vec![self.ack_required.to_bacnet()],
199 }),
200 p if p == PropertyIdentifier::RECIPIENT_LIST => {
201 // Full ASN.1 framing: BACnetLIST of BACnetDestination — each
202 // entry a 7-element application-tagged SEQUENCE with the
203 // recipient discriminated by context tag (device [0]
204 // primitive / address [1] constructed), Clause 12.21 + 21.
205 let mut buf = bytes::BytesMut::new();
206 bacnet_encoding::constructed::encode_destination_list(
207 &mut buf,
208 &self.recipient_list,
209 )?;
210 Ok(PropertyValue::ApplicationData(buf.to_vec()))
211 }
212 _ => Err(common::unknown_property_error()),
213 }
214 }
215
216 fn write_property(
217 &mut self,
218 property: PropertyIdentifier,
219 array_index: Option<u32>,
220 value: PropertyValue,
221 _priority: Option<u8>,
222 ) -> Result<(), Error> {
223 if property == PropertyIdentifier::NOTIFICATION_CLASS {
224 if let PropertyValue::Unsigned(v) = value {
225 self.notification_class = common::u64_to_u32(v)?;
226 return Ok(());
227 }
228 return Err(common::invalid_data_type_error());
229 }
230 if property == PropertyIdentifier::RECIPIENT_LIST {
231 // Indexed (array-element) access: Recipient_List is a BACnetLIST
232 // (Table 12-24), not an array, so Clause 12.1.5.2 makes ReadRange
233 // the only positional access. The RP/RPM/WP/WPM handlers gate
234 // indexed access to PROPERTY / PROPERTY_IS_NOT_AN_ARRAY
235 // (Clause 15.5.1.3 / 15.9.1.3) via `is_array_property`; mirror
236 // the same classification here for direct object-layer calls.
237 if array_index.is_some() {
238 return Err(common::property_is_not_an_array_error());
239 }
240 return self.write_recipient_list(value);
241 }
242 if let Some(result) = common::write_description(&mut self.description, property, &value) {
243 return result;
244 }
245 Err(crate::common::unhandled_write_error(
246 self.property_metadata().as_ref(),
247 property,
248 array_index,
249 ))
250 }
251
252 fn property_metadata(&self) -> Cow<'_, [crate::property_metadata::PropertyMetadata]> {
253 metadata::for_object(self)
254 }
255
256 fn property_list(&self) -> Cow<'static, [PropertyIdentifier]> {
257 crate::property_metadata::property_list_from_metadata(self.property_metadata().as_ref())
258 }
259
260 fn advance_monotonic_time_internal(&mut self, now: core::time::Duration) -> bool {
261 self.expire_staged_write(now);
262 false
263 }
264
265 fn durable_writes_internal(&mut self) -> Option<&mut dyn DurableWrites> {
266 Some(self)
267 }
268}
269
270/// Convert a `Time` to centiseconds (hundredths of a second since midnight).
271fn time_to_centiseconds(t: &Time) -> u32 {
272 let h = if t.hour == Time::UNSPECIFIED {
273 0
274 } else {
275 t.hour as u32
276 };
277 let m = if t.minute == Time::UNSPECIFIED {
278 0
279 } else {
280 t.minute as u32
281 };
282 let s = if t.second == Time::UNSPECIFIED {
283 0
284 } else {
285 t.second as u32
286 };
287 let cs = if t.hundredths == Time::UNSPECIFIED {
288 0
289 } else {
290 t.hundredths as u32
291 };
292 h * 360_000 + m * 6_000 + s * 100 + cs
293}
294
295/// Check if `current` falls within the `[from, to]` time window.
296///
297/// If either bound has an unspecified hour (0xFF), the window is treated as
298/// "all day". A window whose `to` is earlier than its `from` (e.g.
299/// 22:00–02:00) crosses midnight: it is active from `from` up to midnight and
300/// again from midnight up to `to`, i.e. when `current >= from || current <= to`.
301/// This matches the ASHRAE 135-2020 reading that `To_Time` is the end of the
302/// active period; a wrap-around pair denotes an overnight schedule.
303fn time_in_window(current: &Time, from: &Time, to: &Time) -> bool {
304 if from.hour == Time::UNSPECIFIED || to.hour == Time::UNSPECIFIED {
305 return true;
306 }
307 let cur = time_to_centiseconds(current);
308 let from_cs = time_to_centiseconds(from);
309 let to_cs = time_to_centiseconds(to);
310 if to_cs < from_cs {
311 // Overnight window crossing midnight.
312 cur >= from_cs || cur <= to_cs
313 } else {
314 cur >= from_cs && cur <= to_cs
315 }
316}
317
318/// Derive the local day of the week and time of day for recipient filtering.
319///
320/// `utc_secs` is seconds since the Unix epoch (1970-01-01, a Thursday).
321/// `utc_offset_minutes` is the Device object's `UTC_Offset` (signed minutes
322/// west of UTC); 0 keeps UTC. The day comes back as the single matching
323/// [`DaysOfWeek`] flag: the `+3` makes Monday day 0 because the epoch was a
324/// Thursday. The returned `Time` is the local time of day (hundredths are
325/// supplied by the caller via `subsec`).
326pub fn local_day_and_time(utc_secs: u64, utc_offset_minutes: i32) -> (DaysOfWeek, Time) {
327 // BACnet UTC_Offset is signed minutes west of UTC, so local standard time
328 // subtracts it. Saturation only affects values close to the Unix epoch.
329 let local_secs = utc_secs.saturating_add_signed(-i64::from(utc_offset_minutes) * 60);
330 let dow = (local_secs / 86400 + 3) % 7;
331 let today = DaysOfWeek::from_bits_truncate(1 << dow);
332 let day_secs = (local_secs % 86400) as u32;
333 let current_time = Time {
334 hour: (day_secs / 3600) as u8,
335 minute: ((day_secs % 3600) / 60) as u8,
336 second: (day_secs % 60) as u8,
337 hundredths: 0,
338 };
339 (today, current_time)
340}
341
342/// Resolve the NotificationClass object whose `Notification_Class` property
343/// equals `notification_class`.
344///
345/// Tries a direct OID lookup first (instance == notification_class is the
346/// common case), then falls back to scanning every NotificationClass object.
347/// Returns `None` when no matching class is configured.
348fn find_notification_class(
349 db: &ObjectDatabase,
350 notification_class: u32,
351) -> Option<&dyn BACnetObject> {
352 // Try direct OID lookup first (instance == notification_class is the common case)
353 if let Ok(nc_oid) = ObjectIdentifier::new(ObjectType::NOTIFICATION_CLASS, notification_class)
354 && let Some(obj) = db.get(&nc_oid)
355 && matches!(
356 obj.read_property(PropertyIdentifier::NOTIFICATION_CLASS, None),
357 Ok(PropertyValue::Unsigned(n)) if n as u32 == notification_class
358 )
359 {
360 return Some(obj);
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;