Skip to main content

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;