use super::*;
use bacnet_types::bitstring::EventTransitionBits;
use bacnet_types::error::RemoteError;
use bacnet_encoding::primitives;
use bacnet_encoding::tags::{TagClass, encode_tag};
use bacnet_objects::analog::{AnalogOutputObject, AnalogValueObject};
use bacnet_objects::binary::{BinaryInputObject, BinaryOutputObject, BinaryValueObject};
use bacnet_objects::event::{EnrollmentSummaryCapability, EventStateChange, EventTransitionCommit};
use bacnet_objects::event_enrollment::{AlertEnrollmentObject, EventEnrollmentObject};
use bacnet_objects::multistate::{
MultiStateInputObject, MultiStateOutputObject, MultiStateValueObject,
};
use bacnet_services::alarm_event::AcknowledgeAlarmRequest;
use bacnet_types::constructed::{BACnetDeviceObjectPropertyReference, BACnetEventParameter};
use bacnet_types::enums::{EngineeringUnits, EventType};
fn add_committed<O: BACnetObject + 'static>(
db: &mut ObjectDatabase,
mut object: O,
state: EventState,
timestamp: BACnetTimeStamp,
) -> ObjectIdentifier {
let oid = object.object_identifier();
object
.commit_event_transition_internal(EventTransitionCommit {
change: EventStateChange {
from: EventState::NORMAL,
to: state,
},
coordinate: bacnet_objects::event::EventTransition::for_target_state(state),
ack_required: true,
timestamp,
message_text: Some("committed".into()),
})
.unwrap();
db.add(Box::new(object)).unwrap();
oid
}
fn with_event_detection<O: BACnetObject>(mut object: O) -> O {
object
.write_property(
PropertyIdentifier::EVENT_DETECTION_ENABLE,
None,
PropertyValue::Boolean(true),
None,
)
.unwrap();
object
}
fn encode_request(
oid: ObjectIdentifier,
state: EventState,
timestamp: BACnetTimeStamp,
) -> BytesMut {
let request = AcknowledgeAlarmRequest {
acknowledging_process_identifier: 17,
event_object_identifier: oid,
event_state_acknowledged: state,
timestamp,
acknowledgment_source: "operator".into(),
time_of_acknowledgment: BACnetTimeStamp::SequenceNumber(88),
};
let mut encoded = BytesMut::new();
request.encode(&mut encoded).unwrap();
encoded
}
fn encode_request_with_raw_source(oid: ObjectIdentifier, source: &[u8]) -> BytesMut {
let mut encoded = BytesMut::new();
primitives::encode_ctx_unsigned(&mut encoded, 0, 17);
primitives::encode_ctx_object_id(&mut encoded, 1, &oid);
primitives::encode_ctx_enumerated(&mut encoded, 2, EventState::HIGH_LIMIT.to_raw());
primitives::encode_timestamp(&mut encoded, 3, &BACnetTimeStamp::SequenceNumber(42)).unwrap();
encode_tag(&mut encoded, 4, TagClass::Context, source.len() as u32);
encoded.extend_from_slice(source);
primitives::encode_timestamp(&mut encoded, 5, &BACnetTimeStamp::SequenceNumber(88)).unwrap();
encoded
}
fn acked(db: &ObjectDatabase, oid: ObjectIdentifier) -> EventTransitionBits {
let PropertyValue::BitString { data, .. } = db
.get(&oid)
.unwrap()
.read_property(PropertyIdentifier::ACKED_TRANSITIONS, None)
.unwrap()
else {
panic!("Acked_Transitions must be a bit string");
};
EventTransitionBits::from_bacnet(&data)
}
fn snapshot(
db: &ObjectDatabase,
oid: ObjectIdentifier,
) -> (PropertyValue, PropertyValue, EventTransitionBits) {
let object = db.get(&oid).unwrap();
(
object
.read_property(PropertyIdentifier::EVENT_STATE, None)
.unwrap(),
object
.read_property(PropertyIdentifier::EVENT_TIME_STAMPS, None)
.unwrap(),
acked(db, oid),
)
}
fn assert_protocol(error: Error, class: ErrorClass, code: ErrorCode) {
assert!(matches!(
error,
Error::Remote(RemoteError::Error { class: actual_class, code: actual_code })
if actual_class == class && actual_code == code
));
}
fn configured_event_enrollment() -> EventEnrollmentObject {
let mut object = EventEnrollmentObject::new(1, "EE-ack", EventType::OUT_OF_RANGE).unwrap();
object
.set_object_property_reference(Some(BACnetDeviceObjectPropertyReference::new_local(
ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 77).unwrap(),
PropertyIdentifier::PRESENT_VALUE.to_raw(),
)))
.unwrap();
object.set_event_parameters(BACnetEventParameter::OutOfRange {
time_delay: 0,
low_limit: 0.0,
high_limit: 1.0,
deadband: 0.1,
});
object
}
struct LegacyCoarseAckObject {
oid: ObjectIdentifier,
}
struct MalformedDetailedAckObject {
oid: ObjectIdentifier,
}
impl BACnetObject for MalformedDetailedAckObject {
fn object_identifier(&self) -> ObjectIdentifier {
self.oid
}
fn object_name(&self) -> &str {
"malformed-detailed-ack"
}
fn read_property(
&self,
property: PropertyIdentifier,
_array_index: Option<u32>,
) -> Result<PropertyValue, Error> {
if property == PropertyIdentifier::EVENT_ENABLE {
Ok(PropertyValue::BitString {
unused_bits: 4,
data: vec![0xf0],
})
} else {
Err(Error::Encoding("property unavailable".into()))
}
}
fn write_property(
&mut self,
_property: PropertyIdentifier,
_array_index: Option<u32>,
_value: PropertyValue,
_priority: Option<u8>,
) -> Result<(), Error> {
Err(Error::Encoding("test object is read-only".into()))
}
fn property_list(&self) -> std::borrow::Cow<'static, [PropertyIdentifier]> {
std::borrow::Cow::Borrowed(&[])
}
fn enrollment_summary_capability_internal(&self) -> Option<EnrollmentSummaryCapability> {
Some(EnrollmentSummaryCapability {
event_type: EventType::OUT_OF_RANGE,
last_transition: None,
})
}
fn acknowledge_alarm_correlated_detailed_internal(
&mut self,
_event_state: EventState,
_timestamp: &BACnetTimeStamp,
) -> Result<Option<EventStateChange>, Error> {
Ok(Some(EventStateChange {
from: EventState::NORMAL,
to: EventState::HIGH_LIMIT,
}))
}
}
impl BACnetObject for LegacyCoarseAckObject {
fn object_identifier(&self) -> ObjectIdentifier {
self.oid
}
fn object_name(&self) -> &str {
"legacy-coarse-ack"
}
fn read_property(
&self,
_property: PropertyIdentifier,
_array_index: Option<u32>,
) -> Result<PropertyValue, Error> {
Err(Error::Encoding(
"legacy test object has no properties".into(),
))
}
fn write_property(
&mut self,
_property: PropertyIdentifier,
_array_index: Option<u32>,
_value: PropertyValue,
_priority: Option<u8>,
) -> Result<(), Error> {
Err(Error::Encoding("legacy test object is read-only".into()))
}
fn property_list(&self) -> std::borrow::Cow<'static, [PropertyIdentifier]> {
std::borrow::Cow::Borrowed(&[])
}
fn acknowledge_alarm_correlated_internal(
&mut self,
_event_state: EventState,
_timestamp: &BACnetTimeStamp,
) -> Result<(), Error> {
Ok(())
}
}
#[test]
fn legacy_coarse_hook_accepts_without_inventing_notification_context() {
let oid = ObjectIdentifier::new(ObjectType::ACCUMULATOR, 91).unwrap();
let mut db = ObjectDatabase::new();
db.add(Box::new(LegacyCoarseAckObject { oid })).unwrap();
let accepted = handle_acknowledge_alarm(
&mut db,
&encode_request(
oid,
EventState::HIGH_LIMIT,
BACnetTimeStamp::SequenceNumber(42),
),
)
.unwrap();
assert_eq!(accepted.event_object_identifier, oid);
assert_eq!(accepted.notification, None);
}
#[test]
fn malformed_optional_distribution_context_does_not_retract_acceptance() {
let oid = ObjectIdentifier::new(ObjectType::ACCUMULATOR, 92).unwrap();
let mut db = ObjectDatabase::new();
db.add(Box::new(MalformedDetailedAckObject { oid }))
.unwrap();
let accepted = handle_acknowledge_alarm(
&mut db,
&encode_request(
oid,
EventState::HIGH_LIMIT,
BACnetTimeStamp::SequenceNumber(42),
),
)
.unwrap();
assert_eq!(accepted.event_object_identifier, oid);
assert_eq!(accepted.notification, None);
}
#[test]
fn supported_families_acknowledge_only_the_correlated_transition() {
let mut db = ObjectDatabase::new();
let stamp = BACnetTimeStamp::SequenceNumber(42);
let cases = [
(
add_committed(
&mut db,
AnalogInputObject::new(1, "AI-ack", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
EventState::HIGH_LIMIT,
stamp.clone(),
),
EventState::HIGH_LIMIT,
),
(
add_committed(
&mut db,
AnalogOutputObject::new(1, "AO-ack", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
EventState::HIGH_LIMIT,
stamp.clone(),
),
EventState::HIGH_LIMIT,
),
(
add_committed(
&mut db,
AnalogValueObject::new(1, "AV-ack", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
EventState::HIGH_LIMIT,
stamp.clone(),
),
EventState::HIGH_LIMIT,
),
(
add_committed(
&mut db,
BinaryInputObject::new(1, "BI-ack").unwrap(),
EventState::OFFNORMAL,
stamp.clone(),
),
EventState::OFFNORMAL,
),
(
add_committed(
&mut db,
with_event_detection(BinaryOutputObject::new(1, "BO-ack").unwrap()),
EventState::OFFNORMAL,
stamp.clone(),
),
EventState::OFFNORMAL,
),
(
add_committed(
&mut db,
BinaryValueObject::new(1, "BV-ack").unwrap(),
EventState::OFFNORMAL,
stamp.clone(),
),
EventState::OFFNORMAL,
),
(
add_committed(
&mut db,
MultiStateInputObject::new(1, "MSI-ack", 3).unwrap(),
EventState::OFFNORMAL,
stamp.clone(),
),
EventState::OFFNORMAL,
),
(
add_committed(
&mut db,
with_event_detection(MultiStateOutputObject::new(1, "MSO-ack", 3).unwrap()),
EventState::OFFNORMAL,
stamp.clone(),
),
EventState::OFFNORMAL,
),
(
add_committed(
&mut db,
MultiStateValueObject::new(1, "MSV-ack", 3).unwrap(),
EventState::OFFNORMAL,
stamp.clone(),
),
EventState::OFFNORMAL,
),
(
add_committed(
&mut db,
configured_event_enrollment(),
EventState::HIGH_LIMIT,
stamp.clone(),
),
EventState::HIGH_LIMIT,
),
];
for (oid, state) in cases {
assert_eq!(
acked(&db, oid),
EventTransitionBits::TO_FAULT | EventTransitionBits::TO_NORMAL
);
let accepted =
handle_acknowledge_alarm(&mut db, &encode_request(oid, state, stamp.clone())).unwrap();
let notification = accepted
.notification
.expect("built-in objects return exact acknowledgment history");
assert_eq!(notification.change.to, state);
assert_eq!(acked(&db, oid), EventTransitionBits::all());
}
}
#[test]
fn fault_recovery_acknowledgment_uses_historical_change_of_reliability() {
let mut object =
AnalogInputObject::new(2, "AI-fault-recovery", EngineeringUnits::DEGREES_CELSIUS).unwrap();
object
.commit_event_transition_internal(EventTransitionCommit {
change: EventStateChange {
from: EventState::NORMAL,
to: EventState::FAULT,
},
coordinate: bacnet_objects::event::EventTransition::ToFault,
ack_required: false,
timestamp: BACnetTimeStamp::SequenceNumber(41),
message_text: None,
})
.unwrap();
object
.commit_event_transition_internal(EventTransitionCommit {
change: EventStateChange {
from: EventState::FAULT,
to: EventState::NORMAL,
},
coordinate: bacnet_objects::event::EventTransition::ToNormal,
ack_required: true,
timestamp: BACnetTimeStamp::SequenceNumber(42),
message_text: None,
})
.unwrap();
let oid = object.object_identifier();
let mut db = ObjectDatabase::new();
db.add(Box::new(object)).unwrap();
let accepted = handle_acknowledge_alarm(
&mut db,
&encode_request(oid, EventState::NORMAL, BACnetTimeStamp::SequenceNumber(42)),
)
.unwrap();
let notification = accepted.notification.unwrap();
assert_eq!(
notification.change,
EventStateChange {
from: EventState::FAULT,
to: EventState::NORMAL,
}
);
assert_eq!(notification.event_type, EventType::CHANGE_OF_RELIABILITY);
}
#[test]
fn state_and_timestamp_mismatches_return_exact_errors_without_mutation() {
let mut db = ObjectDatabase::new();
let oid = add_committed(
&mut db,
AnalogInputObject::new(1, "AI-invalid", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
EventState::LOW_LIMIT,
BACnetTimeStamp::SequenceNumber(42),
);
let before = snapshot(&db, oid);
let state_error = handle_acknowledge_alarm(
&mut db,
&encode_request(
oid,
EventState::HIGH_LIMIT,
BACnetTimeStamp::SequenceNumber(99),
),
)
.unwrap_err();
assert_protocol(
state_error,
ErrorClass::SERVICES,
ErrorCode::INVALID_EVENT_STATE,
);
assert_eq!(snapshot(&db, oid), before);
let timestamp_error = handle_acknowledge_alarm(
&mut db,
&encode_request(
oid,
EventState::LOW_LIMIT,
BACnetTimeStamp::SequenceNumber(41),
),
)
.unwrap_err();
assert_protocol(
timestamp_error,
ErrorClass::SERVICES,
ErrorCode::INVALID_TIME_STAMP,
);
assert_eq!(snapshot(&db, oid), before);
}
#[test]
fn unknown_uninitialized_and_unsupported_objects_fail_closed() {
let missing = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 99).unwrap();
let mut empty = ObjectDatabase::new();
let error = handle_acknowledge_alarm(
&mut empty,
&encode_request(
missing,
EventState::HIGH_LIMIT,
BACnetTimeStamp::SequenceNumber(0),
),
)
.unwrap_err();
assert_protocol(error, ErrorClass::OBJECT, ErrorCode::UNKNOWN_OBJECT);
let mut db = ObjectDatabase::new();
let objects: Vec<Box<dyn BACnetObject>> = vec![Box::new(
AlertEnrollmentObject::new(
1,
"AE-unsupported",
ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 7).unwrap(),
)
.unwrap(),
)];
for object in objects {
let oid = object.object_identifier();
db.add(object).unwrap();
let before = acked(&db, oid);
let error = handle_acknowledge_alarm(
&mut db,
&encode_request(
oid,
EventState::HIGH_LIMIT,
BACnetTimeStamp::SequenceNumber(0),
),
)
.unwrap_err();
assert_protocol(error, ErrorClass::OBJECT, ErrorCode::NO_ALARM_CONFIGURED);
assert_eq!(acked(&db, oid), before);
}
let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 8).unwrap();
db.add(Box::new(
AnalogInputObject::new(8, "AI-empty", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
))
.unwrap();
let before = snapshot(&db, oid);
let error = handle_acknowledge_alarm(
&mut db,
&encode_request(
oid,
EventState::HIGH_LIMIT,
BACnetTimeStamp::SequenceNumber(0),
),
)
.unwrap_err();
assert_protocol(error, ErrorClass::SERVICES, ErrorCode::INVALID_TIME_STAMP);
assert_eq!(snapshot(&db, oid), before);
let unconfigured =
EventEnrollmentObject::new(8, "EE-unconfigured", EventType::CHANGE_OF_BITSTRING).unwrap();
let oid = unconfigured.object_identifier();
db.add(Box::new(unconfigured)).unwrap();
let before = acked(&db, oid);
let error = handle_acknowledge_alarm(
&mut db,
&encode_request(
oid,
EventState::OFFNORMAL,
BACnetTimeStamp::SequenceNumber(0),
),
)
.unwrap_err();
assert_protocol(error, ErrorClass::OBJECT, ErrorCode::NO_ALARM_CONFIGURED);
assert_eq!(acked(&db, oid), before);
}
#[test]
fn disabled_analog_input_returns_no_alarm_configured_before_correlation() {
let mut db = ObjectDatabase::new();
let oid = add_committed(
&mut db,
AnalogInputObject::new(1, "AI-disabled", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
EventState::HIGH_LIMIT,
BACnetTimeStamp::SequenceNumber(42),
);
db.get_mut(&oid)
.unwrap()
.write_property(
PropertyIdentifier::EVENT_DETECTION_ENABLE,
None,
PropertyValue::Boolean(false),
None,
)
.unwrap();
let before = snapshot(&db, oid);
let error = handle_acknowledge_alarm(
&mut db,
&encode_request(
oid,
EventState::HIGH_LIMIT,
BACnetTimeStamp::SequenceNumber(42),
),
)
.unwrap_err();
assert_protocol(error, ErrorClass::OBJECT, ErrorCode::NO_ALARM_CONFIGURED);
assert_eq!(snapshot(&db, oid), before);
}
#[test]
fn invalid_source_text_is_sanitized_but_malformed_source_framing_is_rejected() {
let mut db = ObjectDatabase::new();
let oid = add_committed(
&mut db,
AnalogInputObject::new(1, "AI-source", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
EventState::HIGH_LIMIT,
BACnetTimeStamp::SequenceNumber(42),
);
for source in [
&[1, 0xff][..],
&[0xff, 0xff][..],
&[0, 0xff][..],
&[4, 0xd8, 0x00][..],
] {
handle_acknowledge_alarm(&mut db, &encode_request_with_raw_source(oid, source)).unwrap();
assert_eq!(acked(&db, oid), EventTransitionBits::all());
}
let before = snapshot(&db, oid);
let error = handle_acknowledge_alarm(&mut db, &encode_request_with_raw_source(oid, &[]));
assert!(
matches!(error, Err(Error::Remote(RemoteError::Reject { .. }))),
"{error:?}"
);
assert_eq!(snapshot(&db, oid), before);
}