use super::super::*;
use bacnet_objects::analog::AnalogInputObject;
use bacnet_objects::binary::BinaryInputObject;
use bacnet_objects::event_enrollment::EventEnrollmentObject;
use bacnet_objects::traits::BACnetObject;
use bacnet_types::constructed::{
BACnetDeviceObjectPropertyReference, BACnetEventParameter, BACnetPropertyStates,
};
use bacnet_types::enums::EngineeringUnits;
fn setup_oor(
present_value: f32,
high_limit: f32,
low_limit: f32,
deadband: f32,
time_delay: u32,
tdn: Option<u32>,
) -> (ObjectDatabase, ObjectIdentifier, ObjectIdentifier) {
let mut db = ObjectDatabase::new();
let mut ai = AnalogInputObject::new(1, "AI-1", EngineeringUnits::DEGREES_CELSIUS).unwrap();
ai.set_present_value(present_value);
let ai_oid = ai.object_identifier();
db.add(Box::new(ai)).unwrap();
let mut ee = EventEnrollmentObject::new(1, "EE-OOR", EventType::OUT_OF_RANGE).unwrap();
ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
ai_oid,
PropertyIdentifier::PRESENT_VALUE.to_raw(),
)))
.unwrap();
ee.set_event_parameters(BACnetEventParameter::OutOfRange {
time_delay,
low_limit,
high_limit,
deadband,
});
ee.set_event_enable(EventTransitionBits::all());
ee.set_time_delay_normal(tdn);
let ee_oid = ee.object_identifier();
db.add(Box::new(ee)).unwrap();
(db, ee_oid, ai_oid)
}
fn set_monitored(db: &mut ObjectDatabase, ai_oid: &ObjectIdentifier, value: f32) {
let ai = db.get_mut(ai_oid).unwrap();
ai.write_property(
PropertyIdentifier::OUT_OF_SERVICE,
None,
PropertyValue::Boolean(true),
None,
)
.unwrap();
ai.write_property(
PropertyIdentifier::PRESENT_VALUE,
None,
PropertyValue::Real(value),
None,
)
.unwrap();
}
fn event_state(db: &ObjectDatabase, ee_oid: &ObjectIdentifier) -> EventState {
match db
.get(ee_oid)
.unwrap()
.read_property(PropertyIdentifier::EVENT_STATE, None)
.unwrap()
{
PropertyValue::Enumerated(v) => EventState::from_raw(v),
other => panic!("EVENT_STATE must read Enumerated, got {other:?}"),
}
}
#[test]
fn out_of_range_time_delay_gates_offnormal_transition() {
let (mut db, ee_oid, _ai_oid) = setup_oor(85.0, 80.0, 20.0, 2.0, 3, None);
for pass in 1..=3 {
let transitions = evaluate_event_enrollments(&mut db, 1);
assert!(
transitions.is_empty(),
"pass {pass}: countdown must still be running, got {transitions:?}"
);
assert_eq!(
event_state(&db, &ee_oid),
EventState::NORMAL,
"pass {pass}: observable Event_State holds at the confirmed state while delayed"
);
}
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1, "pass 4: the delay elapsed");
assert_eq!(transitions[0].change.from, EventState::NORMAL);
assert_eq!(transitions[0].change.to, EventState::HIGH_LIMIT);
assert_eq!(event_state(&db, &ee_oid), EventState::HIGH_LIMIT);
}
#[test]
fn time_delay_normal_gates_only_the_return_to_normal() {
let (mut db, ee_oid, ai_oid) = setup_oor(50.0, 80.0, 20.0, 2.0, 0, Some(3));
set_monitored(&mut db, &ai_oid, 85.0);
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].change.to, EventState::HIGH_LIMIT);
set_monitored(&mut db, &ai_oid, 77.0);
for pass in 1..=3 {
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"recovery pass {pass}: TDN countdown must still be running"
);
assert_eq!(event_state(&db, &ee_oid), EventState::HIGH_LIMIT);
}
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1, "TDN elapsed on recovery pass 4");
assert_eq!(transitions[0].change.to, EventState::NORMAL);
set_monitored(&mut db, &ai_oid, 90.0);
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].change.to, EventState::HIGH_LIMIT);
}
#[test]
fn absent_time_delay_normal_falls_back_to_time_delay() {
let (mut db, ee_oid, ai_oid) = setup_oor(85.0, 80.0, 20.0, 2.0, 2, None);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT
);
set_monitored(&mut db, &ai_oid, 50.0);
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"TDN absent: recovery waits the SAME Time_Delay (fallback)"
);
assert_eq!(event_state(&db, &ee_oid), EventState::HIGH_LIMIT);
let transitions = evaluate_event_enrollments(&mut db, 1);
assert!(
transitions.is_empty(),
"second recovery pass: still counting (fallback TD=2, not immediate)"
);
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::NORMAL,
"fallback delay elapsed"
);
}
#[test]
fn time_delay_and_time_delay_normal_are_independent() {
let (mut db, ee_oid, ai_oid) = setup_oor(50.0, 80.0, 20.0, 2.0, 5, Some(1));
set_monitored(&mut db, &ai_oid, 85.0);
for pass in 1..=5 {
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"offnormal pass {pass}: TD=5 must still be counting"
);
}
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT
);
set_monitored(&mut db, &ai_oid, 50.0);
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"recovery pass 1: TDN=1 seeded, not yet fired"
);
assert_eq!(event_state(&db, &ee_oid), EventState::HIGH_LIMIT);
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::NORMAL,
"recovery pass 2: TDN=1 elapsed — the offnormal delay plays no part"
);
}
#[test]
fn pending_cancels_when_condition_reverts() {
let (mut db, ee_oid, ai_oid) = setup_oor(50.0, 80.0, 20.0, 2.0, 3, None);
set_monitored(&mut db, &ai_oid, 85.0);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty()); assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
set_monitored(&mut db, &ai_oid, 50.0);
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"reverted condition cancels without firing"
);
assert_eq!(event_state(&db, &ee_oid), EventState::NORMAL);
set_monitored(&mut db, &ai_oid, 85.0);
for pass in 1..=3 {
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"fresh excursion pass {pass}: full delay must re-run"
);
}
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT
);
}
#[test]
fn condition_target_change_mid_delay_reseeds() {
let (mut db, ee_oid, ai_oid) = setup_oor(50.0, 80.0, 20.0, 2.0, 3, None);
set_monitored(&mut db, &ai_oid, 85.0);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty()); assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
set_monitored(&mut db, &ai_oid, 15.0);
for pass in 1..=3 {
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"post-re-target pass {pass}: the new LOW_LIMIT condition counts from 3"
);
assert_eq!(event_state(&db, &ee_oid), EventState::NORMAL);
}
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].change.from, EventState::NORMAL);
assert_eq!(transitions[0].change.to, EventState::LOW_LIMIT);
}
#[test]
fn parameter_change_mid_pending_cancels_and_regates() {
let (mut db, ee_oid, _ai_oid) = setup_oor(85.0, 80.0, 20.0, 2.0, 2, None);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty()); assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
let mut scratch =
EventEnrollmentObject::new(1, "scratch", EventType::CHANGE_OF_BITSTRING).unwrap();
scratch.set_event_parameters(BACnetEventParameter::OutOfRange {
time_delay: 5,
low_limit: 20.0,
high_limit: 80.0,
deadband: 2.0,
});
let reframed = scratch
.read_property(PropertyIdentifier::EVENT_PARAMETERS, None)
.unwrap();
db.get_mut(&ee_oid)
.unwrap()
.write_property(PropertyIdentifier::EVENT_PARAMETERS, None, reframed, None)
.unwrap();
for pass in 1..=5 {
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"regated pass {pass}: cancelled+re-seeded countdown must not fire early"
);
}
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT,
"the regated delay (5) eventually elapses"
);
}
#[test]
fn time_delay_normal_change_mid_pending_regates() {
let (mut db, ee_oid, ai_oid) = setup_oor(85.0, 80.0, 20.0, 2.0, 0, Some(3));
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT,
"TD=0: offnormal direction immediate"
);
set_monitored(&mut db, &ai_oid, 50.0);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty()); assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
db.get_mut(&ee_oid)
.unwrap()
.write_property(
PropertyIdentifier::TIME_DELAY_NORMAL,
None,
PropertyValue::Unsigned(1),
None,
)
.unwrap();
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"TDN change cancels the old countdown and re-seeds at the new delay"
);
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::NORMAL,
"the regated TDN=1 delay elapses and fires"
);
}
#[test]
fn change_of_state_delays_both_directions() {
let mut db = ObjectDatabase::new();
let mut bi = BinaryInputObject::new(1, "BI-1").unwrap();
bi.set_present_value(1);
let bi_oid = bi.object_identifier();
db.add(Box::new(bi)).unwrap();
let mut ee = EventEnrollmentObject::new(3, "EE-COS", EventType::CHANGE_OF_STATE).unwrap();
ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
bi_oid,
PropertyIdentifier::PRESENT_VALUE.to_raw(),
)))
.unwrap();
ee.set_event_parameters(BACnetEventParameter::ChangeOfState {
time_delay: 2,
list_of_values: vec![BACnetPropertyStates::BinaryValue(1)],
});
ee.set_event_enable(EventTransitionBits::all());
let ee_oid = ee.object_identifier();
db.add(Box::new(ee)).unwrap();
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::OFFNORMAL
);
let bi = db.get_mut(&bi_oid).unwrap();
bi.write_property(
PropertyIdentifier::OUT_OF_SERVICE,
None,
PropertyValue::Boolean(true),
None,
)
.unwrap();
bi.write_property(
PropertyIdentifier::PRESENT_VALUE,
None,
PropertyValue::Enumerated(0),
None,
)
.unwrap();
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"recovery seeded, not fired"
);
assert_eq!(event_state(&db, &ee_oid), EventState::OFFNORMAL);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::NORMAL
);
}
#[test]
fn passes_for_delay_ceiling_semantics() {
assert_eq!(super::super::passes_for_delay(5, 10), 1);
assert_eq!(super::super::passes_for_delay(10, 10), 1);
assert_eq!(super::super::passes_for_delay(15, 10), 2);
assert_eq!(super::super::passes_for_delay(25, 10), 3);
assert_eq!(
super::super::passes_for_delay(1, 3600),
1,
"never fires early"
);
assert_eq!(
super::super::passes_for_delay(u32::MAX, 1),
u32::MAX,
"huge delays saturate rather than wrap"
);
}
#[test]
fn delay_seconds_convert_to_passes_at_tick_boundaries() {
let (mut db, ee_oid, _ai_oid) = setup_oor(85.0, 80.0, 20.0, 2.0, 25, None);
for pass in 1..=3 {
assert!(
evaluate_event_enrollments(&mut db, 10).is_empty(),
"interval=10s, TD=25s: pass {pass} of ceil(25/10)= normalized must not fire"
);
}
assert_eq!(
evaluate_event_enrollments(&mut db, 10)[0].change.to,
EventState::HIGH_LIMIT,
"the fourth pass (>= ceil boundary) fires"
);
assert_eq!(event_state(&db, &ee_oid), EventState::HIGH_LIMIT);
}
#[test]
fn params_round_trip_does_not_resume_stale_countdown() {
let (mut db, ee_oid, ai_oid) = setup_oor(85.0, 80.0, 20.0, 2.0, 2, None);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty()); assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
set_oor_params(&mut db, &ee_oid, 3, 21.0, 81.0);
let removed = db.remove(&ai_oid).unwrap().expect("fixture AI present");
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
set_oor_params(&mut db, &ee_oid, 2, 20.0, 80.0);
db.add(removed).unwrap();
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"re-gate seeds a fresh countdown"
);
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"full delay re-runs: second pass must not fire"
);
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT,
"fires only after the re-gated delay fully elapses"
);
let _ = ee_oid;
}
#[test]
fn back_to_back_params_round_trip_cancels_stale_countdown() {
let (mut db, ee_oid, _ai_oid) = setup_oor(85.0, 80.0, 20.0, 2.0, 2, None);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
set_oor_params(&mut db, &ee_oid, 3, 21.0, 81.0);
set_oor_params(&mut db, &ee_oid, 2, 20.0, 80.0);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT
);
}
fn set_oor_params(
db: &mut ObjectDatabase,
ee_oid: &ObjectIdentifier,
td: u32,
low: f32,
high: f32,
) {
let mut scratch =
EventEnrollmentObject::new(1, "scratch", EventType::CHANGE_OF_BITSTRING).unwrap();
scratch.set_event_parameters(BACnetEventParameter::OutOfRange {
time_delay: td,
low_limit: low,
high_limit: high,
deadband: 2.0,
});
let reframed = scratch
.read_property(PropertyIdentifier::EVENT_PARAMETERS, None)
.unwrap();
db.get_mut(ee_oid)
.unwrap()
.write_property(PropertyIdentifier::EVENT_PARAMETERS, None, reframed, None)
.unwrap();
}
#[test]
fn fingerprint_covers_monitored_reference() {
let params = BACnetEventParameter::OutOfRange {
time_delay: 2,
low_limit: 20.0,
high_limit: 80.0,
deadband: 2.0,
};
let ai1 = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
let ai2 = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 2).unwrap();
let pv = PropertyIdentifier::PRESENT_VALUE;
let cf = PropertyIdentifier::COV_INCREMENT;
let monitored =
|oid, property, index| super::super::MonitoredReference::local(oid, property, index);
let base = super::super::params_fingerprint(
¶ms,
2,
EventType::OUT_OF_RANGE,
&monitored(ai1, pv, None),
)
.unwrap();
assert_ne!(
base,
super::super::params_fingerprint(
¶ms,
2,
EventType::OUT_OF_RANGE,
&monitored(ai2, pv, None),
)
.unwrap(),
"different monitored object must fingerprint differently"
);
assert_ne!(
base,
super::super::params_fingerprint(
¶ms,
2,
EventType::OUT_OF_RANGE,
&monitored(ai1, cf, None),
)
.unwrap(),
"different monitored property must fingerprint differently"
);
assert_ne!(
base,
super::super::params_fingerprint(
¶ms,
2,
EventType::OUT_OF_RANGE,
&monitored(ai1, pv, Some(0)),
)
.unwrap(),
"an omitted index must differ from index zero"
);
assert_ne!(
super::super::params_fingerprint(
¶ms,
2,
EventType::OUT_OF_RANGE,
&monitored(ai1, pv, Some(0)),
)
.unwrap(),
super::super::params_fingerprint(
¶ms,
2,
EventType::OUT_OF_RANGE,
&monitored(ai1, pv, Some(1)),
)
.unwrap(),
"different array indexes must fingerprint differently"
);
assert_eq!(
base,
super::super::params_fingerprint(
¶ms,
2,
EventType::OUT_OF_RANGE,
&monitored(ai1, pv, None),
)
.unwrap(),
"same configuration fingerprints stably"
);
}
#[test]
fn retarget_mid_pending_cancels_and_regates() {
let (mut db, ee_oid, ai_oid) = setup_oor(85.0, 80.0, 20.0, 2.0, 3, None);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty()); assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
let pending = db
.get(&ee_oid)
.unwrap()
.enrollment_eval_state_internal()
.unwrap()
.pending
.expect("countdown seeded");
let mut ai2 = AnalogInputObject::new(2, "AI-2", EngineeringUnits::DEGREES_CELSIUS).unwrap();
ai2.set_present_value(86.0);
let ai2_oid = ai2.object_identifier();
db.add(Box::new(ai2)).unwrap();
db.remove(&ee_oid).unwrap();
let mut ee = EventEnrollmentObject::new(1, "EE-OOR", EventType::OUT_OF_RANGE).unwrap();
ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
ai2_oid,
PropertyIdentifier::PRESENT_VALUE.to_raw(),
)))
.unwrap();
ee.set_event_parameters(BACnetEventParameter::OutOfRange {
time_delay: 3,
low_limit: 20.0,
high_limit: 80.0,
deadband: 2.0,
});
ee.set_event_enable(EventTransitionBits::all());
db.add(Box::new(ee)).unwrap();
db.get_mut(&ee_oid)
.unwrap()
.set_enrollment_eval_state_internal(
bacnet_objects::event_enrollment::EventEnrollmentEvalState {
pending: Some(pending),
..Default::default()
},
)
.unwrap();
for pass in 1..=3 {
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"post-retarget pass {pass}: full delay must re-run"
);
}
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT
);
let _ = ai_oid;
}