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 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;