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