Skip to main content

bacnet_objects/
schedule.rs

1//! Schedule (type 17) and Calendar (type 6) objects per ASHRAE 135-2020.
2
3use bacnet_types::constructed::{
4    BACnetCalendarEntry, BACnetDateRange, BACnetObjectPropertyReference, BACnetSpecialEvent,
5    BACnetTimeValue,
6};
7use bacnet_types::enums::{ErrorClass, ErrorCode, ObjectType, PropertyIdentifier};
8use bacnet_types::error::Error;
9use bacnet_types::primitives::{ObjectIdentifier, PropertyValue, StatusFlags};
10use std::borrow::Cow;
11
12use crate::common::read_property_list_property;
13use crate::traits::BACnetObject;
14
15// ---------------------------------------------------------------------------
16// Calendar (type 6)
17// ---------------------------------------------------------------------------
18
19/// BACnet Calendar object.
20///
21/// Present_Value is Boolean — true when today matches one of the date_list
22/// entries. The application is responsible for evaluating the date_list and
23/// calling `set_present_value()`.
24pub struct CalendarObject {
25    oid: ObjectIdentifier,
26    name: String,
27    description: String,
28    present_value: bool,
29    status_flags: StatusFlags,
30    date_list: Vec<BACnetCalendarEntry>,
31}
32
33impl CalendarObject {
34    pub fn new(instance: u32, name: impl Into<String>) -> Result<Self, Error> {
35        let oid = ObjectIdentifier::new(ObjectType::CALENDAR, instance)?;
36        Ok(Self {
37            oid,
38            name: name.into(),
39            description: String::new(),
40            present_value: false,
41            status_flags: StatusFlags::empty(),
42            date_list: Vec::new(),
43        })
44    }
45
46    /// Application sets this based on date-list evaluation.
47    pub fn set_present_value(&mut self, value: bool) {
48        self.present_value = value;
49    }
50
51    /// Set the description string.
52    pub fn set_description(&mut self, desc: impl Into<String>) {
53        self.description = desc.into();
54    }
55
56    /// Append a calendar entry to the date_list.
57    pub fn add_date_entry(&mut self, entry: BACnetCalendarEntry) {
58        self.date_list.push(entry);
59    }
60
61    /// Remove all entries from the date_list.
62    pub fn clear_date_list(&mut self) {
63        self.date_list.clear();
64    }
65}
66
67impl BACnetObject for CalendarObject {
68    fn object_identifier(&self) -> ObjectIdentifier {
69        self.oid
70    }
71
72    fn object_name(&self) -> &str {
73        &self.name
74    }
75
76    fn read_property(
77        &self,
78        property: PropertyIdentifier,
79        array_index: Option<u32>,
80    ) -> Result<PropertyValue, Error> {
81        match property {
82            p if p == PropertyIdentifier::OBJECT_IDENTIFIER => {
83                Ok(PropertyValue::ObjectIdentifier(self.oid))
84            }
85            p if p == PropertyIdentifier::OBJECT_NAME => {
86                Ok(PropertyValue::CharacterString(self.name.clone()))
87            }
88            p if p == PropertyIdentifier::DESCRIPTION => {
89                Ok(PropertyValue::CharacterString(self.description.clone()))
90            }
91            p if p == PropertyIdentifier::OBJECT_TYPE => {
92                Ok(PropertyValue::Enumerated(ObjectType::CALENDAR.to_raw()))
93            }
94            p if p == PropertyIdentifier::PRESENT_VALUE => {
95                Ok(PropertyValue::Boolean(self.present_value))
96            }
97            p if p == PropertyIdentifier::STATUS_FLAGS => Ok(PropertyValue::BitString {
98                unused_bits: 4,
99                data: vec![self.status_flags.bits() << 4],
100            }),
101            p if p == PropertyIdentifier::EVENT_STATE => Ok(PropertyValue::Enumerated(0)),
102            p if p == PropertyIdentifier::OUT_OF_SERVICE => Ok(PropertyValue::Boolean(false)),
103            p if p == PropertyIdentifier::DATE_LIST => Ok(PropertyValue::List(
104                self.date_list
105                    .iter()
106                    .map(|entry| match entry {
107                        BACnetCalendarEntry::Date(d) => PropertyValue::Date(*d),
108                        BACnetCalendarEntry::DateRange(dr) => {
109                            PropertyValue::OctetString(dr.encode().to_vec())
110                        }
111                        BACnetCalendarEntry::WeekNDay(wnd) => {
112                            PropertyValue::OctetString(wnd.encode().to_vec())
113                        }
114                    })
115                    .collect(),
116            )),
117            p if p == PropertyIdentifier::PROPERTY_LIST => {
118                read_property_list_property(&self.property_list(), array_index)
119            }
120            _ => Err(Error::Protocol {
121                class: ErrorClass::PROPERTY.to_raw() as u32,
122                code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32,
123            }),
124        }
125    }
126
127    fn write_property(
128        &mut self,
129        property: PropertyIdentifier,
130        _array_index: Option<u32>,
131        value: PropertyValue,
132        _priority: Option<u8>,
133    ) -> Result<(), Error> {
134        if property == PropertyIdentifier::DESCRIPTION {
135            if let PropertyValue::CharacterString(s) = value {
136                self.description = s;
137                return Ok(());
138            }
139            return Err(Error::Protocol {
140                class: ErrorClass::PROPERTY.to_raw() as u32,
141                code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
142            });
143        }
144        if property == PropertyIdentifier::PRESENT_VALUE {
145            return Err(Error::Protocol {
146                class: ErrorClass::PROPERTY.to_raw() as u32,
147                code: ErrorCode::WRITE_ACCESS_DENIED.to_raw() as u32,
148            });
149        }
150        Err(Error::Protocol {
151            class: ErrorClass::PROPERTY.to_raw() as u32,
152            code: ErrorCode::WRITE_ACCESS_DENIED.to_raw() as u32,
153        })
154    }
155
156    fn property_list(&self) -> Cow<'static, [PropertyIdentifier]> {
157        static PROPS: &[PropertyIdentifier] = &[
158            PropertyIdentifier::OBJECT_IDENTIFIER,
159            PropertyIdentifier::OBJECT_NAME,
160            PropertyIdentifier::DESCRIPTION,
161            PropertyIdentifier::OBJECT_TYPE,
162            PropertyIdentifier::PRESENT_VALUE,
163            PropertyIdentifier::DATE_LIST,
164            PropertyIdentifier::STATUS_FLAGS,
165            PropertyIdentifier::EVENT_STATE,
166            PropertyIdentifier::OUT_OF_SERVICE,
167        ];
168        Cow::Borrowed(PROPS)
169    }
170}
171
172// ---------------------------------------------------------------------------
173// Schedule (type 17)
174// ---------------------------------------------------------------------------
175
176/// BACnet Schedule object.
177///
178/// Stores schedule configuration. The application is responsible for
179/// evaluating the weekly/exception schedule and calling `set_present_value()`.
180/// Present_Value data type matches schedule_default.
181pub struct ScheduleObject {
182    oid: ObjectIdentifier,
183    name: String,
184    description: String,
185    present_value: PropertyValue,
186    schedule_default: PropertyValue,
187    out_of_service: bool,
188    reliability: u32,
189    status_flags: StatusFlags,
190    /// 7-day weekly schedule: index 0 = Monday, index 6 = Sunday.
191    weekly_schedule: [Vec<BACnetTimeValue>; 7],
192    exception_schedule: Vec<BACnetSpecialEvent>,
193    effective_period: Option<BACnetDateRange>,
194    list_of_object_property_references: Vec<BACnetObjectPropertyReference>,
195    /// Priority for writing to referenced objects (1-16).
196    priority_for_writing: u8,
197}
198
199impl ScheduleObject {
200    pub fn new(
201        instance: u32,
202        name: impl Into<String>,
203        schedule_default: PropertyValue,
204    ) -> Result<Self, Error> {
205        let oid = ObjectIdentifier::new(ObjectType::SCHEDULE, instance)?;
206        Ok(Self {
207            oid,
208            name: name.into(),
209            description: String::new(),
210            present_value: schedule_default.clone(),
211            schedule_default,
212            out_of_service: false,
213            reliability: 0,
214            status_flags: StatusFlags::empty(),
215            weekly_schedule: [vec![], vec![], vec![], vec![], vec![], vec![], vec![]],
216            exception_schedule: Vec::new(),
217            effective_period: None,
218            list_of_object_property_references: Vec::new(),
219            priority_for_writing: 16, // default: lowest priority
220        })
221    }
222
223    /// Application sets this based on schedule evaluation.
224    pub fn set_present_value(&mut self, value: PropertyValue) {
225        self.present_value = value;
226    }
227
228    /// Set the description string.
229    pub fn set_description(&mut self, desc: impl Into<String>) {
230        self.description = desc.into();
231    }
232
233    /// Set time-value entries for a given day (0=Monday .. 6=Sunday).
234    pub fn set_weekly_schedule(&mut self, day_index: usize, entries: Vec<BACnetTimeValue>) {
235        if day_index < 7 {
236            self.weekly_schedule[day_index] = entries;
237        }
238    }
239
240    /// Append a special event to the exception schedule.
241    pub fn add_exception(&mut self, event: BACnetSpecialEvent) {
242        self.exception_schedule.push(event);
243    }
244
245    /// Set the effective period for this schedule.
246    pub fn set_effective_period(&mut self, period: BACnetDateRange) {
247        self.effective_period = Some(period);
248    }
249
250    /// Append an object property reference to the list.
251    pub fn add_object_property_reference(&mut self, r: BACnetObjectPropertyReference) {
252        self.list_of_object_property_references.push(r);
253    }
254
255    /// Read the current present_value.
256    pub fn present_value(&self) -> &PropertyValue {
257        &self.present_value
258    }
259
260    /// Evaluate the schedule for the given day and time.
261    ///
262    /// Returns the current effective value (from exception, weekly, or default).
263    /// `day_of_week`: 0=Monday .. 6=Sunday.
264    pub fn evaluate(&self, day_of_week: u8, hour: u8, minute: u8) -> PropertyValue {
265        if self.out_of_service {
266            return self.present_value.clone();
267        }
268
269        // 1. Check exception_schedule first (highest priority = lowest number)
270        let mut best_exception: Option<(u8, &[u8])> = None;
271        for event in &self.exception_schedule {
272            if let Some(raw) = find_active_time_value(&event.list_of_time_values, hour, minute) {
273                match best_exception {
274                    None => best_exception = Some((event.event_priority, raw)),
275                    Some((p, _)) if event.event_priority < p => {
276                        best_exception = Some((event.event_priority, raw));
277                    }
278                    _ => {}
279                }
280            }
281        }
282        if let Some((_, raw)) = best_exception {
283            return PropertyValue::OctetString(raw.to_vec());
284        }
285
286        // 2. Check weekly_schedule[day_of_week]
287        if (day_of_week as usize) < 7 {
288            if let Some(raw) =
289                find_active_time_value(&self.weekly_schedule[day_of_week as usize], hour, minute)
290            {
291                return PropertyValue::OctetString(raw.to_vec());
292            }
293        }
294
295        // 3. Fall back to schedule_default
296        self.schedule_default.clone()
297    }
298}
299
300/// Find the last time-value entry whose time is at or before (hour, minute).
301///
302/// Entries are expected to be in chronological order per the BACnet spec.
303fn find_active_time_value(entries: &[BACnetTimeValue], hour: u8, minute: u8) -> Option<&[u8]> {
304    let mut result = None;
305    for tv in entries {
306        let t = &tv.time;
307        if t.hour < hour || (t.hour == hour && t.minute <= minute) {
308            result = Some(tv.value.as_slice());
309        }
310    }
311    result
312}
313
314impl BACnetObject for ScheduleObject {
315    fn object_identifier(&self) -> ObjectIdentifier {
316        self.oid
317    }
318
319    fn object_name(&self) -> &str {
320        &self.name
321    }
322
323    fn read_property(
324        &self,
325        property: PropertyIdentifier,
326        array_index: Option<u32>,
327    ) -> Result<PropertyValue, Error> {
328        match property {
329            p if p == PropertyIdentifier::OBJECT_IDENTIFIER => {
330                Ok(PropertyValue::ObjectIdentifier(self.oid))
331            }
332            p if p == PropertyIdentifier::OBJECT_NAME => {
333                Ok(PropertyValue::CharacterString(self.name.clone()))
334            }
335            p if p == PropertyIdentifier::DESCRIPTION => {
336                Ok(PropertyValue::CharacterString(self.description.clone()))
337            }
338            p if p == PropertyIdentifier::OBJECT_TYPE => {
339                Ok(PropertyValue::Enumerated(ObjectType::SCHEDULE.to_raw()))
340            }
341            p if p == PropertyIdentifier::PRESENT_VALUE => Ok(self.present_value.clone()),
342            p if p == PropertyIdentifier::SCHEDULE_DEFAULT => Ok(self.schedule_default.clone()),
343            p if p == PropertyIdentifier::STATUS_FLAGS => Ok(PropertyValue::BitString {
344                unused_bits: 4,
345                data: vec![self.status_flags.bits() << 4],
346            }),
347            p if p == PropertyIdentifier::EVENT_STATE => Ok(PropertyValue::Enumerated(0)),
348            p if p == PropertyIdentifier::RELIABILITY => {
349                Ok(PropertyValue::Enumerated(self.reliability))
350            }
351            p if p == PropertyIdentifier::OUT_OF_SERVICE => {
352                Ok(PropertyValue::Boolean(self.out_of_service))
353            }
354            p if p == PropertyIdentifier::WEEKLY_SCHEDULE => match array_index {
355                None => {
356                    let days: Vec<PropertyValue> = self
357                        .weekly_schedule
358                        .iter()
359                        .map(|day| {
360                            PropertyValue::List(
361                                day.iter()
362                                    .map(|tv| {
363                                        PropertyValue::List(vec![
364                                            PropertyValue::Time(tv.time),
365                                            PropertyValue::OctetString(tv.value.clone()),
366                                        ])
367                                    })
368                                    .collect(),
369                            )
370                        })
371                        .collect();
372                    Ok(PropertyValue::List(days))
373                }
374                Some(0) => Ok(PropertyValue::Unsigned(7)),
375                Some(idx) if (1..=7).contains(&idx) => {
376                    let day = &self.weekly_schedule[(idx - 1) as usize];
377                    Ok(PropertyValue::List(
378                        day.iter()
379                            .map(|tv| {
380                                PropertyValue::List(vec![
381                                    PropertyValue::Time(tv.time),
382                                    PropertyValue::OctetString(tv.value.clone()),
383                                ])
384                            })
385                            .collect(),
386                    ))
387                }
388                _ => Err(Error::Protocol {
389                    class: ErrorClass::PROPERTY.to_raw() as u32,
390                    code: ErrorCode::INVALID_ARRAY_INDEX.to_raw() as u32,
391                }),
392            },
393            p if p == PropertyIdentifier::EXCEPTION_SCHEDULE => match array_index {
394                None => {
395                    let events: Vec<PropertyValue> = self
396                        .exception_schedule
397                        .iter()
398                        .map(|ev| {
399                            let tvs: Vec<PropertyValue> = ev
400                                .list_of_time_values
401                                .iter()
402                                .map(|tv| {
403                                    PropertyValue::List(vec![
404                                        PropertyValue::Time(tv.time),
405                                        PropertyValue::OctetString(tv.value.clone()),
406                                    ])
407                                })
408                                .collect();
409                            PropertyValue::List(vec![
410                                PropertyValue::Unsigned(ev.event_priority as u64),
411                                PropertyValue::List(tvs),
412                            ])
413                        })
414                        .collect();
415                    Ok(PropertyValue::List(events))
416                }
417                Some(0) => Ok(PropertyValue::Unsigned(self.exception_schedule.len() as u64)),
418                Some(i) => {
419                    let idx = (i as usize).checked_sub(1).ok_or(Error::Protocol {
420                        class: ErrorClass::PROPERTY.to_raw() as u32,
421                        code: ErrorCode::INVALID_ARRAY_INDEX.to_raw() as u32,
422                    })?;
423                    let ev = self.exception_schedule.get(idx).ok_or(Error::Protocol {
424                        class: ErrorClass::PROPERTY.to_raw() as u32,
425                        code: ErrorCode::INVALID_ARRAY_INDEX.to_raw() as u32,
426                    })?;
427                    let tvs: Vec<PropertyValue> = ev
428                        .list_of_time_values
429                        .iter()
430                        .map(|tv| {
431                            PropertyValue::List(vec![
432                                PropertyValue::Time(tv.time),
433                                PropertyValue::OctetString(tv.value.clone()),
434                            ])
435                        })
436                        .collect();
437                    Ok(PropertyValue::List(vec![
438                        PropertyValue::Unsigned(ev.event_priority as u64),
439                        PropertyValue::List(tvs),
440                    ]))
441                }
442            },
443            p if p == PropertyIdentifier::EFFECTIVE_PERIOD => match &self.effective_period {
444                Some(dr) => Ok(PropertyValue::OctetString(dr.encode().to_vec())),
445                None => Ok(PropertyValue::Null),
446            },
447            p if p == PropertyIdentifier::LIST_OF_OBJECT_PROPERTY_REFERENCES => {
448                Ok(PropertyValue::List(
449                    self.list_of_object_property_references
450                        .iter()
451                        .map(|r| {
452                            PropertyValue::List(vec![
453                                PropertyValue::ObjectIdentifier(r.object_identifier),
454                                PropertyValue::Enumerated(r.property_identifier),
455                            ])
456                        })
457                        .collect(),
458                ))
459            }
460            p if p == PropertyIdentifier::PRIORITY_FOR_WRITING => {
461                Ok(PropertyValue::Unsigned(self.priority_for_writing as u64))
462            }
463            p if p == PropertyIdentifier::PROPERTY_LIST => {
464                read_property_list_property(&self.property_list(), array_index)
465            }
466            _ => Err(Error::Protocol {
467                class: ErrorClass::PROPERTY.to_raw() as u32,
468                code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32,
469            }),
470        }
471    }
472
473    fn write_property(
474        &mut self,
475        property: PropertyIdentifier,
476        _array_index: Option<u32>,
477        value: PropertyValue,
478        _priority: Option<u8>,
479    ) -> Result<(), Error> {
480        if property == PropertyIdentifier::SCHEDULE_DEFAULT {
481            self.schedule_default = value;
482            return Ok(());
483        }
484        if property == PropertyIdentifier::RELIABILITY {
485            if let PropertyValue::Enumerated(v) = value {
486                self.reliability = v;
487                return Ok(());
488            }
489            return Err(Error::Protocol {
490                class: ErrorClass::PROPERTY.to_raw() as u32,
491                code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
492            });
493        }
494        if property == PropertyIdentifier::OUT_OF_SERVICE {
495            if let PropertyValue::Boolean(v) = value {
496                self.out_of_service = v;
497                return Ok(());
498            }
499            return Err(Error::Protocol {
500                class: ErrorClass::PROPERTY.to_raw() as u32,
501                code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
502            });
503        }
504        if property == PropertyIdentifier::DESCRIPTION {
505            if let PropertyValue::CharacterString(s) = value {
506                self.description = s;
507                return Ok(());
508            }
509            return Err(Error::Protocol {
510                class: ErrorClass::PROPERTY.to_raw() as u32,
511                code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
512            });
513        }
514        Err(Error::Protocol {
515            class: ErrorClass::PROPERTY.to_raw() as u32,
516            code: ErrorCode::WRITE_ACCESS_DENIED.to_raw() as u32,
517        })
518    }
519
520    fn property_list(&self) -> Cow<'static, [PropertyIdentifier]> {
521        static PROPS: &[PropertyIdentifier] = &[
522            PropertyIdentifier::OBJECT_IDENTIFIER,
523            PropertyIdentifier::OBJECT_NAME,
524            PropertyIdentifier::DESCRIPTION,
525            PropertyIdentifier::OBJECT_TYPE,
526            PropertyIdentifier::PRESENT_VALUE,
527            PropertyIdentifier::SCHEDULE_DEFAULT,
528            PropertyIdentifier::WEEKLY_SCHEDULE,
529            PropertyIdentifier::EXCEPTION_SCHEDULE,
530            PropertyIdentifier::EFFECTIVE_PERIOD,
531            PropertyIdentifier::LIST_OF_OBJECT_PROPERTY_REFERENCES,
532            PropertyIdentifier::STATUS_FLAGS,
533            PropertyIdentifier::EVENT_STATE,
534            PropertyIdentifier::RELIABILITY,
535            PropertyIdentifier::OUT_OF_SERVICE,
536        ];
537        Cow::Borrowed(PROPS)
538    }
539
540    fn tick_schedule(
541        &mut self,
542        day_of_week: u8,
543        hour: u8,
544        minute: u8,
545    ) -> Option<(PropertyValue, Vec<(ObjectIdentifier, u32)>)> {
546        if self.out_of_service || self.list_of_object_property_references.is_empty() {
547            return None;
548        }
549
550        let new_value = self.evaluate(day_of_week, hour, minute);
551        if new_value == self.present_value {
552            return None;
553        }
554
555        self.present_value = new_value.clone();
556
557        let refs = self
558            .list_of_object_property_references
559            .iter()
560            .map(|r| (r.object_identifier, r.property_identifier))
561            .collect();
562
563        Some((new_value, refs))
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use bacnet_types::constructed::{
571        BACnetCalendarEntry, BACnetDateRange, BACnetSpecialEvent, BACnetTimeValue, BACnetWeekNDay,
572        SpecialEventPeriod,
573    };
574    use bacnet_types::primitives::{Date, Time};
575
576    // --- Calendar ---
577
578    #[test]
579    fn calendar_read_present_value_default() {
580        let cal = CalendarObject::new(1, "CAL-1").unwrap();
581        let val = cal
582            .read_property(PropertyIdentifier::PRESENT_VALUE, None)
583            .unwrap();
584        assert_eq!(val, PropertyValue::Boolean(false));
585    }
586
587    #[test]
588    fn calendar_set_present_value() {
589        let mut cal = CalendarObject::new(1, "CAL-1").unwrap();
590        cal.set_present_value(true);
591        let val = cal
592            .read_property(PropertyIdentifier::PRESENT_VALUE, None)
593            .unwrap();
594        assert_eq!(val, PropertyValue::Boolean(true));
595    }
596
597    #[test]
598    fn calendar_write_present_value_denied() {
599        let mut cal = CalendarObject::new(1, "CAL-1").unwrap();
600        let result = cal.write_property(
601            PropertyIdentifier::PRESENT_VALUE,
602            None,
603            PropertyValue::Boolean(true),
604            None,
605        );
606        assert!(result.is_err());
607    }
608
609    #[test]
610    fn calendar_date_list_empty_by_default() {
611        let cal = CalendarObject::new(1, "CAL-1").unwrap();
612        let val = cal
613            .read_property(PropertyIdentifier::DATE_LIST, None)
614            .unwrap();
615        assert_eq!(val, PropertyValue::List(vec![]));
616    }
617
618    #[test]
619    fn calendar_date_list_add_and_read_entries() {
620        let mut cal = CalendarObject::new(1, "CAL-1").unwrap();
621
622        // Add a Date entry
623        let d = Date {
624            year: 124,
625            month: 3,
626            day: 15,
627            day_of_week: 5,
628        };
629        cal.add_date_entry(BACnetCalendarEntry::Date(d));
630
631        // Add a DateRange entry
632        let dr = BACnetDateRange {
633            start_date: Date {
634                year: 124,
635                month: 6,
636                day: 1,
637                day_of_week: 6,
638            },
639            end_date: Date {
640                year: 124,
641                month: 6,
642                day: 30,
643                day_of_week: 0,
644            },
645        };
646        cal.add_date_entry(BACnetCalendarEntry::DateRange(dr.clone()));
647
648        // Add a WeekNDay entry
649        let wnd = BACnetWeekNDay {
650            month: BACnetWeekNDay::ANY,
651            week_of_month: BACnetWeekNDay::ANY,
652            day_of_week: 1,
653        };
654        cal.add_date_entry(BACnetCalendarEntry::WeekNDay(wnd.clone()));
655
656        let val = cal
657            .read_property(PropertyIdentifier::DATE_LIST, None)
658            .unwrap();
659
660        if let PropertyValue::List(items) = val {
661            assert_eq!(items.len(), 3);
662            assert_eq!(items[0], PropertyValue::Date(d));
663            assert_eq!(items[1], PropertyValue::OctetString(dr.encode().to_vec()));
664            assert_eq!(items[2], PropertyValue::OctetString(wnd.encode().to_vec()));
665        } else {
666            panic!("expected PropertyValue::List");
667        }
668    }
669
670    #[test]
671    fn calendar_date_list_clear() {
672        let mut cal = CalendarObject::new(1, "CAL-1").unwrap();
673        let d = Date {
674            year: 124,
675            month: 1,
676            day: 1,
677            day_of_week: 1,
678        };
679        cal.add_date_entry(BACnetCalendarEntry::Date(d));
680        // Confirm it was added
681        let val = cal
682            .read_property(PropertyIdentifier::DATE_LIST, None)
683            .unwrap();
684        if let PropertyValue::List(items) = &val {
685            assert_eq!(items.len(), 1);
686        } else {
687            panic!("expected PropertyValue::List");
688        }
689        // Clear and verify empty
690        cal.clear_date_list();
691        let val = cal
692            .read_property(PropertyIdentifier::DATE_LIST, None)
693            .unwrap();
694        assert_eq!(val, PropertyValue::List(vec![]));
695    }
696
697    #[test]
698    fn calendar_property_list_contains_date_list() {
699        let cal = CalendarObject::new(1, "CAL-1").unwrap();
700        let props = cal.property_list();
701        assert!(props.contains(&PropertyIdentifier::DATE_LIST));
702    }
703
704    // --- Schedule ---
705
706    #[test]
707    fn schedule_read_present_value_default() {
708        let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
709        let val = sched
710            .read_property(PropertyIdentifier::PRESENT_VALUE, None)
711            .unwrap();
712        assert_eq!(val, PropertyValue::Real(72.0));
713    }
714
715    #[test]
716    fn schedule_read_schedule_default() {
717        let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
718        let val = sched
719            .read_property(PropertyIdentifier::SCHEDULE_DEFAULT, None)
720            .unwrap();
721        assert_eq!(val, PropertyValue::Real(72.0));
722    }
723
724    #[test]
725    fn schedule_write_schedule_default() {
726        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
727        sched
728            .write_property(
729                PropertyIdentifier::SCHEDULE_DEFAULT,
730                None,
731                PropertyValue::Real(68.0),
732                None,
733            )
734            .unwrap();
735        let val = sched
736            .read_property(PropertyIdentifier::SCHEDULE_DEFAULT, None)
737            .unwrap();
738        assert_eq!(val, PropertyValue::Real(68.0));
739    }
740
741    #[test]
742    fn schedule_set_present_value() {
743        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
744        sched.set_present_value(PropertyValue::Real(65.0));
745        let val = sched
746            .read_property(PropertyIdentifier::PRESENT_VALUE, None)
747            .unwrap();
748        assert_eq!(val, PropertyValue::Real(65.0));
749    }
750
751    // --- Schedule weekly_schedule ---
752
753    fn make_time(hour: u8, minute: u8) -> Time {
754        Time {
755            hour,
756            minute,
757            second: 0,
758            hundredths: 0,
759        }
760    }
761
762    fn make_tv(hour: u8, minute: u8, raw_value: Vec<u8>) -> BACnetTimeValue {
763        BACnetTimeValue {
764            time: make_time(hour, minute),
765            value: raw_value,
766        }
767    }
768
769    #[test]
770    fn schedule_weekly_schedule_empty_by_default() {
771        let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
772        let val = sched
773            .read_property(PropertyIdentifier::WEEKLY_SCHEDULE, None)
774            .unwrap();
775        if let PropertyValue::List(days) = val {
776            assert_eq!(days.len(), 7);
777            for day in &days {
778                assert_eq!(*day, PropertyValue::List(vec![]));
779            }
780        } else {
781            panic!("expected PropertyValue::List");
782        }
783    }
784
785    #[test]
786    fn schedule_weekly_schedule_set_monday_read_no_index() {
787        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
788        let entries = vec![
789            make_tv(8, 0, vec![0x01]),  // 08:00
790            make_tv(17, 0, vec![0x00]), // 17:00
791        ];
792        sched.set_weekly_schedule(0, entries.clone()); // Monday
793
794        let val = sched
795            .read_property(PropertyIdentifier::WEEKLY_SCHEDULE, None)
796            .unwrap();
797        if let PropertyValue::List(days) = val {
798            assert_eq!(days.len(), 7);
799            // Monday (index 0) should have 2 entries
800            if let PropertyValue::List(monday_entries) = &days[0] {
801                assert_eq!(monday_entries.len(), 2);
802                // First entry: [Time(08:00), OctetString([0x01])]
803                if let PropertyValue::List(pair) = &monday_entries[0] {
804                    assert_eq!(pair[0], PropertyValue::Time(make_time(8, 0)));
805                    assert_eq!(pair[1], PropertyValue::OctetString(vec![0x01]));
806                } else {
807                    panic!("expected pair list");
808                }
809            } else {
810                panic!("expected Monday list");
811            }
812            // Remaining days should be empty
813            for day in days.iter().skip(1) {
814                assert_eq!(*day, PropertyValue::List(vec![]));
815            }
816        } else {
817            panic!("expected PropertyValue::List");
818        }
819    }
820
821    #[test]
822    fn schedule_weekly_schedule_index_0_returns_count() {
823        let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
824        let val = sched
825            .read_property(PropertyIdentifier::WEEKLY_SCHEDULE, Some(0))
826            .unwrap();
827        assert_eq!(val, PropertyValue::Unsigned(7));
828    }
829
830    #[test]
831    fn schedule_weekly_schedule_index_1_returns_monday() {
832        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
833        let entries = vec![make_tv(9, 30, vec![0xAB])];
834        sched.set_weekly_schedule(0, entries); // Monday = day_index 0, array_index 1
835
836        let val = sched
837            .read_property(PropertyIdentifier::WEEKLY_SCHEDULE, Some(1))
838            .unwrap();
839        if let PropertyValue::List(items) = val {
840            assert_eq!(items.len(), 1);
841            if let PropertyValue::List(pair) = &items[0] {
842                assert_eq!(pair[0], PropertyValue::Time(make_time(9, 30)));
843                assert_eq!(pair[1], PropertyValue::OctetString(vec![0xAB]));
844            } else {
845                panic!("expected pair list");
846            }
847        } else {
848            panic!("expected PropertyValue::List");
849        }
850    }
851
852    #[test]
853    fn schedule_weekly_schedule_index_7_returns_sunday() {
854        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
855        let entries = vec![make_tv(10, 0, vec![0xFF])];
856        sched.set_weekly_schedule(6, entries); // Sunday = day_index 6, array_index 7
857
858        let val = sched
859            .read_property(PropertyIdentifier::WEEKLY_SCHEDULE, Some(7))
860            .unwrap();
861        if let PropertyValue::List(items) = val {
862            assert_eq!(items.len(), 1);
863        } else {
864            panic!("expected PropertyValue::List");
865        }
866    }
867
868    #[test]
869    fn schedule_weekly_schedule_invalid_index_8_returns_error() {
870        let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
871        let result = sched.read_property(PropertyIdentifier::WEEKLY_SCHEDULE, Some(8));
872        assert!(result.is_err());
873        if let Err(Error::Protocol { code, .. }) = result {
874            assert_eq!(code, ErrorCode::INVALID_ARRAY_INDEX.to_raw() as u32);
875        } else {
876            panic!("expected Protocol error");
877        }
878    }
879
880    #[test]
881    fn schedule_weekly_schedule_out_of_bounds_day_index_ignored() {
882        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
883        // day_index 7 is out of bounds; should be silently ignored
884        sched.set_weekly_schedule(7, vec![make_tv(8, 0, vec![0x01])]);
885        // All days should still be empty
886        let val = sched
887            .read_property(PropertyIdentifier::WEEKLY_SCHEDULE, None)
888            .unwrap();
889        if let PropertyValue::List(days) = val {
890            for day in &days {
891                assert_eq!(*day, PropertyValue::List(vec![]));
892            }
893        } else {
894            panic!("expected PropertyValue::List");
895        }
896    }
897
898    // --- Schedule effective_period ---
899
900    #[test]
901    fn schedule_effective_period_default_null() {
902        let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
903        let val = sched
904            .read_property(PropertyIdentifier::EFFECTIVE_PERIOD, None)
905            .unwrap();
906        assert_eq!(val, PropertyValue::Null);
907    }
908
909    #[test]
910    fn schedule_effective_period_set_and_read() {
911        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
912        let period = BACnetDateRange {
913            start_date: Date {
914                year: 124,
915                month: 1,
916                day: 1,
917                day_of_week: 1,
918            },
919            end_date: Date {
920                year: 124,
921                month: 12,
922                day: 31,
923                day_of_week: 2,
924            },
925        };
926        sched.set_effective_period(period.clone());
927        let val = sched
928            .read_property(PropertyIdentifier::EFFECTIVE_PERIOD, None)
929            .unwrap();
930        assert_eq!(val, PropertyValue::OctetString(period.encode().to_vec()));
931    }
932
933    // --- Schedule exception_schedule ---
934
935    #[test]
936    fn schedule_exception_schedule_empty_by_default() {
937        let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
938        let val = sched
939            .read_property(PropertyIdentifier::EXCEPTION_SCHEDULE, None)
940            .unwrap();
941        assert_eq!(val, PropertyValue::List(vec![]));
942    }
943
944    #[test]
945    fn schedule_exception_schedule_count_via_index_zero() {
946        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
947        let event = BACnetSpecialEvent {
948            period: SpecialEventPeriod::CalendarEntry(BACnetCalendarEntry::WeekNDay(
949                BACnetWeekNDay {
950                    month: BACnetWeekNDay::ANY,
951                    week_of_month: BACnetWeekNDay::ANY,
952                    day_of_week: 7,
953                },
954            )),
955            list_of_time_values: vec![make_tv(0, 0, vec![0x00])],
956            event_priority: 16,
957        };
958        sched.add_exception(event);
959        let val = sched
960            .read_property(PropertyIdentifier::EXCEPTION_SCHEDULE, Some(0))
961            .unwrap();
962        assert_eq!(val, PropertyValue::Unsigned(1));
963    }
964
965    #[test]
966    fn schedule_exception_schedule_add_and_read() {
967        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
968        let event = BACnetSpecialEvent {
969            period: SpecialEventPeriod::CalendarEntry(BACnetCalendarEntry::WeekNDay(
970                BACnetWeekNDay {
971                    month: BACnetWeekNDay::ANY,
972                    week_of_month: BACnetWeekNDay::ANY,
973                    day_of_week: 7, // Sunday
974                },
975            )),
976            list_of_time_values: vec![make_tv(0, 0, vec![0x00])],
977            event_priority: 16,
978        };
979        sched.add_exception(event);
980        let val = sched
981            .read_property(PropertyIdentifier::EXCEPTION_SCHEDULE, None)
982            .unwrap();
983        // Should be a List with one event entry
984        if let PropertyValue::List(events) = &val {
985            assert_eq!(events.len(), 1);
986        } else {
987            panic!("expected List, got {val:?}");
988        }
989
990        // Add a second exception
991        let event2 = BACnetSpecialEvent {
992            period: SpecialEventPeriod::CalendarEntry(BACnetCalendarEntry::WeekNDay(
993                BACnetWeekNDay {
994                    month: BACnetWeekNDay::ANY,
995                    week_of_month: BACnetWeekNDay::ANY,
996                    day_of_week: 1, // Monday
997                },
998            )),
999            list_of_time_values: vec![],
1000            event_priority: 14,
1001        };
1002        sched.add_exception(event2);
1003        let val = sched
1004            .read_property(PropertyIdentifier::EXCEPTION_SCHEDULE, None)
1005            .unwrap();
1006        if let PropertyValue::List(events) = &val {
1007            assert_eq!(events.len(), 2);
1008        } else {
1009            panic!("expected List, got {val:?}");
1010        }
1011
1012        // array_index 0 returns count
1013        let count = sched
1014            .read_property(PropertyIdentifier::EXCEPTION_SCHEDULE, Some(0))
1015            .unwrap();
1016        assert_eq!(count, PropertyValue::Unsigned(2));
1017    }
1018
1019    // --- Schedule list_of_object_property_references ---
1020
1021    #[test]
1022    fn schedule_opr_list_empty_by_default() {
1023        let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1024        let val = sched
1025            .read_property(PropertyIdentifier::LIST_OF_OBJECT_PROPERTY_REFERENCES, None)
1026            .unwrap();
1027        assert_eq!(val, PropertyValue::List(vec![]));
1028    }
1029
1030    #[test]
1031    fn schedule_opr_list_add_and_read() {
1032        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1033        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
1034        let r = BACnetObjectPropertyReference::new(oid, PropertyIdentifier::PRESENT_VALUE.to_raw());
1035        sched.add_object_property_reference(r.clone());
1036
1037        let val = sched
1038            .read_property(PropertyIdentifier::LIST_OF_OBJECT_PROPERTY_REFERENCES, None)
1039            .unwrap();
1040        if let PropertyValue::List(items) = val {
1041            assert_eq!(items.len(), 1);
1042            if let PropertyValue::List(pair) = &items[0] {
1043                assert_eq!(pair[0], PropertyValue::ObjectIdentifier(oid));
1044                assert_eq!(
1045                    pair[1],
1046                    PropertyValue::Enumerated(PropertyIdentifier::PRESENT_VALUE.to_raw())
1047                );
1048            } else {
1049                panic!("expected pair list");
1050            }
1051        } else {
1052            panic!("expected PropertyValue::List");
1053        }
1054    }
1055
1056    #[test]
1057    fn schedule_opr_list_multiple_references() {
1058        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1059        let oid1 = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
1060        let oid2 = ObjectIdentifier::new(ObjectType::BINARY_OUTPUT, 5).unwrap();
1061        sched.add_object_property_reference(BACnetObjectPropertyReference::new(
1062            oid1,
1063            PropertyIdentifier::PRESENT_VALUE.to_raw(),
1064        ));
1065        sched.add_object_property_reference(BACnetObjectPropertyReference::new(
1066            oid2,
1067            PropertyIdentifier::PRESENT_VALUE.to_raw(),
1068        ));
1069
1070        let val = sched
1071            .read_property(PropertyIdentifier::LIST_OF_OBJECT_PROPERTY_REFERENCES, None)
1072            .unwrap();
1073        if let PropertyValue::List(items) = val {
1074            assert_eq!(items.len(), 2);
1075        } else {
1076            panic!("expected PropertyValue::List");
1077        }
1078    }
1079
1080    // --- Schedule property_list ---
1081
1082    #[test]
1083    fn schedule_property_list_contains_new_properties() {
1084        let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1085        let props = sched.property_list();
1086        assert!(props.contains(&PropertyIdentifier::WEEKLY_SCHEDULE));
1087        assert!(props.contains(&PropertyIdentifier::EXCEPTION_SCHEDULE));
1088        assert!(props.contains(&PropertyIdentifier::EFFECTIVE_PERIOD));
1089        assert!(props.contains(&PropertyIdentifier::LIST_OF_OBJECT_PROPERTY_REFERENCES));
1090    }
1091
1092    // --- Schedule evaluate() ---
1093
1094    #[test]
1095    fn evaluate_returns_default_when_no_entries() {
1096        let sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1097        let value = sched.evaluate(0, 12, 0); // Monday noon
1098        assert_eq!(value, PropertyValue::Real(72.0));
1099    }
1100
1101    #[test]
1102    fn evaluate_returns_weekly_value() {
1103        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1104        // Monday: 08:00 → occupied, 17:00 → unoccupied
1105        sched.set_weekly_schedule(
1106            0,
1107            vec![make_tv(8, 0, vec![0x01]), make_tv(17, 0, vec![0x00])],
1108        );
1109
1110        // Before first entry → default
1111        assert_eq!(sched.evaluate(0, 7, 59), PropertyValue::Real(72.0));
1112        // At 08:00 → occupied
1113        assert_eq!(
1114            sched.evaluate(0, 8, 0),
1115            PropertyValue::OctetString(vec![0x01])
1116        );
1117        // At 12:00 → still occupied (last entry before current time)
1118        assert_eq!(
1119            sched.evaluate(0, 12, 0),
1120            PropertyValue::OctetString(vec![0x01])
1121        );
1122        // At 17:00 → unoccupied
1123        assert_eq!(
1124            sched.evaluate(0, 17, 0),
1125            PropertyValue::OctetString(vec![0x00])
1126        );
1127        // At 23:59 → still unoccupied
1128        assert_eq!(
1129            sched.evaluate(0, 23, 59),
1130            PropertyValue::OctetString(vec![0x00])
1131        );
1132    }
1133
1134    #[test]
1135    fn evaluate_different_day_returns_default() {
1136        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1137        // Only Monday has entries
1138        sched.set_weekly_schedule(0, vec![make_tv(8, 0, vec![0x01])]);
1139
1140        // Tuesday should return default
1141        assert_eq!(sched.evaluate(1, 12, 0), PropertyValue::Real(72.0));
1142    }
1143
1144    #[test]
1145    fn evaluate_exception_overrides_weekly() {
1146        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1147        // Monday: 08:00 → 0x01
1148        sched.set_weekly_schedule(0, vec![make_tv(8, 0, vec![0x01])]);
1149
1150        // Exception: all day → 0xFF (higher priority)
1151        sched.add_exception(BACnetSpecialEvent {
1152            period: SpecialEventPeriod::CalendarEntry(BACnetCalendarEntry::WeekNDay(
1153                BACnetWeekNDay {
1154                    month: BACnetWeekNDay::ANY,
1155                    week_of_month: BACnetWeekNDay::ANY,
1156                    day_of_week: BACnetWeekNDay::ANY,
1157                },
1158            )),
1159            list_of_time_values: vec![make_tv(0, 0, vec![0xFF])],
1160            event_priority: 10,
1161        });
1162
1163        // Exception should win over weekly schedule
1164        assert_eq!(
1165            sched.evaluate(0, 12, 0),
1166            PropertyValue::OctetString(vec![0xFF])
1167        );
1168    }
1169
1170    #[test]
1171    fn evaluate_out_of_service_returns_present_value() {
1172        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1173        sched.set_weekly_schedule(0, vec![make_tv(8, 0, vec![0x01])]);
1174        sched.set_present_value(PropertyValue::Real(55.0));
1175        sched.out_of_service = true;
1176
1177        assert_eq!(sched.evaluate(0, 12, 0), PropertyValue::Real(55.0));
1178    }
1179
1180    #[test]
1181    fn evaluate_exception_priority_lowest_number_wins() {
1182        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1183        // Two exceptions, priority 15 (lower prio) and priority 5 (higher prio)
1184        sched.add_exception(BACnetSpecialEvent {
1185            period: SpecialEventPeriod::CalendarEntry(BACnetCalendarEntry::WeekNDay(
1186                BACnetWeekNDay {
1187                    month: BACnetWeekNDay::ANY,
1188                    week_of_month: BACnetWeekNDay::ANY,
1189                    day_of_week: BACnetWeekNDay::ANY,
1190                },
1191            )),
1192            list_of_time_values: vec![make_tv(0, 0, vec![0xAA])],
1193            event_priority: 15,
1194        });
1195        sched.add_exception(BACnetSpecialEvent {
1196            period: SpecialEventPeriod::CalendarEntry(BACnetCalendarEntry::WeekNDay(
1197                BACnetWeekNDay {
1198                    month: BACnetWeekNDay::ANY,
1199                    week_of_month: BACnetWeekNDay::ANY,
1200                    day_of_week: BACnetWeekNDay::ANY,
1201                },
1202            )),
1203            list_of_time_values: vec![make_tv(0, 0, vec![0xBB])],
1204            event_priority: 5,
1205        });
1206
1207        // Priority 5 (lower number = higher priority) should win
1208        assert_eq!(
1209            sched.evaluate(0, 12, 0),
1210            PropertyValue::OctetString(vec![0xBB])
1211        );
1212    }
1213
1214    // --- Schedule tick_schedule ---
1215
1216    #[test]
1217    fn tick_schedule_returns_none_when_no_refs() {
1218        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1219        sched.set_weekly_schedule(0, vec![make_tv(8, 0, vec![0x01])]);
1220        // No property references → None
1221        assert!(sched.tick_schedule(0, 12, 0).is_none());
1222    }
1223
1224    #[test]
1225    fn tick_schedule_returns_none_when_value_unchanged() {
1226        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1227        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
1228        sched.add_object_property_reference(BACnetObjectPropertyReference::new(
1229            oid,
1230            PropertyIdentifier::PRESENT_VALUE.to_raw(),
1231        ));
1232        // No weekly entries → evaluates to default (Real(72.0)) which matches present_value
1233        assert!(sched.tick_schedule(0, 12, 0).is_none());
1234    }
1235
1236    #[test]
1237    fn tick_schedule_returns_value_and_refs_on_change() {
1238        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1239        let target_oid = ObjectIdentifier::new(ObjectType::ANALOG_OUTPUT, 5).unwrap();
1240        sched.add_object_property_reference(BACnetObjectPropertyReference::new(
1241            target_oid,
1242            PropertyIdentifier::PRESENT_VALUE.to_raw(),
1243        ));
1244        sched.set_weekly_schedule(0, vec![make_tv(8, 0, vec![0x01])]);
1245
1246        let result = sched.tick_schedule(0, 12, 0);
1247        assert!(result.is_some());
1248        let (value, refs) = result.unwrap();
1249        assert_eq!(value, PropertyValue::OctetString(vec![0x01]));
1250        assert_eq!(refs.len(), 1);
1251        assert_eq!(refs[0].0, target_oid);
1252        assert_eq!(refs[0].1, PropertyIdentifier::PRESENT_VALUE.to_raw());
1253    }
1254
1255    #[test]
1256    fn tick_schedule_updates_present_value() {
1257        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1258        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
1259        sched.add_object_property_reference(BACnetObjectPropertyReference::new(
1260            oid,
1261            PropertyIdentifier::PRESENT_VALUE.to_raw(),
1262        ));
1263        sched.set_weekly_schedule(0, vec![make_tv(8, 0, vec![0x01])]);
1264
1265        let _ = sched.tick_schedule(0, 12, 0);
1266        assert_eq!(
1267            *sched.present_value(),
1268            PropertyValue::OctetString(vec![0x01])
1269        );
1270
1271        // Second call with same time → no change
1272        assert!(sched.tick_schedule(0, 12, 0).is_none());
1273    }
1274
1275    #[test]
1276    fn tick_schedule_returns_none_when_out_of_service() {
1277        let mut sched = ScheduleObject::new(1, "SCHED-1", PropertyValue::Real(72.0)).unwrap();
1278        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
1279        sched.add_object_property_reference(BACnetObjectPropertyReference::new(
1280            oid,
1281            PropertyIdentifier::PRESENT_VALUE.to_raw(),
1282        ));
1283        sched.set_weekly_schedule(0, vec![make_tv(8, 0, vec![0x01])]);
1284        sched.out_of_service = true;
1285
1286        assert!(sched.tick_schedule(0, 12, 0).is_none());
1287    }
1288}