Skip to main content

bacnet_objects/
notification_class.rs

1//! NotificationClass object per ASHRAE 135-2020 Clause 12.31.
2
3use bacnet_types::constructed::{BACnetAddress, BACnetDestination, BACnetRecipient};
4use bacnet_types::enums::{ObjectType, PropertyIdentifier};
5use bacnet_types::error::Error;
6use bacnet_types::primitives::{ObjectIdentifier, PropertyValue, StatusFlags, Time};
7use bacnet_types::MacAddr;
8use std::borrow::Cow;
9
10use crate::common::{self, read_common_properties};
11use crate::database::ObjectDatabase;
12use crate::event::EventTransition;
13use crate::traits::BACnetObject;
14
15/// BACnet NotificationClass object.
16///
17/// Stores notification routing configuration: which priorities, acknowledgement
18/// requirements, and recipient destinations apply to event notifications
19/// referencing this class number.
20pub struct NotificationClass {
21    oid: ObjectIdentifier,
22    name: String,
23    description: String,
24    status_flags: StatusFlags,
25    out_of_service: bool,
26    reliability: u32,
27    /// The notification class number.
28    pub notification_class: u32,
29    /// Priority: [TO_OFFNORMAL, TO_FAULT, TO_NORMAL]. Default [255, 255, 255].
30    pub priority: [u8; 3],
31    /// Ack required: [TO_OFFNORMAL, TO_FAULT, TO_NORMAL]. Default [false, false, false].
32    pub ack_required: [bool; 3],
33    /// Recipient list.
34    pub recipient_list: Vec<BACnetDestination>,
35}
36
37impl NotificationClass {
38    /// Create a new NotificationClass object.
39    ///
40    /// The `notification_class` number defaults to the instance number.
41    pub fn new(instance: u32, name: impl Into<String>) -> Result<Self, Error> {
42        let oid = ObjectIdentifier::new(ObjectType::NOTIFICATION_CLASS, instance)?;
43        Ok(Self {
44            oid,
45            name: name.into(),
46            description: String::new(),
47            status_flags: StatusFlags::empty(),
48            out_of_service: false,
49            reliability: 0,
50            notification_class: instance,
51            priority: [255, 255, 255],
52            ack_required: [false, false, false],
53            recipient_list: Vec::new(),
54        })
55    }
56
57    /// Set the description string.
58    pub fn set_description(&mut self, desc: impl Into<String>) {
59        self.description = desc.into();
60    }
61
62    /// Add a destination to the recipient list.
63    pub fn add_destination(&mut self, dest: BACnetDestination) {
64        self.recipient_list.push(dest);
65    }
66}
67
68impl BACnetObject for NotificationClass {
69    fn object_identifier(&self) -> ObjectIdentifier {
70        self.oid
71    }
72
73    fn object_name(&self) -> &str {
74        &self.name
75    }
76
77    fn read_property(
78        &self,
79        property: PropertyIdentifier,
80        array_index: Option<u32>,
81    ) -> Result<PropertyValue, Error> {
82        if let Some(result) = read_common_properties!(self, property, array_index) {
83            return result;
84        }
85        match property {
86            p if p == PropertyIdentifier::OBJECT_TYPE => Ok(PropertyValue::Enumerated(
87                ObjectType::NOTIFICATION_CLASS.to_raw(),
88            )),
89            p if p == PropertyIdentifier::EVENT_STATE => {
90                Ok(PropertyValue::Enumerated(0)) // normal
91            }
92            p if p == PropertyIdentifier::NOTIFICATION_CLASS => {
93                Ok(PropertyValue::Unsigned(self.notification_class as u64))
94            }
95            p if p == PropertyIdentifier::PRIORITY => match array_index {
96                Some(0) => Ok(PropertyValue::Unsigned(3)),
97                Some(idx) if (1..=3).contains(&idx) => Ok(PropertyValue::Unsigned(
98                    self.priority[(idx - 1) as usize] as u64,
99                )),
100                None => Ok(PropertyValue::List(vec![
101                    PropertyValue::Unsigned(self.priority[0] as u64),
102                    PropertyValue::Unsigned(self.priority[1] as u64),
103                    PropertyValue::Unsigned(self.priority[2] as u64),
104                ])),
105                _ => Err(common::invalid_array_index_error()),
106            },
107            p if p == PropertyIdentifier::ACK_REQUIRED => {
108                // 3-bit bitstring: bit 0=TO_OFFNORMAL, bit 1=TO_FAULT, bit 2=TO_NORMAL
109                let mut byte: u8 = 0;
110                if self.ack_required[0] {
111                    byte |= 0x80;
112                } // bit 0 in MSB
113                if self.ack_required[1] {
114                    byte |= 0x40;
115                } // bit 1
116                if self.ack_required[2] {
117                    byte |= 0x20;
118                } // bit 2
119                Ok(PropertyValue::BitString {
120                    unused_bits: 5,
121                    data: vec![byte],
122                })
123            }
124            p if p == PropertyIdentifier::RECIPIENT_LIST => Ok(PropertyValue::List(
125                self.recipient_list
126                    .iter()
127                    .map(|dest| {
128                        PropertyValue::List(vec![
129                            // valid_days as bitstring (7 bits used, 1 unused)
130                            PropertyValue::BitString {
131                                unused_bits: 1,
132                                data: vec![dest.valid_days << 1],
133                            },
134                            PropertyValue::Time(dest.from_time),
135                            PropertyValue::Time(dest.to_time),
136                            // recipient
137                            match &dest.recipient {
138                                BACnetRecipient::Device(oid) => {
139                                    PropertyValue::ObjectIdentifier(*oid)
140                                }
141                                BACnetRecipient::Address(addr) => {
142                                    PropertyValue::OctetString(addr.mac_address.to_vec())
143                                }
144                            },
145                            PropertyValue::Unsigned(dest.process_identifier as u64),
146                            PropertyValue::Boolean(dest.issue_confirmed_notifications),
147                            // transitions as bitstring (3 bits used, 5 unused)
148                            PropertyValue::BitString {
149                                unused_bits: 5,
150                                data: vec![dest.transitions << 5],
151                            },
152                        ])
153                    })
154                    .collect(),
155            )),
156            _ => Err(common::unknown_property_error()),
157        }
158    }
159
160    fn write_property(
161        &mut self,
162        property: PropertyIdentifier,
163        _array_index: Option<u32>,
164        value: PropertyValue,
165        _priority: Option<u8>,
166    ) -> Result<(), Error> {
167        if property == PropertyIdentifier::NOTIFICATION_CLASS {
168            if let PropertyValue::Unsigned(v) = value {
169                self.notification_class = common::u64_to_u32(v)?;
170                return Ok(());
171            }
172            return Err(common::invalid_data_type_error());
173        }
174        if property == PropertyIdentifier::RECIPIENT_LIST {
175            if let PropertyValue::List(entries) = value {
176                let mut new_list = Vec::with_capacity(entries.len());
177                for entry in entries {
178                    if let PropertyValue::List(fields) = entry {
179                        if fields.len() < 7 {
180                            return Err(common::invalid_data_type_error());
181                        }
182                        // [0] valid_days: BitString (7 bits, 1 unused)
183                        let valid_days = match &fields[0] {
184                            PropertyValue::BitString { data, .. } if !data.is_empty() => {
185                                data[0] >> 1
186                            }
187                            _ => return Err(common::invalid_data_type_error()),
188                        };
189                        // [1] from_time
190                        let from_time = match fields[1] {
191                            PropertyValue::Time(t) => t,
192                            _ => return Err(common::invalid_data_type_error()),
193                        };
194                        // [2] to_time
195                        let to_time = match fields[2] {
196                            PropertyValue::Time(t) => t,
197                            _ => return Err(common::invalid_data_type_error()),
198                        };
199                        // [3] recipient: ObjectIdentifier (Device) or OctetString (Address)
200                        let recipient = match &fields[3] {
201                            PropertyValue::ObjectIdentifier(oid) => BACnetRecipient::Device(*oid),
202                            PropertyValue::OctetString(mac) => {
203                                BACnetRecipient::Address(BACnetAddress {
204                                    network_number: 0,
205                                    mac_address: MacAddr::from_slice(mac),
206                                })
207                            }
208                            _ => return Err(common::invalid_data_type_error()),
209                        };
210                        // [4] process_identifier
211                        let process_identifier = match fields[4] {
212                            PropertyValue::Unsigned(v) => common::u64_to_u32(v)?,
213                            _ => return Err(common::invalid_data_type_error()),
214                        };
215                        // [5] issue_confirmed_notifications
216                        let issue_confirmed_notifications = match fields[5] {
217                            PropertyValue::Boolean(b) => b,
218                            _ => return Err(common::invalid_data_type_error()),
219                        };
220                        // [6] transitions: BitString (3 bits, 5 unused)
221                        let transitions = match &fields[6] {
222                            PropertyValue::BitString { data, .. } if !data.is_empty() => {
223                                data[0] >> 5
224                            }
225                            _ => return Err(common::invalid_data_type_error()),
226                        };
227                        new_list.push(BACnetDestination {
228                            valid_days,
229                            from_time,
230                            to_time,
231                            recipient,
232                            process_identifier,
233                            issue_confirmed_notifications,
234                            transitions,
235                        });
236                    } else {
237                        return Err(common::invalid_data_type_error());
238                    }
239                }
240                self.recipient_list = new_list;
241                return Ok(());
242            }
243            return Err(common::invalid_data_type_error());
244        }
245        if let Some(result) =
246            common::write_out_of_service(&mut self.out_of_service, property, &value)
247        {
248            return result;
249        }
250        if let Some(result) = common::write_description(&mut self.description, property, &value) {
251            return result;
252        }
253        Err(common::write_access_denied_error())
254    }
255
256    fn property_list(&self) -> Cow<'static, [PropertyIdentifier]> {
257        static PROPS: &[PropertyIdentifier] = &[
258            PropertyIdentifier::OBJECT_IDENTIFIER,
259            PropertyIdentifier::OBJECT_NAME,
260            PropertyIdentifier::DESCRIPTION,
261            PropertyIdentifier::OBJECT_TYPE,
262            PropertyIdentifier::STATUS_FLAGS,
263            PropertyIdentifier::EVENT_STATE,
264            PropertyIdentifier::OUT_OF_SERVICE,
265            PropertyIdentifier::RELIABILITY,
266            PropertyIdentifier::NOTIFICATION_CLASS,
267            PropertyIdentifier::PRIORITY,
268            PropertyIdentifier::ACK_REQUIRED,
269            PropertyIdentifier::RECIPIENT_LIST,
270        ];
271        Cow::Borrowed(PROPS)
272    }
273}
274
275/// Convert a `Time` to centiseconds (hundredths of a second since midnight).
276fn time_to_centiseconds(t: &Time) -> u32 {
277    let h = if t.hour == Time::UNSPECIFIED {
278        0
279    } else {
280        t.hour as u32
281    };
282    let m = if t.minute == Time::UNSPECIFIED {
283        0
284    } else {
285        t.minute as u32
286    };
287    let s = if t.second == Time::UNSPECIFIED {
288        0
289    } else {
290        t.second as u32
291    };
292    let cs = if t.hundredths == Time::UNSPECIFIED {
293        0
294    } else {
295        t.hundredths as u32
296    };
297    h * 360_000 + m * 6_000 + s * 100 + cs
298}
299
300/// Check if `current` falls within the `[from, to]` time window.
301///
302/// If either bound has an unspecified hour (0xFF), the window is treated as "all day".
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    cur >= from_cs && cur <= to_cs
311}
312
313/// Get notification recipients for a given notification class number and transition.
314///
315/// Looks up the `NotificationClass` object whose `Notification_Class` property equals
316/// `notification_class`, then filters its `Recipient_List` by day, time, and transition.
317///
318/// # Parameters
319/// - `db`: the object database containing NotificationClass objects
320/// - `notification_class`: the notification class number to look up
321/// - `transition`: which event transition to filter for
322/// - `today_bit`: bitmask for today's day of week in `valid_days`
323///   (bit 0 = Sunday, bit 1 = Monday, …, bit 6 = Saturday)
324/// - `current_time`: the current local time for time-window filtering
325///
326/// Returns `(recipient, process_identifier, issue_confirmed_notifications)` tuples.
327/// Returns an empty `Vec` if no matching NotificationClass is found or no recipients match.
328pub fn get_notification_recipients(
329    db: &ObjectDatabase,
330    notification_class: u32,
331    transition: EventTransition,
332    today_bit: u8,
333    current_time: &Time,
334) -> Vec<(BACnetRecipient, u32, bool)> {
335    // Try direct OID lookup first (instance == notification_class is the common case)
336    let recipient_list_val = if let Ok(nc_oid) =
337        ObjectIdentifier::new(ObjectType::NOTIFICATION_CLASS, notification_class)
338    {
339        if let Some(obj) = db.get(&nc_oid) {
340            match obj.read_property(PropertyIdentifier::NOTIFICATION_CLASS, None) {
341                Ok(PropertyValue::Unsigned(n)) if n as u32 == notification_class => obj
342                    .read_property(PropertyIdentifier::RECIPIENT_LIST, None)
343                    .ok(),
344                _ => None,
345            }
346        } else {
347            None
348        }
349    } else {
350        None
351    };
352
353    // Fall back to scanning all NotificationClass objects
354    let recipient_list_val = recipient_list_val.or_else(|| {
355        db.find_by_type(ObjectType::NOTIFICATION_CLASS)
356            .iter()
357            .find_map(|oid| {
358                let obj = db.get(oid)?;
359                match obj.read_property(PropertyIdentifier::NOTIFICATION_CLASS, None) {
360                    Ok(PropertyValue::Unsigned(n)) if n as u32 == notification_class => obj
361                        .read_property(PropertyIdentifier::RECIPIENT_LIST, None)
362                        .ok(),
363                    _ => None,
364                }
365            })
366    });
367
368    let recipient_list_val = match recipient_list_val {
369        Some(v) => v,
370        None => return Vec::new(),
371    };
372
373    filter_recipient_list(&recipient_list_val, transition, today_bit, current_time)
374}
375
376/// Filter an encoded `RECIPIENT_LIST` property value by day, time, and transition.
377///
378/// Parses `PropertyValue::List` entries (as returned by `read_property(RECIPIENT_LIST)`)
379/// and returns only those recipients matching the given filters.
380pub fn filter_recipient_list(
381    recipient_list_value: &PropertyValue,
382    transition: EventTransition,
383    today_bit: u8,
384    current_time: &Time,
385) -> Vec<(BACnetRecipient, u32, bool)> {
386    let entries = match recipient_list_value {
387        PropertyValue::List(l) => l,
388        _ => return Vec::new(),
389    };
390
391    let transition_mask = transition.bit_mask();
392    let mut result = Vec::new();
393
394    for entry in entries {
395        let fields = match entry {
396            PropertyValue::List(f) if f.len() >= 7 => f,
397            _ => continue,
398        };
399
400        // [0] valid_days bitstring
401        let valid_days = match &fields[0] {
402            PropertyValue::BitString { data, .. } if !data.is_empty() => data[0] >> 1,
403            _ => continue,
404        };
405        if valid_days & today_bit == 0 {
406            continue;
407        }
408
409        // [1] from_time, [2] to_time
410        let from_time = match &fields[1] {
411            PropertyValue::Time(t) => t,
412            _ => continue,
413        };
414        let to_time = match &fields[2] {
415            PropertyValue::Time(t) => t,
416            _ => continue,
417        };
418        if !time_in_window(current_time, from_time, to_time) {
419            continue;
420        }
421
422        // [6] transitions bitstring
423        let transitions = match &fields[6] {
424            PropertyValue::BitString { data, .. } if !data.is_empty() => data[0] >> 5,
425            _ => continue,
426        };
427        if transitions & transition_mask == 0 {
428            continue;
429        }
430
431        // [3] recipient
432        let recipient = match &fields[3] {
433            PropertyValue::ObjectIdentifier(oid) => BACnetRecipient::Device(*oid),
434            PropertyValue::OctetString(mac) => BACnetRecipient::Address(BACnetAddress {
435                network_number: 0,
436                mac_address: MacAddr::from_slice(mac),
437            }),
438            _ => continue,
439        };
440
441        // [4] process_identifier
442        let process_id = match &fields[4] {
443            PropertyValue::Unsigned(v) => *v as u32,
444            _ => continue,
445        };
446
447        // [5] issue_confirmed_notifications
448        let confirmed = match &fields[5] {
449            PropertyValue::Boolean(b) => *b,
450            _ => continue,
451        };
452
453        result.push((recipient, process_id, confirmed));
454    }
455
456    result
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use bacnet_types::constructed::{BACnetAddress, BACnetDestination, BACnetRecipient};
463    use bacnet_types::primitives::Time;
464    use bacnet_types::MacAddr;
465
466    fn make_time(hour: u8, minute: u8) -> Time {
467        Time {
468            hour,
469            minute,
470            second: 0,
471            hundredths: 0,
472        }
473    }
474
475    fn make_dest_device(device_instance: u32) -> BACnetDestination {
476        let dev_oid = ObjectIdentifier::new(ObjectType::DEVICE, device_instance).unwrap();
477        BACnetDestination {
478            valid_days: 0b0111_1111, // all days
479            from_time: make_time(0, 0),
480            to_time: make_time(23, 59),
481            recipient: BACnetRecipient::Device(dev_oid),
482            process_identifier: 1,
483            issue_confirmed_notifications: true,
484            transitions: 0b0000_0111, // all transitions
485        }
486    }
487
488    #[test]
489    fn object_type_is_notification_class() {
490        let nc = NotificationClass::new(1, "NC-1").unwrap();
491        assert_eq!(
492            nc.object_identifier().object_type(),
493            ObjectType::NOTIFICATION_CLASS
494        );
495        assert_eq!(nc.object_identifier().instance_number(), 1);
496    }
497
498    #[test]
499    fn read_notification_class_number() {
500        let nc = NotificationClass::new(42, "NC-42").unwrap();
501        let val = nc
502            .read_property(PropertyIdentifier::NOTIFICATION_CLASS, None)
503            .unwrap();
504        if let PropertyValue::Unsigned(n) = val {
505            assert_eq!(n, 42);
506        } else {
507            panic!("Expected Unsigned");
508        }
509    }
510
511    #[test]
512    fn read_priority_array_index() {
513        let nc = NotificationClass::new(1, "NC-1").unwrap();
514        // Index 0 = array length
515        let len = nc
516            .read_property(PropertyIdentifier::PRIORITY, Some(0))
517            .unwrap();
518        if let PropertyValue::Unsigned(n) = len {
519            assert_eq!(n, 3);
520        } else {
521            panic!("Expected Unsigned");
522        }
523
524        // Index 1 = TO_OFFNORMAL priority (default 255)
525        let p1 = nc
526            .read_property(PropertyIdentifier::PRIORITY, Some(1))
527            .unwrap();
528        if let PropertyValue::Unsigned(n) = p1 {
529            assert_eq!(n, 255);
530        } else {
531            panic!("Expected Unsigned");
532        }
533    }
534
535    #[test]
536    fn read_priority_all() {
537        let nc = NotificationClass::new(1, "NC-1").unwrap();
538        let val = nc
539            .read_property(PropertyIdentifier::PRIORITY, None)
540            .unwrap();
541        if let PropertyValue::List(items) = val {
542            assert_eq!(items.len(), 3);
543            assert_eq!(items[0], PropertyValue::Unsigned(255));
544            assert_eq!(items[1], PropertyValue::Unsigned(255));
545            assert_eq!(items[2], PropertyValue::Unsigned(255));
546        } else {
547            panic!("Expected List");
548        }
549    }
550
551    #[test]
552    fn read_priority_invalid_index() {
553        let nc = NotificationClass::new(1, "NC-1").unwrap();
554        let result = nc.read_property(PropertyIdentifier::PRIORITY, Some(4));
555        assert!(result.is_err());
556    }
557
558    #[test]
559    fn read_object_name() {
560        let nc = NotificationClass::new(1, "NC-1").unwrap();
561        let val = nc
562            .read_property(PropertyIdentifier::OBJECT_NAME, None)
563            .unwrap();
564        if let PropertyValue::CharacterString(s) = val {
565            assert_eq!(s, "NC-1");
566        } else {
567            panic!("Expected CharacterString");
568        }
569    }
570
571    #[test]
572    fn write_notification_class_number() {
573        let mut nc = NotificationClass::new(1, "NC-1").unwrap();
574        nc.write_property(
575            PropertyIdentifier::NOTIFICATION_CLASS,
576            None,
577            PropertyValue::Unsigned(99),
578            None,
579        )
580        .unwrap();
581        assert_eq!(nc.notification_class, 99);
582    }
583
584    #[test]
585    fn write_notification_class_wrong_type() {
586        let mut nc = NotificationClass::new(1, "NC-1").unwrap();
587        let result = nc.write_property(
588            PropertyIdentifier::NOTIFICATION_CLASS,
589            None,
590            PropertyValue::Real(1.0),
591            None,
592        );
593        assert!(result.is_err());
594    }
595
596    #[test]
597    fn property_list_contains_recipient_list() {
598        let nc = NotificationClass::new(1, "NC-1").unwrap();
599        let props = nc.property_list();
600        assert!(props.contains(&PropertyIdentifier::NOTIFICATION_CLASS));
601        assert!(props.contains(&PropertyIdentifier::PRIORITY));
602        assert!(props.contains(&PropertyIdentifier::ACK_REQUIRED));
603        assert!(props.contains(&PropertyIdentifier::RECIPIENT_LIST));
604    }
605
606    #[test]
607    fn read_ack_required_default() {
608        let nc = NotificationClass::new(1, "NC-1").unwrap();
609        let val = nc
610            .read_property(PropertyIdentifier::ACK_REQUIRED, None)
611            .unwrap();
612        if let PropertyValue::BitString { unused_bits, data } = val {
613            assert_eq!(unused_bits, 5);
614            assert_eq!(data, vec![0]); // all false
615        } else {
616            panic!("Expected BitString");
617        }
618    }
619
620    #[test]
621    fn read_recipient_list_empty() {
622        let nc = NotificationClass::new(1, "NC-1").unwrap();
623        let val = nc
624            .read_property(PropertyIdentifier::RECIPIENT_LIST, None)
625            .unwrap();
626        if let PropertyValue::List(items) = val {
627            assert!(items.is_empty());
628        } else {
629            panic!("Expected List");
630        }
631    }
632
633    #[test]
634    fn add_destination_device_and_read_back() {
635        let mut nc = NotificationClass::new(1, "NC-1").unwrap();
636        nc.add_destination(make_dest_device(99));
637
638        let val = nc
639            .read_property(PropertyIdentifier::RECIPIENT_LIST, None)
640            .unwrap();
641        let PropertyValue::List(outer) = val else {
642            panic!("Expected outer List");
643        };
644        assert_eq!(outer.len(), 1);
645
646        let PropertyValue::List(fields) = &outer[0] else {
647            panic!("Expected inner List");
648        };
649        // 7 fields: valid_days, from_time, to_time, recipient, process_id, confirmed, transitions
650        assert_eq!(fields.len(), 7);
651
652        // valid_days bitstring: all days = 0b0111_1111 << 1 = 0b1111_1110 = 0xFE
653        assert_eq!(
654            fields[0],
655            PropertyValue::BitString {
656                unused_bits: 1,
657                data: vec![0b1111_1110],
658            }
659        );
660
661        // from_time
662        assert_eq!(fields[1], PropertyValue::Time(make_time(0, 0)));
663
664        // to_time
665        assert_eq!(fields[2], PropertyValue::Time(make_time(23, 59)));
666
667        // recipient = Device OID for instance 99
668        let dev_oid = ObjectIdentifier::new(ObjectType::DEVICE, 99).unwrap();
669        assert_eq!(fields[3], PropertyValue::ObjectIdentifier(dev_oid));
670
671        // process_identifier
672        assert_eq!(fields[4], PropertyValue::Unsigned(1));
673
674        // issue_confirmed_notifications
675        assert_eq!(fields[5], PropertyValue::Boolean(true));
676
677        // transitions: all = 0b0000_0111 << 5 = 0b1110_0000 = 0xE0
678        assert_eq!(
679            fields[6],
680            PropertyValue::BitString {
681                unused_bits: 5,
682                data: vec![0b1110_0000],
683            }
684        );
685    }
686
687    #[test]
688    fn add_destination_address_variant() {
689        let mut nc = NotificationClass::new(1, "NC-1").unwrap();
690        let mac = MacAddr::from_slice(&[192u8, 168, 1, 100, 0xBA, 0xC0]);
691        let dest = BACnetDestination {
692            valid_days: 0b0011_1110, // Mon–Fri
693            from_time: make_time(8, 0),
694            to_time: make_time(17, 0),
695            recipient: BACnetRecipient::Address(BACnetAddress {
696                network_number: 0,
697                mac_address: mac.clone(),
698            }),
699            process_identifier: 42,
700            issue_confirmed_notifications: false,
701            transitions: 0b0000_0001, // TO_OFFNORMAL only
702        };
703        nc.add_destination(dest);
704
705        let val = nc
706            .read_property(PropertyIdentifier::RECIPIENT_LIST, None)
707            .unwrap();
708        let PropertyValue::List(outer) = val else {
709            panic!("Expected outer List");
710        };
711        assert_eq!(outer.len(), 1);
712
713        let PropertyValue::List(fields) = &outer[0] else {
714            panic!("Expected inner List");
715        };
716
717        // recipient = OctetString of mac_address
718        assert_eq!(fields[3], PropertyValue::OctetString(mac.to_vec()));
719
720        // process_identifier = 42
721        assert_eq!(fields[4], PropertyValue::Unsigned(42));
722
723        // issue_confirmed = false
724        assert_eq!(fields[5], PropertyValue::Boolean(false));
725
726        // transitions: bit 0 only = 0b0000_0001 << 5 = 0b0010_0000 = 0x20
727        assert_eq!(
728            fields[6],
729            PropertyValue::BitString {
730                unused_bits: 5,
731                data: vec![0b0010_0000],
732            }
733        );
734    }
735
736    #[test]
737    fn add_multiple_destinations() {
738        let mut nc = NotificationClass::new(5, "NC-5").unwrap();
739        nc.add_destination(make_dest_device(100));
740        nc.add_destination(make_dest_device(200));
741        nc.add_destination(make_dest_device(300));
742
743        let val = nc
744            .read_property(PropertyIdentifier::RECIPIENT_LIST, None)
745            .unwrap();
746        let PropertyValue::List(outer) = val else {
747            panic!("Expected List");
748        };
749        assert_eq!(outer.len(), 3);
750    }
751
752    #[test]
753    fn write_recipient_list_clears_existing() {
754        let mut nc = NotificationClass::new(1, "NC-1").unwrap();
755        nc.add_destination(make_dest_device(10));
756        nc.add_destination(make_dest_device(20));
757        assert_eq!(nc.recipient_list.len(), 2);
758
759        // Write an empty list — should clear
760        nc.write_property(
761            PropertyIdentifier::RECIPIENT_LIST,
762            None,
763            PropertyValue::List(vec![]),
764            None,
765        )
766        .unwrap();
767        assert!(nc.recipient_list.is_empty());
768    }
769
770    #[test]
771    fn write_recipient_list_wrong_type_denied() {
772        let mut nc = NotificationClass::new(1, "NC-1").unwrap();
773        let result = nc.write_property(
774            PropertyIdentifier::RECIPIENT_LIST,
775            None,
776            PropertyValue::Unsigned(0),
777            None,
778        );
779        assert!(result.is_err());
780    }
781
782    #[test]
783    fn write_recipient_list_round_trip() {
784        let mut nc = NotificationClass::new(1, "NC-1").unwrap();
785        nc.add_destination(make_dest_device(10));
786        // Read the encoded list, then write it back
787        let encoded = nc
788            .read_property(PropertyIdentifier::RECIPIENT_LIST, None)
789            .unwrap();
790        nc.write_property(PropertyIdentifier::RECIPIENT_LIST, None, encoded, None)
791            .unwrap();
792        assert_eq!(nc.recipient_list.len(), 1);
793        assert_eq!(nc.recipient_list[0].process_identifier, 1);
794    }
795
796    #[test]
797    fn read_event_state_default() {
798        let nc = NotificationClass::new(1, "NC-1").unwrap();
799        let val = nc
800            .read_property(PropertyIdentifier::EVENT_STATE, None)
801            .unwrap();
802        assert_eq!(val, PropertyValue::Enumerated(0)); // normal
803    }
804
805    #[test]
806    fn write_out_of_service() {
807        let mut nc = NotificationClass::new(1, "NC-1").unwrap();
808        nc.write_property(
809            PropertyIdentifier::OUT_OF_SERVICE,
810            None,
811            PropertyValue::Boolean(true),
812            None,
813        )
814        .unwrap();
815        let val = nc
816            .read_property(PropertyIdentifier::OUT_OF_SERVICE, None)
817            .unwrap();
818        assert_eq!(val, PropertyValue::Boolean(true));
819    }
820
821    #[test]
822    fn write_unknown_property_denied() {
823        let mut nc = NotificationClass::new(1, "NC-1").unwrap();
824        let result = nc.write_property(
825            PropertyIdentifier::PRESENT_VALUE,
826            None,
827            PropertyValue::Real(1.0),
828            None,
829        );
830        assert!(result.is_err());
831    }
832
833    // -----------------------------------------------------------------------
834    // get_notification_recipients tests
835    // -----------------------------------------------------------------------
836
837    fn make_dest(
838        device_instance: u32,
839        valid_days: u8,
840        from: Time,
841        to: Time,
842        confirmed: bool,
843        transitions: u8,
844    ) -> BACnetDestination {
845        let dev_oid = ObjectIdentifier::new(ObjectType::DEVICE, device_instance).unwrap();
846        BACnetDestination {
847            valid_days,
848            from_time: from,
849            to_time: to,
850            recipient: BACnetRecipient::Device(dev_oid),
851            process_identifier: device_instance,
852            issue_confirmed_notifications: confirmed,
853            transitions,
854        }
855    }
856
857    #[test]
858    fn get_recipients_filters_by_transition() {
859        let mut db = ObjectDatabase::new();
860        let mut nc = NotificationClass::new(1, "NC-1").unwrap();
861
862        // Recipient 1: only TO_OFFNORMAL (bit 0)
863        nc.add_destination(make_dest(
864            10,
865            0b0111_1111,
866            make_time(0, 0),
867            make_time(23, 59),
868            false,
869            0b0000_0001,
870        ));
871        // Recipient 2: only TO_NORMAL (bit 2)
872        nc.add_destination(make_dest(
873            20,
874            0b0111_1111,
875            make_time(0, 0),
876            make_time(23, 59),
877            true,
878            0b0000_0100,
879        ));
880        // Recipient 3: all transitions
881        nc.add_destination(make_dest(
882            30,
883            0b0111_1111,
884            make_time(0, 0),
885            make_time(23, 59),
886            false,
887            0b0000_0111,
888        ));
889        db.add(Box::new(nc)).unwrap();
890
891        let now = make_time(12, 0);
892        let monday_bit = 0x02; // bit 1 = Monday
893
894        // TO_OFFNORMAL should match recipients 1 and 3
895        let r = get_notification_recipients(&db, 1, EventTransition::ToOffnormal, monday_bit, &now);
896        assert_eq!(r.len(), 2);
897        assert_eq!(r[0].1, 10); // process_id
898        assert_eq!(r[1].1, 30);
899
900        // TO_NORMAL should match recipients 2 and 3
901        let r = get_notification_recipients(&db, 1, EventTransition::ToNormal, monday_bit, &now);
902        assert_eq!(r.len(), 2);
903        assert_eq!(r[0].1, 20);
904        assert!(r[0].2); // recipient 2 is confirmed
905        assert_eq!(r[1].1, 30);
906
907        // TO_FAULT should match only recipient 3
908        let r = get_notification_recipients(&db, 1, EventTransition::ToFault, monday_bit, &now);
909        assert_eq!(r.len(), 1);
910        assert_eq!(r[0].1, 30);
911    }
912
913    #[test]
914    fn get_recipients_filters_by_day() {
915        let mut db = ObjectDatabase::new();
916        let mut nc = NotificationClass::new(2, "NC-2").unwrap();
917
918        // Recipient valid Mon-Fri only (bits 1-5 = 0b0011_1110)
919        nc.add_destination(make_dest(
920            10,
921            0b0011_1110,
922            make_time(0, 0),
923            make_time(23, 59),
924            false,
925            0b0000_0111,
926        ));
927        db.add(Box::new(nc)).unwrap();
928
929        let now = make_time(12, 0);
930
931        // Monday (bit 1) — should match
932        let r = get_notification_recipients(&db, 2, EventTransition::ToOffnormal, 0x02, &now);
933        assert_eq!(r.len(), 1);
934
935        // Sunday (bit 0) — should NOT match
936        let r = get_notification_recipients(&db, 2, EventTransition::ToOffnormal, 0x01, &now);
937        assert!(r.is_empty());
938
939        // Saturday (bit 6) — should NOT match
940        let r = get_notification_recipients(&db, 2, EventTransition::ToOffnormal, 0x40, &now);
941        assert!(r.is_empty());
942    }
943
944    #[test]
945    fn get_recipients_filters_by_time_window() {
946        let mut db = ObjectDatabase::new();
947        let mut nc = NotificationClass::new(3, "NC-3").unwrap();
948
949        // Recipient valid 08:00–17:00
950        nc.add_destination(make_dest(
951            10,
952            0b0111_1111,
953            make_time(8, 0),
954            make_time(17, 0),
955            false,
956            0b0000_0111,
957        ));
958        db.add(Box::new(nc)).unwrap();
959
960        let monday_bit = 0x02;
961
962        // 12:00 — inside window
963        let r = get_notification_recipients(
964            &db,
965            3,
966            EventTransition::ToOffnormal,
967            monday_bit,
968            &make_time(12, 0),
969        );
970        assert_eq!(r.len(), 1);
971
972        // 07:00 — before window
973        let r = get_notification_recipients(
974            &db,
975            3,
976            EventTransition::ToOffnormal,
977            monday_bit,
978            &make_time(7, 0),
979        );
980        assert!(r.is_empty());
981
982        // 18:00 — after window
983        let r = get_notification_recipients(
984            &db,
985            3,
986            EventTransition::ToOffnormal,
987            monday_bit,
988            &make_time(18, 0),
989        );
990        assert!(r.is_empty());
991    }
992
993    #[test]
994    fn get_recipients_returns_empty_for_missing_class() {
995        let db = ObjectDatabase::new();
996        let r = get_notification_recipients(
997            &db,
998            99,
999            EventTransition::ToOffnormal,
1000            0x02,
1001            &make_time(12, 0),
1002        );
1003        assert!(r.is_empty());
1004    }
1005
1006    #[test]
1007    fn get_recipients_returns_empty_for_empty_list() {
1008        let mut db = ObjectDatabase::new();
1009        let nc = NotificationClass::new(1, "NC-1").unwrap();
1010        db.add(Box::new(nc)).unwrap();
1011
1012        let r = get_notification_recipients(
1013            &db,
1014            1,
1015            EventTransition::ToOffnormal,
1016            0x02,
1017            &make_time(12, 0),
1018        );
1019        assert!(r.is_empty());
1020    }
1021
1022    #[test]
1023    fn event_state_change_transition_mapping() {
1024        use crate::event::EventStateChange;
1025        use bacnet_types::enums::EventState;
1026
1027        let to_normal = EventStateChange {
1028            from: EventState::HIGH_LIMIT,
1029            to: EventState::NORMAL,
1030        };
1031        assert_eq!(to_normal.transition(), EventTransition::ToNormal);
1032
1033        let to_fault = EventStateChange {
1034            from: EventState::NORMAL,
1035            to: EventState::FAULT,
1036        };
1037        assert_eq!(to_fault.transition(), EventTransition::ToFault);
1038
1039        let to_high = EventStateChange {
1040            from: EventState::NORMAL,
1041            to: EventState::HIGH_LIMIT,
1042        };
1043        assert_eq!(to_high.transition(), EventTransition::ToOffnormal);
1044
1045        let to_low = EventStateChange {
1046            from: EventState::NORMAL,
1047            to: EventState::LOW_LIMIT,
1048        };
1049        assert_eq!(to_low.transition(), EventTransition::ToOffnormal);
1050    }
1051}