use super::*;
use crate::life_safety_cov::LifeSafetyCovChange;
use bacnet_types::error::RemoteError;
use bacnet_objects::life_safety::{LifeSafetyPointObject, LifeSafetyZoneObject};
use bacnet_services::life_safety::LifeSafetyOperationRequest;
use bacnet_types::enums::{
EngineeringUnits, ErrorClass, ErrorCode, LifeSafetyOperation, ObjectType, PropertyIdentifier,
SilencedState,
};
use bacnet_types::primitives::{ObjectIdentifier, PropertyValue};
fn request(
operation: LifeSafetyOperation,
object_identifier: Option<ObjectIdentifier>,
) -> LifeSafetyOperationRequest {
LifeSafetyOperationRequest {
requesting_process_identifier: 7,
requesting_source: "operator".into(),
request: operation,
object_identifier,
}
}
fn read_enumerated(
db: &ObjectDatabase,
oid: ObjectIdentifier,
property: PropertyIdentifier,
) -> u32 {
match db.get(&oid).unwrap().read_property(property, None).unwrap() {
PropertyValue::Enumerated(value) => value,
other => panic!("expected enumerated value, got {other:?}"),
}
}
fn assert_protocol_error(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
));
}
#[test]
fn life_safety_operation_targeted_silence_changes_object() {
let oid = ObjectIdentifier::new(ObjectType::LIFE_SAFETY_POINT, 1).unwrap();
let mut point = LifeSafetyPointObject::new(1, "point").unwrap();
point.set_operation_expected(LifeSafetyOperation::SILENCE);
let mut db = ObjectDatabase::new();
db.add(Box::new(point)).unwrap();
let changed =
handle_life_safety_operation(&mut db, &request(LifeSafetyOperation::SILENCE, Some(oid)))
.unwrap();
assert_eq!(
changed,
vec![LifeSafetyCovChange {
object_identifier: oid,
changed_properties: vec![
PropertyIdentifier::SILENCED,
PropertyIdentifier::OPERATION_EXPECTED
]
}]
);
assert_eq!(
read_enumerated(&db, oid, PropertyIdentifier::SILENCED),
SilencedState::ALL_SILENCED.to_raw()
);
}
#[test]
fn life_safety_operation_replay_without_response_cache_is_invalid_state() {
let oid = ObjectIdentifier::new(ObjectType::LIFE_SAFETY_POINT, 1).unwrap();
let mut point = LifeSafetyPointObject::new(1, "point").unwrap();
point.set_operation_expected(LifeSafetyOperation::SILENCE);
let mut db = ObjectDatabase::new();
db.add(Box::new(point)).unwrap();
let request = request(LifeSafetyOperation::SILENCE, Some(oid));
assert_eq!(
handle_life_safety_operation(&mut db, &request).unwrap(),
vec![LifeSafetyCovChange {
object_identifier: oid,
changed_properties: vec![
PropertyIdentifier::SILENCED,
PropertyIdentifier::OPERATION_EXPECTED
]
}]
);
let error = handle_life_safety_operation(&mut db, &request).unwrap_err();
assert_protocol_error(
error,
ErrorClass::OBJECT,
ErrorCode::INVALID_OPERATION_IN_THIS_STATE,
);
}
#[test]
fn life_safety_operation_reports_target_errors() {
let missing = ObjectIdentifier::new(ObjectType::LIFE_SAFETY_POINT, 44).unwrap();
let mut db = ObjectDatabase::new();
let error = handle_life_safety_operation(
&mut db,
&request(LifeSafetyOperation::SILENCE, Some(missing)),
)
.unwrap_err();
assert_protocol_error(error, ErrorClass::OBJECT, ErrorCode::UNKNOWN_OBJECT);
let analog_oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
db.add(Box::new(
AnalogInputObject::new(1, "analog", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
))
.unwrap();
let error = handle_life_safety_operation(
&mut db,
&request(LifeSafetyOperation::SILENCE, Some(analog_oid)),
)
.unwrap_err();
assert_protocol_error(
error,
ErrorClass::OBJECT,
ErrorCode::OPTIONAL_FUNCTIONALITY_NOT_SUPPORTED,
);
}
#[test]
fn life_safety_operation_rejects_none_and_reserved_and_reports_missing_reset_executor() {
let oid = ObjectIdentifier::new(ObjectType::LIFE_SAFETY_POINT, 1).unwrap();
let mut point = LifeSafetyPointObject::new(1, "point").unwrap();
point.set_operation_expected(LifeSafetyOperation::RESET);
let mut db = ObjectDatabase::new();
db.add(Box::new(point)).unwrap();
for operation in [LifeSafetyOperation::NONE, LifeSafetyOperation::from_raw(10)] {
let error =
handle_life_safety_operation(&mut db, &request(operation, Some(oid))).unwrap_err();
assert_protocol_error(error, ErrorClass::OBJECT, ErrorCode::VALUE_OUT_OF_RANGE);
}
let error =
handle_life_safety_operation(&mut db, &request(LifeSafetyOperation::RESET, Some(oid)))
.unwrap_err();
assert_protocol_error(
error,
ErrorClass::OBJECT,
ErrorCode::OPTIONAL_FUNCTIONALITY_NOT_SUPPORTED,
);
}
#[test]
fn life_safety_operation_without_target_suppresses_missing_reset_executor() {
let oid = ObjectIdentifier::new(ObjectType::LIFE_SAFETY_POINT, 1).unwrap();
let mut point = LifeSafetyPointObject::new(1, "point").unwrap();
point.set_operation_expected(LifeSafetyOperation::RESET);
let mut db = ObjectDatabase::new();
db.add(Box::new(point)).unwrap();
let changed =
handle_life_safety_operation(&mut db, &request(LifeSafetyOperation::RESET, None)).unwrap();
assert!(changed.is_empty());
assert_eq!(
read_enumerated(&db, oid, PropertyIdentifier::OPERATION_EXPECTED),
LifeSafetyOperation::RESET.to_raw()
);
}
#[test]
fn life_safety_operation_without_target_attempts_every_object() {
let point_oid = ObjectIdentifier::new(ObjectType::LIFE_SAFETY_POINT, 1).unwrap();
let zone_oid = ObjectIdentifier::new(ObjectType::LIFE_SAFETY_ZONE, 1).unwrap();
let mut point = LifeSafetyPointObject::new(1, "point").unwrap();
point.set_operation_expected(LifeSafetyOperation::SILENCE_VISUAL);
let mut zone = LifeSafetyZoneObject::new(1, "zone").unwrap();
zone.set_operation_expected(LifeSafetyOperation::SILENCE_VISUAL);
let mut already_desired = LifeSafetyPointObject::new(2, "already").unwrap();
already_desired.set_silenced(SilencedState::VISIBLE_SILENCED);
let invalid_state = LifeSafetyPointObject::new(3, "invalid").unwrap();
let mut db = ObjectDatabase::new();
db.add(Box::new(point)).unwrap();
db.add(Box::new(zone)).unwrap();
db.add(Box::new(already_desired)).unwrap();
db.add(Box::new(invalid_state)).unwrap();
db.add(Box::new(
AnalogInputObject::new(1, "analog", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
))
.unwrap();
let changed =
handle_life_safety_operation(&mut db, &request(LifeSafetyOperation::SILENCE_VISUAL, None))
.unwrap();
assert_eq!(
changed,
[point_oid, zone_oid].map(|oid| LifeSafetyCovChange {
object_identifier: oid,
changed_properties: vec![
PropertyIdentifier::SILENCED,
PropertyIdentifier::OPERATION_EXPECTED
],
})
);
for oid in [point_oid, zone_oid] {
assert_eq!(
read_enumerated(&db, oid, PropertyIdentifier::SILENCED),
SilencedState::VISIBLE_SILENCED.to_raw()
);
}
assert_eq!(
read_enumerated(
&db,
ObjectIdentifier::new(ObjectType::LIFE_SAFETY_POINT, 2).unwrap(),
PropertyIdentifier::SILENCED,
),
SilencedState::VISIBLE_SILENCED.to_raw()
);
assert_eq!(
read_enumerated(
&db,
ObjectIdentifier::new(ObjectType::LIFE_SAFETY_POINT, 3).unwrap(),
PropertyIdentifier::SILENCED,
),
SilencedState::UNSILENCED.to_raw()
);
}
#[test]
fn targetless_mixed_outcomes_aggregate_only_successful_exact_deltas() {
let point_oid = ObjectIdentifier::new(ObjectType::LIFE_SAFETY_POINT, 1).unwrap();
let zone_oid = ObjectIdentifier::new(ObjectType::LIFE_SAFETY_ZONE, 1).unwrap();
let mut point = LifeSafetyPointObject::new(1, "point").unwrap();
point.set_silenced(SilencedState::VISIBLE_SILENCED);
point.set_operation_expected(LifeSafetyOperation::SILENCE_VISUAL);
let mut zone = LifeSafetyZoneObject::new(1, "zone").unwrap();
zone.set_operation_expected(LifeSafetyOperation::SILENCE_VISUAL);
let failed = LifeSafetyPointObject::new(2, "failed").unwrap();
let mut db = ObjectDatabase::new();
db.add(Box::new(point)).unwrap();
db.add(Box::new(zone)).unwrap();
db.add(Box::new(failed)).unwrap();
let result =
handle_life_safety_operation(&mut db, &request(LifeSafetyOperation::SILENCE_VISUAL, None))
.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].object_identifier, point_oid);
assert_eq!(
result[0].changed_properties,
vec![PropertyIdentifier::OPERATION_EXPECTED]
);
assert_eq!(result[1].object_identifier, zone_oid);
assert_eq!(
result[1].changed_properties,
vec![
PropertyIdentifier::SILENCED,
PropertyIdentifier::OPERATION_EXPECTED,
]
);
}
#[test]
fn life_safety_operation_without_target_succeeds_with_empty_database() {
let mut db = ObjectDatabase::new();
assert!(
handle_life_safety_operation(&mut db, &request(LifeSafetyOperation::UNSILENCE, None),)
.unwrap()
.is_empty()
);
}
#[test]
fn life_safety_properties_cannot_be_bypassed_through_write_property_multiple() {
use bacnet_services::common::BACnetPropertyValue;
use bacnet_services::wpm::{WriteAccessSpecification, WritePropertyMultipleRequest};
let oid = ObjectIdentifier::new(ObjectType::LIFE_SAFETY_POINT, 1).unwrap();
let mut db = ObjectDatabase::new();
db.add(Box::new(LifeSafetyPointObject::new(1, "point").unwrap()))
.unwrap();
let mut mode = BytesMut::new();
bacnet_encoding::primitives::encode_app_enumerated(&mut mode, 1);
let mut silenced = BytesMut::new();
bacnet_encoding::primitives::encode_app_enumerated(&mut silenced, 3);
let request = WritePropertyMultipleRequest {
list_of_write_access_specs: vec![WriteAccessSpecification {
object_identifier: oid,
list_of_properties: vec![
BACnetPropertyValue {
property_identifier: PropertyIdentifier::MODE,
property_array_index: None,
value: mode.to_vec(),
priority: None,
},
BACnetPropertyValue {
property_identifier: PropertyIdentifier::SILENCED,
property_array_index: None,
value: silenced.to_vec(),
priority: None,
},
],
}],
};
let mut encoded = BytesMut::new();
request.encode(&mut encoded).unwrap();
let error = handle_write_property_multiple(&mut db, &encoded).unwrap_err();
assert_protocol_error(error, ErrorClass::PROPERTY, ErrorCode::WRITE_ACCESS_DENIED);
assert_eq!(
read_enumerated(&db, oid, PropertyIdentifier::MODE),
1,
"the earlier MODE write stays committed"
);
assert_eq!(
read_enumerated(&db, oid, PropertyIdentifier::SILENCED),
SilencedState::UNSILENCED.to_raw()
);
}