use std::sync::{Arc, Mutex};
use bacnet_objects::analog::AnalogOutputObject;
use bacnet_objects::clock::{ClockFrame, ClockReader};
use bacnet_objects::device::{DeviceConfig, DeviceObject};
use bacnet_objects::schedule::{CalendarObject, ScheduleObject};
use bacnet_services::read_property::{ReadPropertyACK, ReadPropertyRequest};
use bacnet_services::write_property::WritePropertyRequest;
use bacnet_types::constructed::{
BACnetDateRange, BACnetObjectPropertyReference, BACnetSpecialEvent, BACnetTimeValue,
SpecialEventPeriod,
};
use bytes::BytesMut;
use super::*;
use crate::handlers::{handle_read_property, handle_write_property};
use bacnet_types::enums::EngineeringUnits;
pub(crate) struct SettableClock(Mutex<ClockFrame>);
impl SettableClock {
pub(crate) fn at(year: u16, month: u8, day: u8, hour: u8, minute: u8) -> Arc<Self> {
Arc::new(Self(Mutex::new(Self::frame(
year, month, day, hour, minute,
))))
}
pub(crate) fn set(&self, year: u16, month: u8, day: u8, hour: u8, minute: u8) {
*self.0.lock().unwrap() = Self::frame(year, month, day, hour, minute);
}
fn frame(year: u16, month: u8, day: u8, hour: u8, minute: u8) -> ClockFrame {
ClockFrame {
local_date: SpecificDate::new(year, month, day).unwrap().to_date(),
local_time: Time {
hour,
minute,
second: 0,
hundredths: 0,
},
utc_offset: 0,
daylight_savings_status: false,
}
}
}
impl ClockReader for SettableClock {
fn read_clock(&self) -> Option<ClockFrame> {
Some(*self.0.lock().unwrap())
}
}
const SCHEDULE: u32 = 1;
const CALENDAR: u32 = 3;
fn oid(object_type: ObjectType, instance: u32) -> ObjectIdentifier {
ObjectIdentifier::new(object_type, instance).unwrap()
}
fn target() -> ObjectIdentifier {
oid(ObjectType::ANALOG_OUTPUT, 2)
}
fn at(hour: u8, minute: u8) -> Time {
Time {
hour,
minute,
second: 0,
hundredths: 0,
}
}
fn database(clock: &Arc<SettableClock>, mut schedule: ScheduleObject) -> ObjectDatabase {
schedule
.add_object_property_reference(BACnetObjectPropertyReference::new(
target(),
PropertyIdentifier::PRESENT_VALUE.to_raw(),
))
.unwrap();
let mut db = ObjectDatabase::new();
db.set_clock_reader(Some(clock.clone()));
db.add(Box::new(
DeviceObject::new(DeviceConfig::default()).unwrap(),
))
.unwrap();
db.add(Box::new(
AnalogOutputObject::new(2, "AO-2", EngineeringUnits::DEGREES_CELSIUS).unwrap(),
))
.unwrap();
db.add(Box::new(schedule)).unwrap();
db
}
fn read_wire(
db: &ObjectDatabase,
object_identifier: ObjectIdentifier,
property_identifier: PropertyIdentifier,
property_array_index: Option<u32>,
) -> Vec<u8> {
let mut request = BytesMut::new();
ReadPropertyRequest {
object_identifier,
property_identifier,
property_array_index,
}
.encode(&mut request);
let mut response = BytesMut::new();
handle_read_property(db, &request, &mut response).unwrap();
ReadPropertyACK::decode(&response).unwrap().property_value
}
fn write_wire(
db: &mut ObjectDatabase,
object_identifier: ObjectIdentifier,
property_identifier: PropertyIdentifier,
property_value: &[u8],
) {
let mut request = BytesMut::new();
WritePropertyRequest {
object_identifier,
property_identifier,
property_array_index: None,
property_value: property_value.to_vec(),
priority: None,
}
.encode(&mut request)
.unwrap();
handle_write_property(db, &request).unwrap();
}
fn target_wire(db: &ObjectDatabase, slot: u32) -> (Vec<u8>, Vec<u8>) {
(
read_wire(db, target(), PropertyIdentifier::PRESENT_VALUE, None),
read_wire(db, target(), PropertyIdentifier::PRIORITY_ARRAY, Some(slot)),
)
}
fn schedule_present_value(db: &ObjectDatabase) -> Vec<u8> {
read_wire(
db,
oid(ObjectType::SCHEDULE, SCHEDULE),
PropertyIdentifier::PRESENT_VALUE,
None,
)
}
const REAL_21_5: &[u8] = &[0x44, 0x41, 0xAC, 0x00, 0x00];
const REAL_5: &[u8] = &[0x44, 0x40, 0xA0, 0x00, 0x00];
const REAL_10: &[u8] = &[0x44, 0x41, 0x20, 0x00, 0x00];
const REAL_0: &[u8] = &[0x44, 0x00, 0x00, 0x00, 0x00];
const NULL: &[u8] = &[0x00];
#[tokio::test]
async fn schedule_commands_its_typed_value_at_priority_for_writing_and_relinquishes_with_null() {
let clock = SettableClock::at(2026, 9, 14, 7, 0); let mut schedule = ScheduleObject::new(SCHEDULE, "SCH-1", PropertyValue::Null).unwrap();
schedule
.set_weekly_schedule(
0,
vec![
BACnetTimeValue {
time: at(8, 0),
value: PropertyValue::Real(21.5),
},
BACnetTimeValue {
time: at(17, 0),
value: PropertyValue::Null,
},
],
)
.unwrap();
schedule.set_priority_for_writing(9).unwrap();
let db = Arc::new(RwLock::new(database(&clock, schedule)));
tick_schedules(&db).await;
let guard = db.read().await;
assert_eq!(schedule_present_value(&guard), NULL);
assert_eq!(target_wire(&guard, 9), (REAL_0.to_vec(), NULL.to_vec()));
drop(guard);
clock.set(2026, 9, 14, 8, 0);
tick_schedules(&db).await;
let guard = db.read().await;
assert_eq!(schedule_present_value(&guard), REAL_21_5);
assert_eq!(
target_wire(&guard, 9),
(REAL_21_5.to_vec(), REAL_21_5.to_vec())
);
assert_eq!(target_wire(&guard, 16).1, NULL);
drop(guard);
clock.set(2026, 9, 14, 17, 0);
tick_schedules(&db).await;
let guard = db.read().await;
assert_eq!(schedule_present_value(&guard), NULL);
assert_eq!(target_wire(&guard, 9), (REAL_0.to_vec(), NULL.to_vec()));
}
#[tokio::test]
async fn schedule_follows_a_calendar_whose_date_list_is_written_over_the_wire() {
let clock = SettableClock::at(2026, 9, 14, 9, 0); let calendar = oid(ObjectType::CALENDAR, CALENDAR);
let mut schedule = ScheduleObject::new(SCHEDULE, "SCH-1", PropertyValue::Real(10.0)).unwrap();
schedule
.set_weekly_schedule(
0,
vec![BACnetTimeValue {
time: at(8, 0),
value: PropertyValue::Real(21.5),
}],
)
.unwrap();
schedule
.add_exception(BACnetSpecialEvent {
period: SpecialEventPeriod::CalendarReference(calendar),
list_of_time_values: vec![BACnetTimeValue {
time: at(0, 0),
value: PropertyValue::Real(5.0),
}],
event_priority: 1,
})
.unwrap();
let mut db = database(&clock, schedule);
db.add(Box::new(CalendarObject::new(CALENDAR, "Holidays").unwrap()))
.unwrap();
let db = Arc::new(RwLock::new(db));
let calendar_pv =
|db: &ObjectDatabase| read_wire(db, calendar, PropertyIdentifier::PRESENT_VALUE, None);
tick_schedules(&db).await;
assert_eq!(calendar_pv(&*db.read().await), [0x10]);
assert_eq!(target_wire(&*db.read().await, 16).0, REAL_21_5);
write_wire(
&mut *db.write().await,
calendar,
PropertyIdentifier::DATE_LIST,
&[0x0C, 126, 9, 14, 0xFF],
);
assert_eq!(calendar_pv(&*db.read().await), [0x11]);
tick_schedules(&db).await;
assert_eq!(schedule_present_value(&*db.read().await), REAL_5);
assert_eq!(target_wire(&*db.read().await, 16).0, REAL_5);
clock.set(2026, 9, 15, 9, 0);
assert_eq!(calendar_pv(&*db.read().await), [0x10]);
tick_schedules(&db).await;
assert_eq!(target_wire(&*db.read().await, 16).0, REAL_10);
}
#[tokio::test]
async fn schedule_writes_only_within_its_effective_period() {
let clock = SettableClock::at(2026, 9, 13, 12, 0);
let mut schedule = ScheduleObject::new(SCHEDULE, "SCH-1", PropertyValue::Real(10.0)).unwrap();
schedule
.set_effective_period(BACnetDateRange {
start_date: SpecificDate::new(2026, 9, 14).unwrap().to_date(),
end_date: SpecificDate::new(2026, 9, 15).unwrap().to_date(),
})
.unwrap();
for day in [0, 2] {
schedule
.set_weekly_schedule(
day,
vec![BACnetTimeValue {
time: at(8, 0),
value: PropertyValue::Real(21.5),
}],
)
.unwrap();
}
let db = Arc::new(RwLock::new(database(&clock, schedule)));
tick_schedules(&db).await;
assert_eq!(target_wire(&*db.read().await, 16).1, NULL);
clock.set(2026, 9, 14, 12, 0);
tick_schedules(&db).await;
assert_eq!(target_wire(&*db.read().await, 16).1, REAL_21_5);
clock.set(2026, 9, 15, 12, 0);
tick_schedules(&db).await;
assert_eq!(target_wire(&*db.read().await, 16).1, REAL_10);
clock.set(2026, 9, 16, 12, 0);
tick_schedules(&db).await;
assert_eq!(target_wire(&*db.read().await, 16).1, REAL_10);
assert_eq!(schedule_present_value(&*db.read().await), REAL_10);
}
#[tokio::test]
async fn a_present_value_written_out_of_service_reaches_the_target_without_a_clock() {
let clock = SettableClock::at(2026, 9, 14, 9, 0);
let mut schedule = ScheduleObject::new(SCHEDULE, "SCH-1", PropertyValue::Real(10.0)).unwrap();
schedule.set_priority_for_writing(9).unwrap();
let mut db = database(&clock, schedule);
db.set_clock_reader(None);
let sch = oid(ObjectType::SCHEDULE, SCHEDULE);
write_wire(&mut db, sch, PropertyIdentifier::OUT_OF_SERVICE, &[0x11]);
write_wire(&mut db, sch, PropertyIdentifier::PRESENT_VALUE, REAL_5);
assert_eq!(target_wire(&db, 9), (REAL_0.to_vec(), NULL.to_vec()));
let db = Arc::new(RwLock::new(db));
tick_schedules(&db).await;
let guard = db.read().await;
assert_eq!(schedule_present_value(&guard), REAL_5);
assert_eq!(target_wire(&guard, 9), (REAL_5.to_vec(), REAL_5.to_vec()));
}