Skip to main content

bacnet_objects/notification_class/
mod.rs

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