use super::super::*;
use bacnet_objects::analog::AnalogInputObject;
use bacnet_objects::event_enrollment::EventEnrollmentObject;
use bacnet_objects::traits::BACnetObject;
use bacnet_types::constructed::{
BACnetDeviceObjectPropertyReference, BACnetEventParameter, BACnetPropertyStates,
ChangeOfValueCriteria,
};
use bacnet_types::enums::EngineeringUnits;
fn setup_on_notification_class(
event_type: EventType,
params: BACnetEventParameter,
notification_class: u32,
) -> (ObjectDatabase, ObjectIdentifier) {
let mut db = ObjectDatabase::new();
let mut ai = AnalogInputObject::new(1, "AI-1", EngineeringUnits::DEGREES_CELSIUS).unwrap();
ai.write_property(
PropertyIdentifier::NOTIFICATION_CLASS,
None,
PropertyValue::Unsigned(notification_class as u64),
None,
)
.unwrap();
let ai_oid = ai.object_identifier();
db.add(Box::new(ai)).unwrap();
let mut ee = EventEnrollmentObject::new(1, "EE-1", event_type).unwrap();
ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
ai_oid,
PropertyIdentifier::NOTIFICATION_CLASS.to_raw(),
)))
.unwrap();
ee.set_event_parameters(params);
ee.set_event_enable(EventTransitionBits::all());
let ee_oid = ee.object_identifier();
db.add(Box::new(ee)).unwrap();
(db, ee_oid)
}
fn rewrite_params(
db: &mut ObjectDatabase,
ee_oid: &ObjectIdentifier,
params: BACnetEventParameter,
) {
let mut scratch =
EventEnrollmentObject::new(1, "scratch", EventType::CHANGE_OF_BITSTRING).unwrap();
scratch.set_event_parameters(params);
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();
}
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 foreign_high_limit_recovers_under_cos_params() {
let (mut db, ee_oid) = setup_on_notification_class(
EventType::OUT_OF_RANGE,
BACnetEventParameter::OutOfRange {
time_delay: 0,
low_limit: 20.0,
high_limit: 80.0,
deadband: 2.0,
},
90, );
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT,
"OOR drives the enrollment to HIGH_LIMIT first"
);
rewrite_params(
&mut db,
&ee_oid,
BACnetEventParameter::ChangeOfState {
time_delay: 2,
list_of_values: vec![BACnetPropertyStates::UnsignedValue(999)],
},
);
for pass in 1..=2 {
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"recovery pass {pass}: gated by the normal-direction delay"
);
assert_eq!(event_state(&db, &ee_oid), EventState::HIGH_LIMIT);
}
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].change.from, EventState::HIGH_LIMIT);
assert_eq!(transitions[0].change.to, EventState::NORMAL);
assert_eq!(transitions[0].event_type, EventType::CHANGE_OF_STATE);
assert_eq!(event_state(&db, &ee_oid), EventState::NORMAL);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
}
#[test]
fn foreign_offnormal_recovers_under_oor_params() {
let (mut db, ee_oid) = setup_on_notification_class(
EventType::CHANGE_OF_STATE,
BACnetEventParameter::ChangeOfState {
time_delay: 0,
list_of_values: vec![BACnetPropertyStates::UnsignedValue(1)],
},
1,
);
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::OFFNORMAL
);
rewrite_params(
&mut db,
&ee_oid,
BACnetEventParameter::OutOfRange {
time_delay: 0,
low_limit: 0.0,
high_limit: 80.0,
deadband: 2.0,
},
);
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].change.from, EventState::OFFNORMAL);
assert_eq!(
transitions[0].change.to,
EventState::NORMAL,
"in-band value under the new OOR params settles NORMAL"
);
assert_eq!(transitions[0].event_type, EventType::OUT_OF_RANGE);
assert_eq!(event_state(&db, &ee_oid), EventState::NORMAL);
}
#[test]
fn foreign_offnormal_under_cov_recovers_and_establishes_baseline() {
let (mut db, ee_oid) = setup_on_notification_class(
EventType::CHANGE_OF_STATE,
BACnetEventParameter::ChangeOfState {
time_delay: 0,
list_of_values: vec![BACnetPropertyStates::UnsignedValue(3)],
},
3,
);
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::OFFNORMAL
);
rewrite_params(
&mut db,
&ee_oid,
BACnetEventParameter::ChangeOfValue {
time_delay: 0,
criteria: ChangeOfValueCriteria::ReferencedPropertyIncrement(5.0),
},
);
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(
transitions.len(),
1,
"COV's only target is NORMAL: the foreign state recovers"
);
assert_eq!(transitions[0].change.from, EventState::OFFNORMAL);
assert_eq!(transitions[0].change.to, EventState::NORMAL);
assert_eq!(event_state(&db, &ee_oid), EventState::NORMAL);
let ai_oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
db.get_mut(&ai_oid)
.unwrap()
.write_property(
PropertyIdentifier::NOTIFICATION_CLASS,
None,
PropertyValue::Unsigned(9),
None,
)
.unwrap();
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::NORMAL);
}
#[test]
fn foreign_high_limit_recovers_under_cobs_params() {
let mut db = ObjectDatabase::new();
let mut target = EventEnrollmentObject::new(96, "Target", EventType::NONE).unwrap();
target.set_event_enable(EventTransitionBits::all());
let target_oid = target.object_identifier();
db.add(Box::new(target)).unwrap();
let mut ee = EventEnrollmentObject::new(97, "EE-cobs", EventType::CHANGE_OF_BITSTRING).unwrap();
ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
target_oid,
PropertyIdentifier::EVENT_ENABLE.to_raw(),
)))
.unwrap();
ee.set_event_parameters(BACnetEventParameter::ChangeOfBitstring {
time_delay: 0,
bitmask: (5, vec![0x80]), list_of_values: vec![(5, vec![0x00])], });
ee.set_event_enable(EventTransitionBits::all());
ee.set_event_state_internal(EventState::HIGH_LIMIT).unwrap();
let ee_oid = ee.object_identifier();
db.add(Box::new(ee)).unwrap();
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1, "COBS recovers the foreign HIGH_LIMIT");
assert_eq!(transitions[0].change.from, EventState::HIGH_LIMIT);
assert_eq!(
transitions[0].change.to,
EventState::NORMAL,
"bit set while the alarm pattern wants it clear -> no match -> settles NORMAL"
);
assert_eq!(event_state(&db, &ee_oid), EventState::NORMAL);
}
#[test]
fn cobs_mask_wider_than_monitored_value_is_not_a_match() {
let mut db = ObjectDatabase::new();
let mut ee =
EventEnrollmentObject::new(97, "EE-cobs-w", EventType::CHANGE_OF_BITSTRING).unwrap();
let mut target = EventEnrollmentObject::new(96, "Target", EventType::NONE).unwrap();
target.set_event_enable(EventTransitionBits::empty());
let target_oid = target.object_identifier();
db.add(Box::new(target)).unwrap();
ee.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
target_oid,
PropertyIdentifier::EVENT_ENABLE.to_raw(),
)))
.unwrap();
ee.set_event_parameters(BACnetEventParameter::ChangeOfBitstring {
time_delay: 0,
bitmask: (0, vec![0xFF, 0xFF]), list_of_values: vec![(0, vec![0x00, 0x01])], });
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(),
"the alarm's second byte disagrees with the (zero-filled) monitored width"
);
assert_eq!(event_state(&db, &ee_oid), EventState::NORMAL);
}
#[test]
fn foreign_high_limit_recovers_under_floating_limit_params() {
let (mut db, ee_oid) = setup_on_notification_class(
EventType::OUT_OF_RANGE,
BACnetEventParameter::OutOfRange {
time_delay: 0,
low_limit: 20.0,
high_limit: 80.0,
deadband: 2.0,
},
90,
);
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT
);
let ai_oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
db.get_mut(&ai_oid)
.unwrap()
.write_property(
PropertyIdentifier::OUT_OF_SERVICE,
None,
PropertyValue::Boolean(true),
None,
)
.unwrap();
db.get_mut(&ai_oid)
.unwrap()
.write_property(
PropertyIdentifier::PRESENT_VALUE,
None,
PropertyValue::Real(50.0),
None,
)
.unwrap();
rewrite_params(
&mut db,
&ee_oid,
BACnetEventParameter::FloatingLimit {
time_delay: 0,
setpoint_reference: BACnetDeviceObjectPropertyReference::new_local(
ai_oid,
PropertyIdentifier::PRESENT_VALUE.to_raw(),
),
low_diff_limit: 60.0,
high_diff_limit: 60.0,
deadband: 0.5,
},
);
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].change.from, EventState::HIGH_LIMIT);
assert_eq!(
transitions[0].change.to,
EventState::NORMAL,
"FL from a foreign HIGH_LIMIT: value inside the band -> NORMAL"
);
assert_eq!(event_state(&db, &ee_oid), EventState::NORMAL);
}
#[test]
fn foreign_high_limit_with_matching_alarm_indicates_offnormal() {
let (mut db, ee_oid) = setup_on_notification_class(
EventType::OUT_OF_RANGE,
BACnetEventParameter::OutOfRange {
time_delay: 0,
low_limit: 20.0,
high_limit: 80.0,
deadband: 2.0,
},
90,
);
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT
);
rewrite_params(
&mut db,
&ee_oid,
BACnetEventParameter::ChangeOfState {
time_delay: 2,
list_of_values: vec![BACnetPropertyStates::UnsignedValue(90)],
},
);
for pass in 1..=2 {
assert!(
evaluate_event_enrollments(&mut db, 1).is_empty(),
"recovery-to-OFFNORMAL pass {pass}: gated by pTimeDelay"
);
assert_eq!(
event_state(&db, &ee_oid),
EventState::HIGH_LIMIT,
"the ghost state persists until the delay elapses"
);
}
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].change.from, EventState::HIGH_LIMIT);
assert_eq!(transitions[0].change.to, EventState::OFFNORMAL);
assert_eq!(
event_state(&db, &ee_oid),
EventState::OFFNORMAL,
"the SPECIFIC indicated state is stored (13.2.2.1.4), not left at the ghost"
);
}
#[test]
fn offnormal_returns_to_normal_under_none_params() {
let (mut db, ee_oid) = setup_on_notification_class(
EventType::OUT_OF_RANGE,
BACnetEventParameter::OutOfRange {
time_delay: 0,
low_limit: 20.0,
high_limit: 80.0,
deadband: 2.0,
},
90,
);
assert_eq!(
evaluate_event_enrollments(&mut db, 1)[0].change.to,
EventState::HIGH_LIMIT
);
db.get_mut(&ee_oid)
.unwrap()
.write_property(
PropertyIdentifier::EVENT_PARAMETERS,
None,
PropertyValue::ApplicationData(vec![0xF8, 0x14]),
None,
)
.unwrap();
let transitions = evaluate_event_enrollments(&mut db, 1);
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].change.from, EventState::HIGH_LIMIT);
assert_eq!(transitions[0].change.to, EventState::NORMAL);
assert_eq!(transitions[0].event_type, EventType::NONE);
assert!(transitions[0].distribute, "TO_NORMAL is enabled");
assert_eq!(event_state(&db, &ee_oid), EventState::NORMAL);
assert!(evaluate_event_enrollments(&mut db, 1).is_empty());
}