Skip to main content

bacnet_objects/notification_class/
mod.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;