use std::sync::Arc;
use bacnet_encoding::constructed::encode_object_property_reference;
use bacnet_objects::analog::{AnalogInputObject, AnalogValueObject};
use bacnet_objects::device::{DeviceConfig, DeviceObject};
use bacnet_objects::multistate::MultiStateValueObject;
use bacnet_objects::schedule::ScheduleObject;
use bacnet_types::constructed::BACnetObjectPropertyReference;
use bacnet_types::enums::{EngineeringUnits, Reliability};
use bytes::BytesMut;
use super::tests::SettableClock;
use super::*;
use PropertyIdentifier as P;
pub(super) fn oid(object_type: ObjectType, instance: u32) -> ObjectIdentifier {
ObjectIdentifier::new(object_type, instance).unwrap()
}
pub(super) fn sch() -> ObjectIdentifier {
oid(ObjectType::SCHEDULE, 1)
}
pub(super) fn av(instance: u32) -> ObjectIdentifier {
oid(ObjectType::ANALOG_VALUE, instance)
}
pub(super) fn reference(object: ObjectIdentifier, property: P) -> BACnetObjectPropertyReference {
BACnetObjectPropertyReference::new(object, property.to_raw())
}
pub(super) fn indexed(
object: ObjectIdentifier,
property: P,
index: u32,
) -> BACnetObjectPropertyReference {
BACnetObjectPropertyReference::new_indexed(object, property.to_raw(), index)
}
pub(super) fn database(
default: PropertyValue,
references: Vec<BACnetObjectPropertyReference>,
) -> Arc<RwLock<ObjectDatabase>> {
let mut schedule = ScheduleObject::new(1, "SCH-1", default).unwrap();
schedule.set_object_property_references(references).unwrap();
let mut db = ObjectDatabase::new();
db.set_clock_reader(Some(SettableClock::at(2026, 10, 5, 9, 0)));
db.add(Box::new(
DeviceObject::new(DeviceConfig::default()).unwrap(),
))
.unwrap();
db.add(Box::new(
AnalogInputObject::new(1, "AI-1", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
))
.unwrap();
for instance in [1, 2] {
db.add(Box::new(
AnalogValueObject::new(
instance,
format!("AV-{instance}"),
EngineeringUnits::DEGREES_CELSIUS,
)
.unwrap(),
))
.unwrap();
}
db.add(Box::new(MultiStateValueObject::new(1, "MSV-1", 3).unwrap()))
.unwrap();
db.add(Box::new(schedule)).unwrap();
Arc::new(RwLock::new(db))
}
pub(super) fn read(db: &ObjectDatabase, object: ObjectIdentifier, property: P) -> PropertyValue {
db.get(&object)
.unwrap()
.read_property(property, None)
.unwrap()
}
pub(super) async fn health(db: &RwLock<ObjectDatabase>) -> (Reliability, bool) {
let db = db.read().await;
let PropertyValue::Enumerated(raw) = read(&db, sch(), P::RELIABILITY) else {
panic!("Reliability reads as an enumeration");
};
let PropertyValue::BitString { data, .. } = read(&db, sch(), P::STATUS_FLAGS) else {
panic!("Status_Flags reads as a bit string");
};
let fault = data.first().is_some_and(|octet| octet & 0x40 != 0);
(Reliability::from_raw(raw), fault)
}
pub(super) const FAULTED: (Reliability, bool) = (Reliability::CONFIGURATION_ERROR, true);
pub(super) const HEALTHY: (Reliability, bool) = (Reliability::NO_FAULT_DETECTED, false);
#[tokio::test]
async fn a_reference_its_target_can_never_write_faults_the_schedule() {
let real = || PropertyValue::Real(5.0);
for (case, default, references) in [
(
"PROPERTY_IS_NOT_AN_ARRAY",
real(),
vec![
reference(av(1), P::PRESENT_VALUE),
indexed(av(2), P::DESCRIPTION, 1),
],
),
(
"UNKNOWN_OBJECT",
real(),
vec![reference(av(9), P::PRESENT_VALUE)],
),
(
"UNKNOWN_PROPERTY",
real(),
vec![reference(av(1), P::STATE_TEXT)],
),
(
"INVALID_ARRAY_INDEX",
PropertyValue::CharacterString("Away".into()),
vec![indexed(
oid(ObjectType::MULTI_STATE_VALUE, 1),
P::STATE_TEXT,
9,
)],
),
] {
let db = database(default, references);
tick_schedules(&db).await;
assert_eq!(health(&db).await, FAULTED, "{case}");
}
let db = database(
real(),
vec![
reference(av(1), P::PRESENT_VALUE),
indexed(av(2), P::DESCRIPTION, 1),
],
);
tick_schedules(&db).await;
assert_eq!(read(&*db.read().await, av(1), P::PRESENT_VALUE), real());
let db = database(
real(),
vec![reference(
oid(ObjectType::ANALOG_INPUT, 1),
P::PRESENT_VALUE,
)],
);
tick_schedules(&db).await;
assert_eq!(health(&db).await, HEALTHY);
}
async fn write_references(
db: &RwLock<ObjectDatabase>,
references: &[BACnetObjectPropertyReference],
) {
let mut encoded = BytesMut::new();
for reference in references {
encode_object_property_reference(&mut encoded, reference);
}
db.write()
.await
.get_mut(&sch())
.unwrap()
.write_property(
P::LIST_OF_OBJECT_PROPERTY_REFERENCES,
None,
PropertyValue::ApplicationData(encoded.to_vec()),
None,
)
.unwrap();
}
#[tokio::test]
async fn the_fault_clears_once_the_reference_is_fixed() {
let db = database(
PropertyValue::Real(5.0),
vec![
reference(av(1), P::PRESENT_VALUE),
indexed(av(2), P::DESCRIPTION, 1),
],
);
tick_schedules(&db).await;
assert_eq!(health(&db).await, FAULTED);
let fixed = [
reference(av(1), P::PRESENT_VALUE),
reference(av(2), P::PRESENT_VALUE),
];
write_references(&db, &fixed).await;
assert_eq!(health(&db).await, HEALTHY);
tick_schedules(&db).await;
assert_eq!(health(&db).await, HEALTHY);
assert_eq!(
read(&*db.read().await, av(2), P::PRESENT_VALUE),
PropertyValue::Real(5.0)
);
}
#[tokio::test(start_paused = true)]
async fn a_missing_object_created_later_takes_the_value_at_the_next_pass() {
let db = database(
PropertyValue::Real(5.0),
vec![reference(av(9), P::PRESENT_VALUE)],
);
tick_schedules(&db).await;
assert_eq!(health(&db).await, FAULTED);
tick_schedules(&db).await;
assert_eq!(health(&db).await, FAULTED, "AV-9 still missing");
db.write()
.await
.add(Box::new(
AnalogValueObject::new(9, "AV-9", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
))
.unwrap();
tick_schedules(&db).await;
assert_eq!(health(&db).await, HEALTHY);
assert_eq!(
read(&*db.read().await, av(9), P::PRESENT_VALUE),
PropertyValue::Real(5.0)
);
}