use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
use bacnet_objects::analog::AnalogOutputObject;
use bacnet_objects::channel::ChannelObject;
use bacnet_objects::command::CommandObject;
use bacnet_objects::device::{DeviceConfig, DeviceObject};
use bacnet_objects::multistate::MultiStateValueObject;
use bacnet_objects::schedule::ScheduleObject;
use bacnet_types::constructed::{
BACnetActionCommand, BACnetActionList, BACnetDeviceObjectPropertyReference,
};
use tracing::span::{Attributes, Id, Record};
use tracing::subscriber::Interest;
use tracing::{Event, Level, Metadata, Subscriber};
use super::reference_reliability_tests::{
FAULTED, HEALTHY, database, health, indexed, oid, read, reference,
};
use super::tests::SettableClock;
use super::*;
use PropertyIdentifier as P;
use bacnet_types::enums::EngineeringUnits;
fn msv(instance: u32) -> ObjectIdentifier {
oid(ObjectType::MULTI_STATE_VALUE, instance)
}
fn ao(instance: u32) -> ObjectIdentifier {
oid(ObjectType::ANALOG_OUTPUT, instance)
}
fn text(value: &str) -> PropertyValue {
PropertyValue::CharacterString(value.into())
}
#[tokio::test(start_paused = true)]
async fn a_state_text_index_that_becomes_valid_clears_the_fault() {
let db = database(text("Away"), vec![indexed(msv(1), P::STATE_TEXT, 5)]);
tick_schedules(&db).await;
assert_eq!(health(&db).await, FAULTED);
db.write()
.await
.add(Box::new(MultiStateValueObject::new(1, "MSV-1", 5).unwrap()))
.unwrap();
tick_schedules(&db).await;
assert_eq!(health(&db).await, HEALTHY);
let db = db.read().await;
let state_text = db
.get(&msv(1))
.unwrap()
.read_property(P::STATE_TEXT, Some(5))
.unwrap();
assert_eq!(state_text, text("Away"));
}
fn command_and_channel() -> Arc<RwLock<ObjectDatabase>> {
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();
for instance in [1, 2] {
db.add(Box::new(
AnalogOutputObject::new(
instance,
format!("AO-{instance}"),
EngineeringUnits::DEGREES_CELSIUS,
)
.unwrap(),
))
.unwrap();
}
let mut command = CommandObject::new(1, "CMD-1").unwrap();
command
.set_action(vec![BACnetActionList {
commands: vec![BACnetActionCommand {
device_identifier: None,
object_identifier: ao(1),
property_identifier: P::PRESENT_VALUE,
property_array_index: None,
property_value: PropertyValue::Real(50.0),
priority: Some(8),
post_delay: None,
quit_on_failure: false,
write_successful: true,
}],
}])
.unwrap();
db.add(Box::new(command)).unwrap();
let mut channel = ChannelObject::new(1, "CH-1", 1).unwrap();
channel
.set_members(vec![BACnetDeviceObjectPropertyReference::new_local(
ao(2),
P::PRESENT_VALUE.to_raw(),
)])
.unwrap();
channel.set_execution_delay(vec![0]).unwrap();
db.add(Box::new(channel)).unwrap();
let mut schedule = ScheduleObject::new(1, "SCH-1", PropertyValue::Unsigned(1)).unwrap();
schedule
.set_object_property_references(vec![
reference(oid(ObjectType::COMMAND, 1), P::PRESENT_VALUE),
reference(oid(ObjectType::CHANNEL, 1), P::PRESENT_VALUE),
reference(msv(9), P::PRESENT_VALUE),
])
.unwrap();
db.add(Box::new(schedule)).unwrap();
Arc::new(RwLock::new(db))
}
async fn slots(db: &RwLock<ObjectDatabase>) -> [PropertyValue; 2] {
let db = db.read().await;
let slot = |object, priority| {
db.get(&object)
.unwrap()
.read_property(P::PRIORITY_ARRAY, Some(priority))
.unwrap()
};
[slot(ao(1), 8), slot(ao(2), 16)]
}
#[tokio::test(start_paused = true)]
async fn a_retry_runs_no_command_or_channel_member_again() {
let db = command_and_channel();
tick_schedules(&db).await;
assert_eq!(health(&db).await, FAULTED, "MSV-9 is missing");
assert_eq!(
slots(&db).await,
[PropertyValue::Real(50.0), PropertyValue::Real(1.0)]
);
{
let mut db = db.write().await;
for (object, priority) in [(ao(1), 8), (ao(2), 16)] {
db.get_mut(&object)
.unwrap()
.write_property_from(
P::PRESENT_VALUE,
None,
PropertyValue::Real(7.0),
Some(priority),
&crate::command_source::test_origin(),
)
.unwrap();
}
}
let taken_over = [PropertyValue::Real(7.0), PropertyValue::Real(7.0)];
tick_schedules(&db).await;
assert_eq!(health(&db).await, FAULTED, "MSV-9 still missing");
assert_eq!(slots(&db).await, taken_over);
db.write()
.await
.add(Box::new(MultiStateValueObject::new(9, "MSV-9", 3).unwrap()))
.unwrap();
tick_schedules(&db).await;
assert_eq!(health(&db).await, HEALTHY);
assert_eq!(
read(&*db.read().await, msv(9), P::PRESENT_VALUE),
PropertyValue::Unsigned(1)
);
assert_eq!(slots(&db).await, taken_over);
}
#[derive(Clone, Default)]
struct ScheduleLog {
debug: Arc<AtomicUsize>,
above_debug: Arc<AtomicUsize>,
}
impl ScheduleLog {
fn install(&self) -> tracing::subscriber::DefaultGuard {
static REGISTRATION_PEER: OnceLock<tracing::Dispatch> = OnceLock::new();
REGISTRATION_PEER
.get_or_init(|| tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default()));
tracing::subscriber::set_default(self.clone())
}
fn take(&self) -> (usize, usize) {
(
self.debug.swap(0, Ordering::SeqCst),
self.above_debug.swap(0, Ordering::SeqCst),
)
}
}
impl Subscriber for ScheduleLog {
fn register_callsite(&self, _: &'static Metadata<'static>) -> Interest {
Interest::sometimes()
}
fn max_level_hint(&self) -> Option<tracing::metadata::LevelFilter> {
Some(tracing::metadata::LevelFilter::DEBUG)
}
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
metadata.target() == "bacnet_server::schedule"
}
fn new_span(&self, _: &Attributes<'_>) -> Id {
Id::from_u64(1)
}
fn record(&self, _: &Id, _: &Record<'_>) {}
fn record_follows_from(&self, _: &Id, _: &Id) {}
fn event(&self, event: &Event<'_>) {
if !self.enabled(event.metadata()) {
return;
}
match *event.metadata().level() {
Level::DEBUG => self.debug.fetch_add(1, Ordering::SeqCst),
Level::TRACE => 0,
_ => self.above_debug.fetch_add(1, Ordering::SeqCst),
};
}
fn enter(&self, _: &Id) {}
fn exit(&self, _: &Id) {}
}
#[tokio::test(start_paused = true)]
async fn a_reference_still_refused_keeps_the_fault_and_logs_only_at_debug() {
let log = ScheduleLog::default();
let _guard = log.install();
let db = database(text("Away"), vec![indexed(msv(1), P::STATE_TEXT, 9)]);
tick_schedules(&db).await;
assert_eq!(health(&db).await, FAULTED);
let (_, warnings) = log.take();
assert_eq!(warnings, 1, "the first refusal warns");
for _ in 0..3 {
tick_schedules(&db).await;
assert_eq!(health(&db).await, FAULTED);
let (debug, above_debug) = log.take();
assert_eq!(above_debug, 0, "a retry logs nothing above debug");
assert!(debug >= 2, "the retry and its refusal log at debug");
}
}
#[tokio::test(start_paused = true)]
async fn a_retry_that_fails_otherwise_ends_the_fault_and_warns_once() {
let log = ScheduleLog::default();
let _guard = log.install();
let five = || PropertyValue::Unsigned(5);
let msv9 = || Box::new(MultiStateValueObject::new(9, "MSV-9", 3).unwrap());
let db = database(five(), vec![reference(msv(9), P::PRESENT_VALUE)]);
db.write().await.add(msv9()).unwrap();
tick_schedules(&db).await;
assert_eq!(health(&db).await, HEALTHY);
assert_eq!(log.take().1, 1);
let db = database(five(), vec![reference(msv(9), P::PRESENT_VALUE)]);
tick_schedules(&db).await;
assert_eq!(health(&db).await, FAULTED, "MSV-9 is missing");
log.take();
db.write().await.add(msv9()).unwrap();
tick_schedules(&db).await;
assert_eq!(health(&db).await, HEALTHY);
assert_eq!(log.take().1, 1, "the retry's new failure warns once");
assert_eq!(
read(&*db.read().await, msv(9), P::PRESENT_VALUE),
PropertyValue::Unsigned(1)
);
tick_schedules(&db).await;
assert_eq!(health(&db).await, HEALTHY);
assert_eq!(log.take(), (0, 0));
}