use super::*;
use bacnet_objects::analog::{AnalogInputObject, AnalogOutputObject};
use bacnet_objects::binary::BinaryValueObject;
use bacnet_objects::color::ColorTemperatureObject;
use bacnet_objects::event_enrollment::EventEnrollmentObject;
use bacnet_objects::loop_obj::LoopObject;
use bacnet_objects::multistate::MultiStateOutputObject;
use bacnet_objects::schedule::ScheduleObject;
use bacnet_objects::trend::TrendLogObject;
use bacnet_types::enums::EngineeringUnits;
use bacnet_types::error::RemoteError;
fn encode_value(value: PropertyValue) -> Vec<u8> {
let mut buf = BytesMut::new();
encode_property_value(&mut buf, &value).unwrap();
buf.to_vec()
}
fn write_wire(
db: &mut ObjectDatabase,
target_oid: ObjectIdentifier,
property: PropertyIdentifier,
value: PropertyValue,
) -> Result<(), Error> {
let request = WritePropertyRequest {
object_identifier: target_oid,
property_identifier: property,
property_array_index: None,
property_value: encode_value(value),
priority: None,
};
let mut buf = BytesMut::new();
request.encode(&mut buf).unwrap();
sourced_wp(db, &buf).map(|_| ())
}
fn read_wire(
db: &ObjectDatabase,
target_oid: ObjectIdentifier,
property: PropertyIdentifier,
) -> PropertyValue {
let request = ReadPropertyRequest {
object_identifier: target_oid,
property_identifier: property,
property_array_index: None,
};
let mut buf = BytesMut::new();
request.encode(&mut buf);
let mut ack_buf = BytesMut::new();
handle_read_property(db, &buf, &mut ack_buf).unwrap();
let raw = ReadPropertyACK::decode(&ack_buf).unwrap().property_value;
bacnet_encoding::primitives::decode_application_value(&raw, 0)
.unwrap()
.0
}
fn assert_refused(
db: &mut ObjectDatabase,
target_oid: ObjectIdentifier,
property: PropertyIdentifier,
value: PropertyValue,
expected_code: ErrorCode,
expected: PropertyValue,
context: &str,
) {
match write_wire(db, target_oid, property, value)
.expect_err(&format!("{context}: write must be refused"))
{
Error::Remote(RemoteError::Error { class, code }) => {
assert_eq!(class, ErrorClass::PROPERTY, "{context}: wrong error class");
assert_eq!(code, expected_code, "{context}: wrong error code");
}
other => panic!("{context}: expected PROPERTY/{expected_code:?}, got {other:?}"),
}
assert_eq!(
read_wire(db, target_oid, property),
expected,
"{context}: refused write must leave the property unchanged"
);
}
fn assert_wire_reliability_gate(
db: &mut ObjectDatabase,
target_oid: ObjectIdentifier,
context: &str,
) {
assert_refused(
db,
target_oid,
PropertyIdentifier::RELIABILITY,
PropertyValue::Enumerated(1),
ErrorCode::WRITE_ACCESS_DENIED,
PropertyValue::Enumerated(0),
&format!("{context} in-service Reliability"),
);
write_wire(
db,
target_oid,
PropertyIdentifier::OUT_OF_SERVICE,
PropertyValue::Boolean(true),
)
.expect("Out_Of_Service must be writable");
assert_refused(
db,
target_oid,
PropertyIdentifier::RELIABILITY,
PropertyValue::Enumerated(11), ErrorCode::VALUE_OUT_OF_RANGE,
PropertyValue::Enumerated(0),
&format!("{context} reserved Reliability"),
);
write_wire(
db,
target_oid,
PropertyIdentifier::RELIABILITY,
PropertyValue::Enumerated(1), )
.expect("out-of-service Reliability write must succeed");
assert_eq!(
read_wire(db, target_oid, PropertyIdentifier::RELIABILITY),
PropertyValue::Enumerated(1),
"{context}: simulated Reliability must read back"
);
write_wire(
db,
target_oid,
PropertyIdentifier::OUT_OF_SERVICE,
PropertyValue::Boolean(false),
)
.expect("returning to service must succeed");
assert_eq!(
read_wire(db, target_oid, PropertyIdentifier::RELIABILITY),
PropertyValue::Enumerated(0),
"{context}: returning to service must discard the simulation"
);
}
#[test]
fn loop_reliability_gate_holds_over_write_property() {
let mut db = ObjectDatabase::new();
let lo = LoopObject::new(1, "LOOP-1", EngineeringUnits::DEGREES_CELSIUS).unwrap();
let lo_oid = lo.object_identifier();
db.add(Box::new(lo)).unwrap();
assert_wire_reliability_gate(&mut db, lo_oid, "LOOP");
}
#[test]
fn schedule_reliability_gate_holds_over_write_property() {
let mut db = ObjectDatabase::new();
let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(0.0)).unwrap();
let sched_oid = sched.object_identifier();
db.add(Box::new(sched)).unwrap();
assert_wire_reliability_gate(&mut db, sched_oid, "SCHEDULE");
}
#[test]
fn trend_log_reliability_write_is_denied_over_write_property() {
let mut db = ObjectDatabase::new();
let tl = TrendLogObject::new(1, "TL-1", 100).unwrap();
let tl_oid = tl.object_identifier();
db.add(Box::new(tl)).unwrap();
assert_refused(
&mut db,
tl_oid,
PropertyIdentifier::RELIABILITY,
PropertyValue::Enumerated(1),
ErrorCode::WRITE_ACCESS_DENIED,
PropertyValue::Enumerated(0),
"TL Reliability",
);
assert!(matches!(
write_wire(
&mut db,
tl_oid,
PropertyIdentifier::OUT_OF_SERVICE,
PropertyValue::Boolean(true),
),
Err(Error::Remote(RemoteError::Error { class, code }))
if class == ErrorClass::PROPERTY
&& code == ErrorCode::UNKNOWN_PROPERTY
));
}
fn assert_notify_type_validated(
db: &mut ObjectDatabase,
target_oid: ObjectIdentifier,
baseline: PropertyValue,
context: &str,
) {
for out_of_production in [3u32, 99, u32::MAX] {
assert_refused(
db,
target_oid,
PropertyIdentifier::NOTIFY_TYPE,
PropertyValue::Enumerated(out_of_production),
ErrorCode::VALUE_OUT_OF_RANGE,
baseline.clone(),
&format!("{context} Notify_Type={out_of_production}"),
);
}
for in_production in [0u32, 1, 2] {
write_wire(
db,
target_oid,
PropertyIdentifier::NOTIFY_TYPE,
PropertyValue::Enumerated(in_production),
)
.expect("named Notify_Type values must be accepted");
}
}
#[test]
fn enumerated_overflow_is_invalid_data_encoding_without_mutation() {
let mut db = ObjectDatabase::new();
let ai = AnalogInputObject::new(1, "AI-1", EngineeringUnits::DEGREES_CELSIUS).unwrap();
let ai_oid = ai.object_identifier();
db.add(Box::new(ai)).unwrap();
let baseline = read_wire(&db, ai_oid, PropertyIdentifier::NOTIFY_TYPE);
let mut property_value = BytesMut::new();
bacnet_encoding::tags::encode_tag(
&mut property_value,
bacnet_encoding::tags::app_tag::ENUMERATED,
bacnet_encoding::tags::TagClass::Application,
8,
);
property_value.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 2]);
let request = WritePropertyRequest {
object_identifier: ai_oid,
property_identifier: PropertyIdentifier::NOTIFY_TYPE,
property_array_index: None,
property_value: property_value.to_vec(),
priority: None,
};
let mut request_bytes = BytesMut::new();
request.encode(&mut request_bytes).unwrap();
match sourced_wp(&mut db, &request_bytes).unwrap_err() {
Error::Remote(RemoteError::Error { class, code }) => {
assert_eq!(class, ErrorClass::PROPERTY);
assert_eq!(code, ErrorCode::INVALID_DATA_ENCODING);
}
other => panic!("expected PROPERTY/INVALID_DATA_ENCODING, got {other:?}"),
}
assert_eq!(
read_wire(&db, ai_oid, PropertyIdentifier::NOTIFY_TYPE),
baseline
);
}
fn assert_event_enable_validated(
db: &mut ObjectDatabase,
target_oid: ObjectIdentifier,
context: &str,
) {
let baseline = read_wire(db, target_oid, PropertyIdentifier::EVENT_ENABLE);
for (unused_bits, data, label) in [
(0u8, vec![0xFFu8], "8-bit string where 3 are defined"),
(5u8, vec![0xFFu8, 0xFF], "two content octets"),
(4u8, vec![0xF0u8], "half-octet string"),
(5u8, vec![], "no content octet"),
] {
assert_refused(
db,
target_oid,
PropertyIdentifier::EVENT_ENABLE,
PropertyValue::BitString { unused_bits, data },
ErrorCode::INVALID_DATA_ENCODING,
baseline.clone(),
&format!("{context} Event_Enable {label}"),
);
}
write_wire(
db,
target_oid,
PropertyIdentifier::EVENT_ENABLE,
PropertyValue::BitString {
unused_bits: 5,
data: vec![0xA0], },
)
.expect("canonical 3-bit encoding must be accepted");
assert_eq!(
read_wire(db, target_oid, PropertyIdentifier::EVENT_ENABLE),
PropertyValue::BitString {
unused_bits: 5,
data: vec![0xA0],
},
"{context}: accepted Event_Enable must read back"
);
}
#[test]
fn analog_event_property_writes_are_validated_over_write_property() {
let mut db = ObjectDatabase::new();
let ai = AnalogInputObject::new(1, "AI-1", EngineeringUnits::DEGREES_CELSIUS).unwrap();
let ai_oid = ai.object_identifier();
db.add(Box::new(ai)).unwrap();
assert_notify_type_validated(&mut db, ai_oid, PropertyValue::Enumerated(0), "AI");
assert_event_enable_validated(&mut db, ai_oid, "AI");
let baseline = read_wire(&db, ai_oid, PropertyIdentifier::LIMIT_ENABLE);
for (unused_bits, data, label) in [
(0u8, vec![0xFFu8], "8-bit string where 2 are defined"),
(5u8, vec![0xE0u8], "Event_Enable's 3-bit shape"),
(6u8, vec![0xC0u8, 0x00], "extra content octet"),
] {
assert_refused(
&mut db,
ai_oid,
PropertyIdentifier::LIMIT_ENABLE,
PropertyValue::BitString { unused_bits, data },
ErrorCode::INVALID_DATA_ENCODING,
baseline.clone(),
&format!("AI Limit_Enable {label}"),
);
}
write_wire(
&mut db,
ai_oid,
PropertyIdentifier::LIMIT_ENABLE,
PropertyValue::BitString {
unused_bits: 6,
data: vec![0xC0], },
)
.expect("canonical 2-bit encoding must be accepted");
}
#[test]
fn binary_event_property_writes_are_validated_over_write_property() {
let mut db = ObjectDatabase::new();
let bv = BinaryValueObject::new(1, "BV-1").unwrap();
let bv_oid = bv.object_identifier();
db.add(Box::new(bv)).unwrap();
assert_notify_type_validated(&mut db, bv_oid, PropertyValue::Enumerated(0), "BV");
assert_event_enable_validated(&mut db, bv_oid, "BV");
}
#[test]
fn multistate_event_property_writes_are_validated_over_write_property() {
let mut db = ObjectDatabase::new();
let mso = MultiStateOutputObject::new(1, "MSO-1", 3).unwrap();
let mso_oid = mso.object_identifier();
db.add(Box::new(mso)).unwrap();
assert_notify_type_validated(&mut db, mso_oid, PropertyValue::Enumerated(0), "MSO");
assert_event_enable_validated(&mut db, mso_oid, "MSO");
}
#[test]
fn event_enrollment_writes_are_validated_over_write_property() {
let mut db = ObjectDatabase::new();
let ee = EventEnrollmentObject::new(1, "EE-1", EventType::CHANGE_OF_BITSTRING).unwrap();
let ee_oid = ee.object_identifier();
db.add(Box::new(ee)).unwrap();
assert_notify_type_validated(&mut db, ee_oid, PropertyValue::Enumerated(0), "EE");
assert_event_enable_validated(&mut db, ee_oid, "EE");
}
#[test]
fn relinquish_default_write_recaptures_present_value_over_write_property() {
let mut db = ObjectDatabase::new();
let ao = AnalogOutputObject::new(1, "AO-1", EngineeringUnits::DEGREES_CELSIUS).unwrap();
let ao_oid = ao.object_identifier();
db.add(Box::new(ao)).unwrap();
let slot = WritePropertyRequest {
object_identifier: ao_oid,
property_identifier: PropertyIdentifier::PRESENT_VALUE,
property_array_index: None,
property_value: encode_value(PropertyValue::Real(55.0)),
priority: Some(8),
};
let mut buf = BytesMut::new();
slot.encode(&mut buf).unwrap();
sourced_wp(&mut db, &buf).unwrap();
assert_eq!(
read_wire(&db, ao_oid, PropertyIdentifier::PRESENT_VALUE),
PropertyValue::Real(55.0)
);
write_wire(
&mut db,
ao_oid,
PropertyIdentifier::RELINQUISH_DEFAULT,
PropertyValue::Real(12.5),
)
.expect("Relinquish_Default must be writable");
assert_eq!(
read_wire(&db, ao_oid, PropertyIdentifier::RELINQUISH_DEFAULT),
PropertyValue::Real(12.5)
);
assert_eq!(
read_wire(&db, ao_oid, PropertyIdentifier::PRESENT_VALUE),
PropertyValue::Real(55.0),
"a live command must still outrank the new default"
);
assert_refused(
&mut db,
ao_oid,
PropertyIdentifier::RELINQUISH_DEFAULT,
PropertyValue::Real(f32::NAN),
ErrorCode::VALUE_OUT_OF_RANGE,
PropertyValue::Real(12.5),
"AO NaN Relinquish_Default",
);
let slot = WritePropertyRequest {
object_identifier: ao_oid,
property_identifier: PropertyIdentifier::PRESENT_VALUE,
property_array_index: None,
property_value: encode_value(PropertyValue::Null),
priority: Some(8),
};
let mut buf = BytesMut::new();
slot.encode(&mut buf).unwrap();
sourced_wp(&mut db, &buf).unwrap();
assert_eq!(
read_wire(&db, ao_oid, PropertyIdentifier::PRESENT_VALUE),
PropertyValue::Real(12.5),
"with an empty priority array, PV must resolve to the written default"
);
}
#[test]
fn time_delay_normal_round_trips_over_write_property_and_read_property() {
let mut db = ObjectDatabase::new();
let ai = AnalogInputObject::new(1, "AI-1", EngineeringUnits::DEGREES_CELSIUS).unwrap();
let ai_oid = ai.object_identifier();
db.add(Box::new(ai)).unwrap();
write_wire(
&mut db,
ai_oid,
PropertyIdentifier::TIME_DELAY,
PropertyValue::Unsigned(11),
)
.expect("Time_Delay must be writable");
assert_eq!(
read_wire(&db, ai_oid, PropertyIdentifier::TIME_DELAY_NORMAL),
PropertyValue::Unsigned(11)
);
write_wire(
&mut db,
ai_oid,
PropertyIdentifier::TIME_DELAY_NORMAL,
PropertyValue::Unsigned(9),
)
.expect("Time_Delay_Normal must be writable");
assert_eq!(
read_wire(&db, ai_oid, PropertyIdentifier::TIME_DELAY_NORMAL),
PropertyValue::Unsigned(9)
);
assert_eq!(
read_wire(&db, ai_oid, PropertyIdentifier::TIME_DELAY),
PropertyValue::Unsigned(11),
"Time_Delay is untouched by a Time_Delay_Normal write"
);
assert_refused(
&mut db,
ai_oid,
PropertyIdentifier::TIME_DELAY_NORMAL,
PropertyValue::Enumerated(9),
ErrorCode::INVALID_DATA_TYPE,
PropertyValue::Unsigned(9),
"AI Enumerated Time_Delay_Normal",
);
assert_refused(
&mut db,
ai_oid,
PropertyIdentifier::TIME_DELAY_NORMAL,
PropertyValue::Unsigned(u32::MAX as u64 + 1),
ErrorCode::VALUE_OUT_OF_RANGE,
PropertyValue::Unsigned(9),
"AI oversized Time_Delay_Normal",
);
}
#[test]
fn write_property_rejects_overwide_color_temperature_without_mutation() {
const HOSTILE: u64 = 0x1_0000_03E8;
let mut db = ObjectDatabase::new();
let object = ColorTemperatureObject::new(1, "CT-1").unwrap();
let oid = object.object_identifier();
db.add(Box::new(object)).unwrap();
let property_value = encode_value(PropertyValue::Unsigned(HOSTILE));
assert_eq!(property_value[0], 0x25);
let (tag, content_start) = bacnet_encoding::tags::decode_tag(&property_value, 0).unwrap();
assert_eq!(tag.class, bacnet_encoding::tags::TagClass::Application);
assert_eq!(tag.number, bacnet_encoding::tags::app_tag::UNSIGNED);
assert_eq!(tag.length, 5);
assert_eq!(
&property_value[content_start..],
&[0x01, 0x00, 0x00, 0x03, 0xE8]
);
let request = WritePropertyRequest {
object_identifier: oid,
property_identifier: PropertyIdentifier::PRESENT_VALUE,
property_array_index: None,
property_value,
priority: None,
};
let mut request_bytes = BytesMut::new();
request.encode(&mut request_bytes).unwrap();
match sourced_wp(&mut db, &request_bytes)
.expect_err("over-wide Color Temperature Present_Value must be rejected")
{
Error::Remote(RemoteError::Error { class, code }) => {
assert_eq!(class, ErrorClass::PROPERTY);
assert_eq!(code, ErrorCode::VALUE_OUT_OF_RANGE);
}
other => panic!("expected PROPERTY/VALUE_OUT_OF_RANGE, got {other:?}"),
}
for property in [
PropertyIdentifier::PRESENT_VALUE,
PropertyIdentifier::TRACKING_VALUE,
] {
assert_eq!(
read_wire(&db, oid, property),
PropertyValue::Unsigned(4000),
"refused write must leave {property:?} unchanged"
);
}
}