Skip to main content

bacnet_server/
event_enrollment.rs

1//! Event Enrollment algorithmic evaluation.
2//!
3//! Unlike intrinsic reporting (built into object types), Event Enrollment is a
4//! separate object that monitors another object's property and evaluates an
5//! algorithm against it.
6//!
7//! Supported algorithms: OUT_OF_RANGE, FLOATING_LIMIT, CHANGE_OF_STATE,
8//! CHANGE_OF_BITSTRING, CHANGE_OF_VALUE.
9
10use bacnet_objects::database::ObjectDatabase;
11use bacnet_objects::event::EventStateChange;
12use bacnet_types::enums::{EventState, EventType, ObjectType, PropertyIdentifier};
13use bacnet_types::primitives::{ObjectIdentifier, PropertyValue};
14
15/// A state transition detected during event enrollment evaluation.
16#[derive(Debug, Clone, PartialEq)]
17pub struct EventEnrollmentTransition {
18    /// The EventEnrollment object that detected the transition.
19    pub enrollment_oid: ObjectIdentifier,
20    /// The monitored object whose property triggered the transition.
21    pub monitored_oid: ObjectIdentifier,
22    /// The detected state change.
23    pub change: EventStateChange,
24    /// The event type that was evaluated.
25    pub event_type: EventType,
26}
27
28// ---- Event parameter encoding helpers ----
29
30/// Encode OUT_OF_RANGE parameters: `[high_limit: f32 LE][low_limit: f32 LE][deadband: f32 LE]`
31pub fn encode_out_of_range_params(high_limit: f32, low_limit: f32, deadband: f32) -> Vec<u8> {
32    let mut buf = Vec::with_capacity(12);
33    buf.extend_from_slice(&high_limit.to_le_bytes());
34    buf.extend_from_slice(&low_limit.to_le_bytes());
35    buf.extend_from_slice(&deadband.to_le_bytes());
36    buf
37}
38
39/// Encode FLOATING_LIMIT parameters:
40/// `[setpoint: f32 LE][high_diff: f32 LE][low_diff: f32 LE][deadband: f32 LE]`
41pub fn encode_floating_limit_params(
42    setpoint: f32,
43    high_diff_limit: f32,
44    low_diff_limit: f32,
45    deadband: f32,
46) -> Vec<u8> {
47    let mut buf = Vec::with_capacity(16);
48    buf.extend_from_slice(&setpoint.to_le_bytes());
49    buf.extend_from_slice(&high_diff_limit.to_le_bytes());
50    buf.extend_from_slice(&low_diff_limit.to_le_bytes());
51    buf.extend_from_slice(&deadband.to_le_bytes());
52    buf
53}
54
55/// Encode CHANGE_OF_STATE parameters: `[count: u32 LE][alarm_values: u32 LE ...]`
56pub fn encode_change_of_state_params(alarm_values: &[u32]) -> Vec<u8> {
57    let mut buf = Vec::with_capacity(4 + alarm_values.len() * 4);
58    buf.extend_from_slice(&(alarm_values.len() as u32).to_le_bytes());
59    for &v in alarm_values {
60        buf.extend_from_slice(&v.to_le_bytes());
61    }
62    buf
63}
64
65/// Encode CHANGE_OF_VALUE parameters: `[increment: f32 LE]`
66pub fn encode_change_of_value_params(increment: f32) -> Vec<u8> {
67    increment.to_le_bytes().to_vec()
68}
69
70/// Encode CHANGE_OF_BITSTRING parameters:
71/// `[mask_len: u32 LE][mask_bytes ...][alarm_bits ...]`
72pub fn encode_change_of_bitstring_params(mask: &[u8], alarm_bits: &[u8]) -> Vec<u8> {
73    let len = mask.len().min(alarm_bits.len());
74    let mut buf = Vec::with_capacity(4 + len * 2);
75    buf.extend_from_slice(&(len as u32).to_le_bytes());
76    buf.extend_from_slice(&mask[..len]);
77    buf.extend_from_slice(&alarm_bits[..len]);
78    buf
79}
80
81// ---- Algorithm evaluation ----
82
83/// Evaluate the OUT_OF_RANGE algorithm.
84///
85/// Compares a real present_value against high/low limits with deadband hysteresis.
86fn eval_out_of_range(params: &[u8], value: f32, current: EventState) -> EventState {
87    if params.len() < 12 {
88        return current;
89    }
90    let high_limit = f32::from_le_bytes([params[0], params[1], params[2], params[3]]);
91    let low_limit = f32::from_le_bytes([params[4], params[5], params[6], params[7]]);
92    let deadband = f32::from_le_bytes([params[8], params[9], params[10], params[11]]);
93
94    match current {
95        s if s == EventState::NORMAL => {
96            if value > high_limit {
97                EventState::HIGH_LIMIT
98            } else if value < low_limit {
99                EventState::LOW_LIMIT
100            } else {
101                EventState::NORMAL
102            }
103        }
104        s if s == EventState::HIGH_LIMIT => {
105            if value < low_limit {
106                EventState::LOW_LIMIT
107            } else if value < high_limit - deadband {
108                EventState::NORMAL
109            } else {
110                EventState::HIGH_LIMIT
111            }
112        }
113        s if s == EventState::LOW_LIMIT => {
114            if value > high_limit {
115                EventState::HIGH_LIMIT
116            } else if value > low_limit + deadband {
117                EventState::NORMAL
118            } else {
119                EventState::LOW_LIMIT
120            }
121        }
122        _ => current,
123    }
124}
125
126/// Evaluate the FLOATING_LIMIT algorithm.
127///
128/// Compares a real present_value against a setpoint +/- differential limits
129/// with deadband hysteresis.
130fn eval_floating_limit(params: &[u8], value: f32, current: EventState) -> EventState {
131    if params.len() < 16 {
132        return current;
133    }
134    let setpoint = f32::from_le_bytes([params[0], params[1], params[2], params[3]]);
135    let high_diff = f32::from_le_bytes([params[4], params[5], params[6], params[7]]);
136    let low_diff = f32::from_le_bytes([params[8], params[9], params[10], params[11]]);
137    let deadband = f32::from_le_bytes([params[12], params[13], params[14], params[15]]);
138
139    let high_limit = setpoint + high_diff;
140    let low_limit = setpoint - low_diff;
141
142    match current {
143        s if s == EventState::NORMAL => {
144            if value > high_limit {
145                EventState::HIGH_LIMIT
146            } else if value < low_limit {
147                EventState::LOW_LIMIT
148            } else {
149                EventState::NORMAL
150            }
151        }
152        s if s == EventState::HIGH_LIMIT => {
153            if value < low_limit {
154                EventState::LOW_LIMIT
155            } else if value < high_limit - deadband {
156                EventState::NORMAL
157            } else {
158                EventState::HIGH_LIMIT
159            }
160        }
161        s if s == EventState::LOW_LIMIT => {
162            if value > high_limit {
163                EventState::HIGH_LIMIT
164            } else if value > low_limit + deadband {
165                EventState::NORMAL
166            } else {
167                EventState::LOW_LIMIT
168            }
169        }
170        _ => current,
171    }
172}
173
174/// Evaluate the CHANGE_OF_STATE algorithm.
175///
176/// OFFNORMAL if the value matches any alarm value, otherwise NORMAL.
177fn eval_change_of_state(params: &[u8], value: u32, _current: EventState) -> EventState {
178    if params.len() < 4 {
179        return EventState::NORMAL;
180    }
181    let count = u32::from_le_bytes([params[0], params[1], params[2], params[3]]) as usize;
182    let needed = 4usize.saturating_add(count.saturating_mul(4));
183    if params.len() < needed {
184        return EventState::NORMAL;
185    }
186    for i in 0..count {
187        let offset = 4 + i * 4;
188        let alarm_val = u32::from_le_bytes([
189            params[offset],
190            params[offset + 1],
191            params[offset + 2],
192            params[offset + 3],
193        ]);
194        if value == alarm_val {
195            return EventState::OFFNORMAL;
196        }
197    }
198    EventState::NORMAL
199}
200
201/// Evaluate the CHANGE_OF_BITSTRING algorithm.
202///
203/// Applies a mask to the monitored bitstring and compares against the alarm pattern.
204fn eval_change_of_bitstring(params: &[u8], value_bits: &[u8], _current: EventState) -> EventState {
205    if params.len() < 4 {
206        return EventState::NORMAL;
207    }
208    let mask_len = u32::from_le_bytes([params[0], params[1], params[2], params[3]]) as usize;
209    let needed = 4usize.saturating_add(mask_len.saturating_mul(2));
210    if params.len() < needed {
211        return EventState::NORMAL;
212    }
213
214    let mask = &params[4..4 + mask_len];
215    let alarm_bits = &params[4 + mask_len..4 + 2 * mask_len];
216
217    for i in 0..mask_len {
218        let monitored_byte = value_bits.get(i).copied().unwrap_or(0);
219        if (monitored_byte & mask[i]) != (alarm_bits[i] & mask[i]) {
220            return EventState::NORMAL;
221        }
222    }
223    EventState::OFFNORMAL
224}
225
226/// Evaluate the CHANGE_OF_VALUE algorithm.
227///
228/// OFFNORMAL if |current_value| >= increment, otherwise NORMAL.
229fn eval_change_of_value(params: &[u8], value: f32, _current: EventState) -> EventState {
230    if params.len() < 4 {
231        return EventState::NORMAL;
232    }
233    let increment = f32::from_le_bytes([params[0], params[1], params[2], params[3]]);
234    if increment <= 0.0 || !increment.is_finite() {
235        return EventState::NORMAL;
236    }
237    if value.abs() >= increment {
238        EventState::OFFNORMAL
239    } else {
240        EventState::NORMAL
241    }
242}
243
244/// Extract a real (f32) value from a PropertyValue.
245fn extract_real(pv: &PropertyValue) -> Option<f32> {
246    match pv {
247        PropertyValue::Real(v) => Some(*v),
248        PropertyValue::Double(v) => Some(*v as f32),
249        PropertyValue::Unsigned(v) => Some(*v as f32),
250        PropertyValue::Signed(v) => Some(*v as f32),
251        _ => None,
252    }
253}
254
255/// Extract an enumerated (u32) value from a PropertyValue.
256fn extract_enumerated(pv: &PropertyValue) -> Option<u32> {
257    match pv {
258        PropertyValue::Enumerated(v) => Some(*v),
259        PropertyValue::Unsigned(v) => Some(*v as u32),
260        _ => None,
261    }
262}
263
264/// Extract bitstring bytes from a PropertyValue.
265fn extract_bitstring(pv: &PropertyValue) -> Option<Vec<u8>> {
266    match pv {
267        PropertyValue::BitString { data, .. } => Some(data.clone()),
268        _ => None,
269    }
270}
271
272/// Read the object_property_reference from an EventEnrollment object.
273///
274/// Returns (monitored_object_id, monitored_property_id) if valid.
275fn read_object_property_ref(
276    enrollment: &dyn bacnet_objects::traits::BACnetObject,
277) -> Option<(ObjectIdentifier, PropertyIdentifier)> {
278    match enrollment.read_property(PropertyIdentifier::OBJECT_PROPERTY_REFERENCE, None) {
279        Ok(PropertyValue::List(ref items)) if items.len() >= 2 => {
280            let obj_id = match &items[0] {
281                PropertyValue::ObjectIdentifier(oid) => *oid,
282                _ => return None,
283            };
284            let prop_id = match &items[1] {
285                PropertyValue::Unsigned(v) => PropertyIdentifier::from_raw(*v as u32),
286                _ => return None,
287            };
288            Some((obj_id, prop_id))
289        }
290        _ => None,
291    }
292}
293
294/// Evaluate all EventEnrollment objects in the database.
295///
296/// For each active enrollment, reads the monitored property, evaluates the
297/// configured algorithm, and returns any state transitions.
298pub fn evaluate_event_enrollments(db: &mut ObjectDatabase) -> Vec<EventEnrollmentTransition> {
299    let oids = db.find_by_type(ObjectType::EVENT_ENROLLMENT);
300
301    let mut updates: Vec<(
302        ObjectIdentifier,
303        ObjectIdentifier,
304        u32,
305        EventState,
306        EventState,
307    )> = Vec::new();
308
309    for oid in &oids {
310        let Some(enrollment) = db.get(oid) else {
311            continue;
312        };
313
314        if let Ok(PropertyValue::Boolean(true)) =
315            enrollment.read_property(PropertyIdentifier::OUT_OF_SERVICE, None)
316        {
317            continue;
318        }
319
320        let event_type_raw = match enrollment.read_property(PropertyIdentifier::EVENT_TYPE, None) {
321            Ok(PropertyValue::Enumerated(v)) => v,
322            _ => continue,
323        };
324
325        let current_state = match enrollment.read_property(PropertyIdentifier::EVENT_STATE, None) {
326            Ok(PropertyValue::Enumerated(v)) => EventState::from_raw(v),
327            _ => continue,
328        };
329
330        let event_enable = match enrollment.read_property(PropertyIdentifier::EVENT_ENABLE, None) {
331            Ok(PropertyValue::BitString { data, .. }) => data.first().map(|b| b >> 5).unwrap_or(0),
332            _ => 0,
333        };
334
335        let params = match enrollment.read_property(PropertyIdentifier::EVENT_PARAMETERS, None) {
336            Ok(PropertyValue::OctetString(bytes)) => bytes,
337            _ => Vec::new(),
338        };
339
340        let Some((monitored_oid, monitored_prop)) = read_object_property_ref(enrollment) else {
341            continue;
342        };
343
344        let Some(monitored_obj) = db.get(&monitored_oid) else {
345            continue;
346        };
347        let monitored_value = match monitored_obj.read_property(monitored_prop, None) {
348            Ok(v) => v,
349            Err(_) => continue,
350        };
351
352        let event_type = EventType::from_raw(event_type_raw);
353        let new_state = if event_type == EventType::OUT_OF_RANGE {
354            let Some(val) = extract_real(&monitored_value) else {
355                continue;
356            };
357            eval_out_of_range(&params, val, current_state)
358        } else if event_type == EventType::FLOATING_LIMIT {
359            let Some(val) = extract_real(&monitored_value) else {
360                continue;
361            };
362            eval_floating_limit(&params, val, current_state)
363        } else if event_type == EventType::CHANGE_OF_STATE {
364            let Some(val) = extract_enumerated(&monitored_value) else {
365                continue;
366            };
367            eval_change_of_state(&params, val, current_state)
368        } else if event_type == EventType::CHANGE_OF_BITSTRING {
369            let Some(bits) = extract_bitstring(&monitored_value) else {
370                continue;
371            };
372            eval_change_of_bitstring(&params, &bits, current_state)
373        } else if event_type == EventType::CHANGE_OF_VALUE {
374            let Some(val) = extract_real(&monitored_value) else {
375                continue;
376            };
377            eval_change_of_value(&params, val, current_state)
378        } else {
379            continue;
380        };
381
382        if new_state == current_state {
383            continue;
384        }
385
386        let transition_enabled = match new_state {
387            s if s == EventState::NORMAL => event_enable & 0x04 != 0,
388            s if s == EventState::HIGH_LIMIT
389                || s == EventState::LOW_LIMIT
390                || s == EventState::OFFNORMAL =>
391            {
392                event_enable & 0x01 != 0
393            }
394            _ => event_enable & 0x02 != 0,
395        };
396
397        if transition_enabled {
398            updates.push((
399                *oid,
400                monitored_oid,
401                event_type_raw,
402                current_state,
403                new_state,
404            ));
405        }
406    }
407
408    let mut transitions = Vec::new();
409    for (oid, monitored_oid, event_type_raw, from_state, to_state) in updates {
410        if let Some(obj) = db.get_mut(&oid) {
411            if obj
412                .write_property(
413                    PropertyIdentifier::EVENT_STATE,
414                    None,
415                    PropertyValue::Enumerated(to_state.to_raw()),
416                    None,
417                )
418                .is_ok()
419            {
420                transitions.push(EventEnrollmentTransition {
421                    enrollment_oid: oid,
422                    monitored_oid,
423                    change: EventStateChange {
424                        from: from_state,
425                        to: to_state,
426                    },
427                    event_type: EventType::from_raw(event_type_raw),
428                });
429            }
430        }
431    }
432
433    transitions
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use bacnet_objects::analog::AnalogInputObject;
440    use bacnet_objects::binary::BinaryInputObject;
441    use bacnet_objects::event_enrollment::EventEnrollmentObject;
442    use bacnet_objects::traits::BACnetObject;
443    use bacnet_types::constructed::BACnetDeviceObjectPropertyReference;
444
445    /// Helper: create an EventEnrollment monitoring an AnalogInput with OUT_OF_RANGE.
446    fn setup_out_of_range(
447        present_value: f32,
448        high_limit: f32,
449        low_limit: f32,
450        deadband: f32,
451    ) -> (ObjectDatabase, ObjectIdentifier, ObjectIdentifier) {
452        let mut db = ObjectDatabase::new();
453
454        // Monitored analog input
455        let mut ai = AnalogInputObject::new(1, "AI-1", 62).unwrap();
456        ai.set_present_value(present_value);
457        let ai_oid = ai.object_identifier();
458        db.add(Box::new(ai)).unwrap();
459
460        // Event enrollment
461        let mut ee =
462            EventEnrollmentObject::new(1, "EE-OOR", EventType::OUT_OF_RANGE.to_raw()).unwrap();
463        ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
464            ai_oid,
465            PropertyIdentifier::PRESENT_VALUE.to_raw(),
466        )));
467        ee.set_event_parameters(encode_out_of_range_params(high_limit, low_limit, deadband));
468        ee.set_event_enable(0x07); // all transitions
469        let ee_oid = ee.object_identifier();
470        db.add(Box::new(ee)).unwrap();
471
472        (db, ee_oid, ai_oid)
473    }
474
475    /// Helper: create an EventEnrollment monitoring an AnalogInput with FLOATING_LIMIT.
476    fn setup_floating_limit(
477        present_value: f32,
478        setpoint: f32,
479        high_diff: f32,
480        low_diff: f32,
481        deadband: f32,
482    ) -> (ObjectDatabase, ObjectIdentifier, ObjectIdentifier) {
483        let mut db = ObjectDatabase::new();
484
485        let mut ai = AnalogInputObject::new(2, "AI-2", 62).unwrap();
486        ai.set_present_value(present_value);
487        let ai_oid = ai.object_identifier();
488        db.add(Box::new(ai)).unwrap();
489
490        let mut ee =
491            EventEnrollmentObject::new(2, "EE-FL", EventType::FLOATING_LIMIT.to_raw()).unwrap();
492        ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
493            ai_oid,
494            PropertyIdentifier::PRESENT_VALUE.to_raw(),
495        )));
496        ee.set_event_parameters(encode_floating_limit_params(
497            setpoint, high_diff, low_diff, deadband,
498        ));
499        ee.set_event_enable(0x07);
500        let ee_oid = ee.object_identifier();
501        db.add(Box::new(ee)).unwrap();
502
503        (db, ee_oid, ai_oid)
504    }
505
506    /// Helper: create an EventEnrollment monitoring a BinaryInput with CHANGE_OF_STATE.
507    fn setup_change_of_state(
508        present_value: u32,
509        alarm_values: &[u32],
510    ) -> (ObjectDatabase, ObjectIdentifier, ObjectIdentifier) {
511        let mut db = ObjectDatabase::new();
512
513        let mut bi = BinaryInputObject::new(1, "BI-1").unwrap();
514        bi.set_present_value(present_value);
515        let bi_oid = bi.object_identifier();
516        db.add(Box::new(bi)).unwrap();
517
518        let mut ee =
519            EventEnrollmentObject::new(3, "EE-COS", EventType::CHANGE_OF_STATE.to_raw()).unwrap();
520        ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
521            bi_oid,
522            PropertyIdentifier::PRESENT_VALUE.to_raw(),
523        )));
524        ee.set_event_parameters(encode_change_of_state_params(alarm_values));
525        ee.set_event_enable(0x07);
526        let ee_oid = ee.object_identifier();
527        db.add(Box::new(ee)).unwrap();
528
529        (db, ee_oid, bi_oid)
530    }
531
532    // ---- OUT_OF_RANGE tests ----
533
534    #[test]
535    fn out_of_range_normal_stays_normal() {
536        let (mut db, _ee_oid, _ai_oid) = setup_out_of_range(50.0, 80.0, 20.0, 2.0);
537        let transitions = evaluate_event_enrollments(&mut db);
538        assert!(transitions.is_empty());
539    }
540
541    #[test]
542    fn out_of_range_normal_to_high_limit() {
543        let (mut db, ee_oid, ai_oid) = setup_out_of_range(85.0, 80.0, 20.0, 2.0);
544        let transitions = evaluate_event_enrollments(&mut db);
545        assert_eq!(transitions.len(), 1);
546        assert_eq!(transitions[0].enrollment_oid, ee_oid);
547        assert_eq!(transitions[0].monitored_oid, ai_oid);
548        assert_eq!(transitions[0].change.from, EventState::NORMAL);
549        assert_eq!(transitions[0].change.to, EventState::HIGH_LIMIT);
550        assert_eq!(transitions[0].event_type, EventType::OUT_OF_RANGE);
551
552        // Verify event_state was persisted
553        let obj = db.get(&ee_oid).unwrap();
554        assert_eq!(
555            obj.read_property(PropertyIdentifier::EVENT_STATE, None)
556                .unwrap(),
557            PropertyValue::Enumerated(EventState::HIGH_LIMIT.to_raw())
558        );
559    }
560
561    #[test]
562    fn out_of_range_normal_to_low_limit() {
563        let (mut db, ee_oid, _ai_oid) = setup_out_of_range(15.0, 80.0, 20.0, 2.0);
564        let transitions = evaluate_event_enrollments(&mut db);
565        assert_eq!(transitions.len(), 1);
566        assert_eq!(transitions[0].change.from, EventState::NORMAL);
567        assert_eq!(transitions[0].change.to, EventState::LOW_LIMIT);
568
569        // Verify persisted state
570        let obj = db.get(&ee_oid).unwrap();
571        assert_eq!(
572            obj.read_property(PropertyIdentifier::EVENT_STATE, None)
573                .unwrap(),
574            PropertyValue::Enumerated(EventState::LOW_LIMIT.to_raw())
575        );
576    }
577
578    #[test]
579    fn out_of_range_high_to_normal_with_deadband() {
580        let (mut db, ee_oid, ai_oid) = setup_out_of_range(85.0, 80.0, 20.0, 2.0);
581        // First: go to HIGH_LIMIT
582        evaluate_event_enrollments(&mut db);
583
584        // Update monitored value — still within deadband (80 - 2 = 78)
585        let ai = db.get_mut(&ai_oid).unwrap();
586        ai.write_property(
587            PropertyIdentifier::OUT_OF_SERVICE,
588            None,
589            PropertyValue::Boolean(true),
590            None,
591        )
592        .unwrap();
593        ai.write_property(
594            PropertyIdentifier::PRESENT_VALUE,
595            None,
596            PropertyValue::Real(79.0),
597            None,
598        )
599        .unwrap();
600
601        let transitions = evaluate_event_enrollments(&mut db);
602        assert!(transitions.is_empty(), "within deadband — no transition");
603
604        // Drop below deadband
605        let ai = db.get_mut(&ai_oid).unwrap();
606        ai.write_property(
607            PropertyIdentifier::PRESENT_VALUE,
608            None,
609            PropertyValue::Real(77.0),
610            None,
611        )
612        .unwrap();
613
614        let transitions = evaluate_event_enrollments(&mut db);
615        assert_eq!(transitions.len(), 1);
616        assert_eq!(transitions[0].change.from, EventState::HIGH_LIMIT);
617        assert_eq!(transitions[0].change.to, EventState::NORMAL);
618
619        let obj = db.get(&ee_oid).unwrap();
620        assert_eq!(
621            obj.read_property(PropertyIdentifier::EVENT_STATE, None)
622                .unwrap(),
623            PropertyValue::Enumerated(EventState::NORMAL.to_raw())
624        );
625    }
626
627    #[test]
628    fn out_of_range_no_change_when_already_faulted() {
629        let (mut db, _ee_oid, _ai_oid) = setup_out_of_range(85.0, 80.0, 20.0, 2.0);
630        let t1 = evaluate_event_enrollments(&mut db);
631        assert_eq!(t1.len(), 1);
632
633        // Second evaluation: same state, no new transition
634        let t2 = evaluate_event_enrollments(&mut db);
635        assert!(t2.is_empty());
636    }
637
638    #[test]
639    fn out_of_range_event_enable_suppresses_notification() {
640        let mut db = ObjectDatabase::new();
641
642        let mut ai = AnalogInputObject::new(10, "AI-10", 62).unwrap();
643        ai.set_present_value(85.0);
644        let ai_oid = ai.object_identifier();
645        db.add(Box::new(ai)).unwrap();
646
647        let mut ee =
648            EventEnrollmentObject::new(10, "EE-sup", EventType::OUT_OF_RANGE.to_raw()).unwrap();
649        ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
650            ai_oid,
651            PropertyIdentifier::PRESENT_VALUE.to_raw(),
652        )));
653        ee.set_event_parameters(encode_out_of_range_params(80.0, 20.0, 2.0));
654        ee.set_event_enable(0x04); // only TO_NORMAL enabled
655        let ee_oid = ee.object_identifier();
656        db.add(Box::new(ee)).unwrap();
657
658        // TO_OFFNORMAL not enabled — should not appear in transitions
659        let transitions = evaluate_event_enrollments(&mut db);
660        assert!(transitions.is_empty());
661
662        // But event_state should NOT have been updated (notification suppressed)
663        let obj = db.get(&ee_oid).unwrap();
664        assert_eq!(
665            obj.read_property(PropertyIdentifier::EVENT_STATE, None)
666                .unwrap(),
667            PropertyValue::Enumerated(EventState::NORMAL.to_raw())
668        );
669    }
670
671    #[test]
672    fn out_of_range_skips_out_of_service() {
673        let (mut db, ee_oid, _ai_oid) = setup_out_of_range(85.0, 80.0, 20.0, 2.0);
674
675        // Set enrollment to out-of-service
676        let obj = db.get_mut(&ee_oid).unwrap();
677        obj.write_property(
678            PropertyIdentifier::OUT_OF_SERVICE,
679            None,
680            PropertyValue::Boolean(true),
681            None,
682        )
683        .unwrap();
684
685        let transitions = evaluate_event_enrollments(&mut db);
686        assert!(transitions.is_empty());
687    }
688
689    // ---- FLOATING_LIMIT tests ----
690
691    #[test]
692    fn floating_limit_normal_stays_normal() {
693        // setpoint=50, high_diff=10, low_diff=10 → limits at 60/40
694        let (mut db, _ee_oid, _ai_oid) = setup_floating_limit(50.0, 50.0, 10.0, 10.0, 2.0);
695        let transitions = evaluate_event_enrollments(&mut db);
696        assert!(transitions.is_empty());
697    }
698
699    #[test]
700    fn floating_limit_to_high() {
701        // setpoint=50, high_diff=10 → high_limit=60; value=65 exceeds
702        let (mut db, ee_oid, ai_oid) = setup_floating_limit(65.0, 50.0, 10.0, 10.0, 2.0);
703        let transitions = evaluate_event_enrollments(&mut db);
704        assert_eq!(transitions.len(), 1);
705        assert_eq!(transitions[0].enrollment_oid, ee_oid);
706        assert_eq!(transitions[0].monitored_oid, ai_oid);
707        assert_eq!(transitions[0].change.from, EventState::NORMAL);
708        assert_eq!(transitions[0].change.to, EventState::HIGH_LIMIT);
709        assert_eq!(transitions[0].event_type, EventType::FLOATING_LIMIT);
710    }
711
712    #[test]
713    fn floating_limit_to_low() {
714        // setpoint=50, low_diff=10 → low_limit=40; value=35 below
715        let (mut db, _ee_oid, _ai_oid) = setup_floating_limit(35.0, 50.0, 10.0, 10.0, 2.0);
716        let transitions = evaluate_event_enrollments(&mut db);
717        assert_eq!(transitions.len(), 1);
718        assert_eq!(transitions[0].change.to, EventState::LOW_LIMIT);
719    }
720
721    #[test]
722    fn floating_limit_deadband_hysteresis() {
723        // setpoint=50, high_diff=10, deadband=2 → high_limit=60, return threshold=58
724        let (mut db, _ee_oid, ai_oid) = setup_floating_limit(65.0, 50.0, 10.0, 10.0, 2.0);
725        evaluate_event_enrollments(&mut db);
726
727        // Still above return threshold (58)
728        let ai = db.get_mut(&ai_oid).unwrap();
729        ai.write_property(
730            PropertyIdentifier::OUT_OF_SERVICE,
731            None,
732            PropertyValue::Boolean(true),
733            None,
734        )
735        .unwrap();
736        ai.write_property(
737            PropertyIdentifier::PRESENT_VALUE,
738            None,
739            PropertyValue::Real(59.0),
740            None,
741        )
742        .unwrap();
743        let transitions = evaluate_event_enrollments(&mut db);
744        assert!(transitions.is_empty());
745
746        // Below return threshold
747        let ai = db.get_mut(&ai_oid).unwrap();
748        ai.write_property(
749            PropertyIdentifier::PRESENT_VALUE,
750            None,
751            PropertyValue::Real(57.0),
752            None,
753        )
754        .unwrap();
755        let transitions = evaluate_event_enrollments(&mut db);
756        assert_eq!(transitions.len(), 1);
757        assert_eq!(transitions[0].change.to, EventState::NORMAL);
758    }
759
760    // ---- CHANGE_OF_STATE tests ----
761
762    #[test]
763    fn change_of_state_normal_when_not_in_alarm_set() {
764        // Binary INACTIVE (0), alarm on ACTIVE (1)
765        let (mut db, _ee_oid, _bi_oid) = setup_change_of_state(0, &[1]);
766        let transitions = evaluate_event_enrollments(&mut db);
767        assert!(transitions.is_empty());
768    }
769
770    #[test]
771    fn change_of_state_to_offnormal() {
772        // Binary ACTIVE (1), alarm on ACTIVE (1)
773        let (mut db, ee_oid, bi_oid) = setup_change_of_state(1, &[1]);
774        let transitions = evaluate_event_enrollments(&mut db);
775        assert_eq!(transitions.len(), 1);
776        assert_eq!(transitions[0].enrollment_oid, ee_oid);
777        assert_eq!(transitions[0].monitored_oid, bi_oid);
778        assert_eq!(transitions[0].change.from, EventState::NORMAL);
779        assert_eq!(transitions[0].change.to, EventState::OFFNORMAL);
780        assert_eq!(transitions[0].event_type, EventType::CHANGE_OF_STATE);
781    }
782
783    #[test]
784    fn change_of_state_back_to_normal() {
785        let (mut db, _ee_oid, bi_oid) = setup_change_of_state(1, &[1]);
786        evaluate_event_enrollments(&mut db);
787
788        // Set monitored value to non-alarm
789        let bi = db.get_mut(&bi_oid).unwrap();
790        bi.write_property(
791            PropertyIdentifier::OUT_OF_SERVICE,
792            None,
793            PropertyValue::Boolean(true),
794            None,
795        )
796        .unwrap();
797        bi.write_property(
798            PropertyIdentifier::PRESENT_VALUE,
799            None,
800            PropertyValue::Enumerated(0),
801            None,
802        )
803        .unwrap();
804
805        let transitions = evaluate_event_enrollments(&mut db);
806        assert_eq!(transitions.len(), 1);
807        assert_eq!(transitions[0].change.from, EventState::OFFNORMAL);
808        assert_eq!(transitions[0].change.to, EventState::NORMAL);
809    }
810
811    #[test]
812    fn change_of_state_multiple_alarm_values() {
813        // Alarm on values 1, 3, 5
814        let (mut db, _ee_oid, _bi_oid) = setup_change_of_state(3, &[1, 3, 5]);
815        let transitions = evaluate_event_enrollments(&mut db);
816        assert_eq!(transitions.len(), 1);
817        assert_eq!(transitions[0].change.to, EventState::OFFNORMAL);
818    }
819
820    // ---- CHANGE_OF_BITSTRING tests ----
821
822    #[test]
823    fn change_of_bitstring_normal() {
824        let mut db = ObjectDatabase::new();
825
826        // Create an object with a bitstring property (using a multistate or similar)
827        // For testing, we'll use an EventEnrollment monitoring another enrollment's EVENT_ENABLE
828        let mut target =
829            EventEnrollmentObject::new(50, "Target", EventType::NONE.to_raw()).unwrap();
830        // EVENT_ENABLE is a 3-bit bitstring
831        target.set_event_enable(0x05); // bits: TO_OFFNORMAL | TO_NORMAL
832        let target_oid = target.object_identifier();
833        db.add(Box::new(target)).unwrap();
834
835        let mut ee =
836            EventEnrollmentObject::new(51, "EE-COBS", EventType::CHANGE_OF_BITSTRING.to_raw())
837                .unwrap();
838        ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
839            target_oid,
840            PropertyIdentifier::EVENT_ENABLE.to_raw(),
841        )));
842        // mask=0xFF, alarm_pattern=0xE0 (all 3 high bits set)
843        ee.set_event_parameters(encode_change_of_bitstring_params(&[0xFF], &[0xE0]));
844        ee.set_event_enable(0x07);
845        db.add(Box::new(ee)).unwrap();
846
847        let transitions = evaluate_event_enrollments(&mut db);
848        // 0x05 << 5 = 0xA0, mask 0xFF → 0xA0, alarm 0xE0 → no match → NORMAL
849        assert!(transitions.is_empty());
850    }
851
852    #[test]
853    fn change_of_bitstring_offnormal() {
854        let mut db = ObjectDatabase::new();
855
856        let mut target =
857            EventEnrollmentObject::new(60, "Target2", EventType::NONE.to_raw()).unwrap();
858        target.set_event_enable(0x07); // all 3 bits set
859        let target_oid = target.object_identifier();
860        db.add(Box::new(target)).unwrap();
861
862        let mut ee =
863            EventEnrollmentObject::new(61, "EE-COBS2", EventType::CHANGE_OF_BITSTRING.to_raw())
864                .unwrap();
865        ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
866            target_oid,
867            PropertyIdentifier::EVENT_ENABLE.to_raw(),
868        )));
869        // mask=0xE0, alarm_pattern=0xE0 (all 3 high bits)
870        ee.set_event_parameters(encode_change_of_bitstring_params(&[0xE0], &[0xE0]));
871        ee.set_event_enable(0x07);
872        db.add(Box::new(ee)).unwrap();
873
874        let transitions = evaluate_event_enrollments(&mut db);
875        // 0x07 << 5 = 0xE0, mask 0xE0 → 0xE0, alarm 0xE0 → match → OFFNORMAL
876        assert_eq!(transitions.len(), 1);
877        assert_eq!(transitions[0].change.to, EventState::OFFNORMAL);
878    }
879
880    // ---- CHANGE_OF_VALUE tests ----
881
882    #[test]
883    fn change_of_value_within_increment() {
884        let mut db = ObjectDatabase::new();
885
886        let mut ai = AnalogInputObject::new(70, "AI-COV", 62).unwrap();
887        ai.set_present_value(3.0);
888        let ai_oid = ai.object_identifier();
889        db.add(Box::new(ai)).unwrap();
890
891        let mut ee =
892            EventEnrollmentObject::new(70, "EE-COV", EventType::CHANGE_OF_VALUE.to_raw()).unwrap();
893        ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
894            ai_oid,
895            PropertyIdentifier::PRESENT_VALUE.to_raw(),
896        )));
897        ee.set_event_parameters(encode_change_of_value_params(5.0));
898        ee.set_event_enable(0x07);
899        db.add(Box::new(ee)).unwrap();
900
901        // |3.0| < 5.0 → NORMAL
902        let transitions = evaluate_event_enrollments(&mut db);
903        assert!(transitions.is_empty());
904    }
905
906    #[test]
907    fn change_of_value_exceeds_increment() {
908        let mut db = ObjectDatabase::new();
909
910        let mut ai = AnalogInputObject::new(71, "AI-COV2", 62).unwrap();
911        ai.set_present_value(10.0);
912        let ai_oid = ai.object_identifier();
913        db.add(Box::new(ai)).unwrap();
914
915        let mut ee =
916            EventEnrollmentObject::new(71, "EE-COV2", EventType::CHANGE_OF_VALUE.to_raw()).unwrap();
917        ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
918            ai_oid,
919            PropertyIdentifier::PRESENT_VALUE.to_raw(),
920        )));
921        ee.set_event_parameters(encode_change_of_value_params(5.0));
922        ee.set_event_enable(0x07);
923        db.add(Box::new(ee)).unwrap();
924
925        // |10.0| >= 5.0 → OFFNORMAL
926        let transitions = evaluate_event_enrollments(&mut db);
927        assert_eq!(transitions.len(), 1);
928        assert_eq!(transitions[0].change.to, EventState::OFFNORMAL);
929    }
930
931    // ---- Integration: multiple enrollments ----
932
933    #[test]
934    fn evaluates_multiple_enrollments() {
935        let mut db = ObjectDatabase::new();
936
937        // Two analog inputs
938        let mut ai1 = AnalogInputObject::new(80, "AI-80", 62).unwrap();
939        ai1.set_present_value(90.0); // will trigger HIGH_LIMIT
940        let ai1_oid = ai1.object_identifier();
941        db.add(Box::new(ai1)).unwrap();
942
943        let mut ai2 = AnalogInputObject::new(81, "AI-81", 62).unwrap();
944        ai2.set_present_value(50.0); // normal
945        let ai2_oid = ai2.object_identifier();
946        db.add(Box::new(ai2)).unwrap();
947
948        // Two enrollments
949        let mut ee1 =
950            EventEnrollmentObject::new(80, "EE-80", EventType::OUT_OF_RANGE.to_raw()).unwrap();
951        ee1.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
952            ai1_oid,
953            PropertyIdentifier::PRESENT_VALUE.to_raw(),
954        )));
955        ee1.set_event_parameters(encode_out_of_range_params(80.0, 20.0, 2.0));
956        ee1.set_event_enable(0x07);
957        db.add(Box::new(ee1)).unwrap();
958
959        let mut ee2 =
960            EventEnrollmentObject::new(81, "EE-81", EventType::OUT_OF_RANGE.to_raw()).unwrap();
961        ee2.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
962            ai2_oid,
963            PropertyIdentifier::PRESENT_VALUE.to_raw(),
964        )));
965        ee2.set_event_parameters(encode_out_of_range_params(80.0, 20.0, 2.0));
966        ee2.set_event_enable(0x07);
967        db.add(Box::new(ee2)).unwrap();
968
969        let transitions = evaluate_event_enrollments(&mut db);
970        // Only AI-80 triggers (90 > 80)
971        assert_eq!(transitions.len(), 1);
972        assert_eq!(transitions[0].monitored_oid, ai1_oid);
973    }
974
975    #[test]
976    fn missing_monitored_object_is_skipped() {
977        let mut db = ObjectDatabase::new();
978
979        let fake_oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 999).unwrap();
980        let mut ee =
981            EventEnrollmentObject::new(90, "EE-miss", EventType::OUT_OF_RANGE.to_raw()).unwrap();
982        ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
983            fake_oid,
984            PropertyIdentifier::PRESENT_VALUE.to_raw(),
985        )));
986        ee.set_event_parameters(encode_out_of_range_params(80.0, 20.0, 2.0));
987        ee.set_event_enable(0x07);
988        db.add(Box::new(ee)).unwrap();
989
990        // Should not panic or return transitions
991        let transitions = evaluate_event_enrollments(&mut db);
992        assert!(transitions.is_empty());
993    }
994
995    #[test]
996    fn no_reference_is_skipped() {
997        let mut db = ObjectDatabase::new();
998
999        let ee =
1000            EventEnrollmentObject::new(91, "EE-noref", EventType::OUT_OF_RANGE.to_raw()).unwrap();
1001        db.add(Box::new(ee)).unwrap();
1002
1003        let transitions = evaluate_event_enrollments(&mut db);
1004        assert!(transitions.is_empty());
1005    }
1006
1007    #[test]
1008    fn empty_parameters_is_skipped() {
1009        let mut db = ObjectDatabase::new();
1010
1011        let mut ai = AnalogInputObject::new(92, "AI-92", 62).unwrap();
1012        ai.set_present_value(100.0);
1013        let ai_oid = ai.object_identifier();
1014        db.add(Box::new(ai)).unwrap();
1015
1016        let mut ee =
1017            EventEnrollmentObject::new(92, "EE-noparam", EventType::OUT_OF_RANGE.to_raw()).unwrap();
1018        ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
1019            ai_oid,
1020            PropertyIdentifier::PRESENT_VALUE.to_raw(),
1021        )));
1022        // No parameters set — should remain at current state
1023        ee.set_event_enable(0x07);
1024        db.add(Box::new(ee)).unwrap();
1025
1026        let transitions = evaluate_event_enrollments(&mut db);
1027        assert!(transitions.is_empty());
1028    }
1029}