Skip to main content

bacnet_services/
schedule.rs

1//! Schedule-property codecs per ASHRAE 135-2020 Clauses 12.17, 21.
2//!
3//! These cover the wire-format encode/decode for the constructed types that
4//! the Schedule object's `weekly-schedule` and `exception-schedule` carry:
5//!
6//! - [`BACnetTimeValue`]               — `(Time, application-tagged value)`
7//! - [`BACnetCalendarEntry`]           — `CHOICE { Date, DateRange, WeekNDay }`
8//! - [`SpecialEventPeriod`]            — `CHOICE { CalendarEntry, CalendarReference }`
9//! - [`BACnetSpecialEvent`]            — full exception-schedule entry
10//!
11//! And the two top-level array convenience helpers:
12//!
13//! - [`encode_weekly_schedule`] / [`decode_weekly_schedule`]
14//!   `BACnetARRAY[7] OF BACnetDailySchedule`
15//! - [`encode_exception_schedule`] / [`decode_exception_schedule`]
16//!   `SEQUENCE OF BACnetSpecialEvent`
17//!
18//! `BACnetTimeValue.value` stays as `Vec<u8>` — the raw application-tagged
19//! bytes for a single primitive datatype. Consumers that want a typed
20//! [`PropertyValue`] back can run [`decode_application_value`] on those
21//! bytes themselves.
22//!
23//! [`PropertyValue`]: bacnet_types::primitives::PropertyValue
24//! [`decode_application_value`]: bacnet_encoding::primitives::decode_application_value
25
26use bacnet_encoding::primitives;
27use bacnet_encoding::tags;
28use bacnet_types::constructed::{
29    BACnetCalendarEntry, BACnetDateRange, BACnetSpecialEvent, BACnetTimeValue, BACnetWeekNDay,
30    SpecialEventPeriod,
31};
32use bacnet_types::error::Error;
33use bacnet_types::primitives::{Date, ObjectIdentifier, Time};
34use bytes::BytesMut;
35
36use crate::common::MAX_DECODED_ITEMS;
37
38// ---------------------------------------------------------------------------
39// BACnetTimeValue
40// ---------------------------------------------------------------------------
41//
42// BACnetTimeValue ::= SEQUENCE {
43//     time   Time,                      -- application-tagged
44//     value  ABSTRACT-SYNTAX.&Type      -- application-tagged primitive
45// }
46//
47// Both fields are application-tagged, so the encoding is just concatenation
48// of two application-tagged values with no outer wrapper.
49
50/// Encode a single `BACnetTimeValue` (application-tagged Time + raw
51/// application-tagged value bytes).
52pub fn encode_time_value(buf: &mut BytesMut, tv: &BACnetTimeValue) {
53    primitives::encode_app_time(buf, &tv.time);
54    buf.extend_from_slice(&tv.value);
55}
56
57/// Decode a single `BACnetTimeValue` starting at `offset`. Returns the
58/// decoded value and the offset past the consumed bytes.
59pub fn decode_time_value(data: &[u8], offset: usize) -> Result<(BACnetTimeValue, usize), Error> {
60    // Application-tagged Time, length 4.
61    let (tag, pos) = tags::decode_tag(data, offset)?;
62    if tag.class != tags::TagClass::Application
63        || tag.number != tags::app_tag::TIME
64        || tag.length != 4
65    {
66        return Err(Error::decoding(
67            offset,
68            "TimeValue: expected application-tagged Time (4 bytes)",
69        ));
70    }
71    let end = pos
72        .checked_add(4)
73        .ok_or_else(|| Error::decoding(pos, "TimeValue: time length overflow"))?;
74    if end > data.len() {
75        return Err(Error::buffer_too_short(end, data.len()));
76    }
77    let time = Time::decode(&data[pos..end])?;
78
79    // One application-tagged value — slice out tag header + content as raw
80    // bytes so the BACnetTimeValue.value field stays opaque.
81    let value_start = end;
82    let (val_tag, val_pos) = tags::decode_tag(data, value_start)?;
83    if val_tag.class != tags::TagClass::Application {
84        return Err(Error::decoding(
85            value_start,
86            format!(
87                "TimeValue: expected application tag for value, got context tag {}",
88                val_tag.number
89            ),
90        ));
91    }
92    if val_tag.is_opening || val_tag.is_closing {
93        return Err(Error::decoding(
94            value_start,
95            "TimeValue: unexpected opening/closing tag in value",
96        ));
97    }
98    // Booleans encode their payload in the L/V/T field with no content
99    // octets — every other primitive uses the tag length. This matches the
100    // dispatch in decode_application_value.
101    let content_len = if val_tag.number == tags::app_tag::BOOLEAN {
102        0
103    } else {
104        val_tag.length as usize
105    };
106    let value_end = val_pos
107        .checked_add(content_len)
108        .ok_or_else(|| Error::decoding(val_pos, "TimeValue: value length overflow"))?;
109    if value_end > data.len() {
110        return Err(Error::buffer_too_short(value_end, data.len()));
111    }
112
113    Ok((
114        BACnetTimeValue {
115            time,
116            value: data[value_start..value_end].to_vec(),
117        },
118        value_end,
119    ))
120}
121
122// ---------------------------------------------------------------------------
123// BACnetCalendarEntry
124// ---------------------------------------------------------------------------
125//
126// BACnetCalendarEntry ::= CHOICE {
127//     date         [0] Date,
128//     date-range   [1] BACnetDateRange,
129//     weekNDay     [2] BACnetWeekNDay
130// }
131//
132// Date and WeekNDay are primitive — encoded as ctx-tagged content.
133// DateRange is a SEQUENCE — encoded with [1] opening / closing wrappers
134// around its two application-tagged Date fields.
135
136/// Encode a `BACnetCalendarEntry` (one of three CHOICE variants).
137pub fn encode_calendar_entry(buf: &mut BytesMut, e: &BACnetCalendarEntry) {
138    match e {
139        BACnetCalendarEntry::Date(d) => {
140            primitives::encode_ctx_date(buf, 0, d);
141        }
142        BACnetCalendarEntry::DateRange(dr) => {
143            tags::encode_opening_tag(buf, 1);
144            primitives::encode_app_date(buf, &dr.start_date);
145            primitives::encode_app_date(buf, &dr.end_date);
146            tags::encode_closing_tag(buf, 1);
147        }
148        BACnetCalendarEntry::WeekNDay(w) => {
149            primitives::encode_ctx_octet_string(buf, 2, &w.encode());
150        }
151    }
152}
153
154/// Decode a `BACnetCalendarEntry` starting at `offset`.
155pub fn decode_calendar_entry(
156    data: &[u8],
157    offset: usize,
158) -> Result<(BACnetCalendarEntry, usize), Error> {
159    let (tag, pos) = tags::decode_tag(data, offset)?;
160    if tag.class != tags::TagClass::Context {
161        return Err(Error::decoding(
162            offset,
163            "CalendarEntry: expected context tag",
164        ));
165    }
166
167    match tag.number {
168        0 => {
169            // [0] Date — primitive, length 4.
170            if tag.length != 4 {
171                return Err(Error::decoding(
172                    offset,
173                    format!("CalendarEntry::Date: expected length 4, got {}", tag.length),
174                ));
175            }
176            let end = pos + 4;
177            if end > data.len() {
178                return Err(Error::buffer_too_short(end, data.len()));
179            }
180            let date = Date::decode(&data[pos..end])?;
181            Ok((BACnetCalendarEntry::Date(date), end))
182        }
183        1 => {
184            // [1] DateRange — opening / two app-Dates / closing.
185            if !tag.is_opening {
186                return Err(Error::decoding(
187                    offset,
188                    "CalendarEntry::DateRange: expected [1] opening tag",
189                ));
190            }
191            let (start_date, p1) = read_app_date(data, pos)?;
192            let (end_date, p2) = read_app_date(data, p1)?;
193            let (close, p3) = tags::decode_tag(data, p2)?;
194            if !close.is_closing_tag(1) {
195                return Err(Error::decoding(
196                    p2,
197                    "CalendarEntry::DateRange: expected [1] closing tag",
198                ));
199            }
200            Ok((
201                BACnetCalendarEntry::DateRange(BACnetDateRange {
202                    start_date,
203                    end_date,
204                }),
205                p3,
206            ))
207        }
208        2 => {
209            // [2] WeekNDay — primitive octet string, length 3.
210            if tag.length != 3 {
211                return Err(Error::decoding(
212                    offset,
213                    format!(
214                        "CalendarEntry::WeekNDay: expected length 3, got {}",
215                        tag.length
216                    ),
217                ));
218            }
219            let end = pos + 3;
220            if end > data.len() {
221                return Err(Error::buffer_too_short(end, data.len()));
222            }
223            let w = BACnetWeekNDay::decode(&data[pos..end])?;
224            Ok((BACnetCalendarEntry::WeekNDay(w), end))
225        }
226        other => Err(Error::decoding(
227            offset,
228            format!("CalendarEntry: unknown CHOICE tag [{other}]"),
229        )),
230    }
231}
232
233// ---------------------------------------------------------------------------
234// SpecialEventPeriod
235// ---------------------------------------------------------------------------
236//
237// (the period field of BACnetSpecialEvent)
238// CHOICE {
239//     calendar-entry     [0] BACnetCalendarEntry,
240//     calendar-reference [1] BACnetObjectIdentifier
241// }
242//
243// CalendarEntry is itself a CHOICE that uses context tags — wrapped in [0]
244// opening / closing because its content is constructed.
245// CalendarReference is a primitive ObjectIdentifier — directly ctx-tagged.
246
247/// Encode a `SpecialEventPeriod`.
248pub fn encode_special_event_period(buf: &mut BytesMut, p: &SpecialEventPeriod) {
249    match p {
250        SpecialEventPeriod::CalendarEntry(e) => {
251            tags::encode_opening_tag(buf, 0);
252            encode_calendar_entry(buf, e);
253            tags::encode_closing_tag(buf, 0);
254        }
255        SpecialEventPeriod::CalendarReference(oid) => {
256            primitives::encode_ctx_object_id(buf, 1, oid);
257        }
258    }
259}
260
261/// Decode a `SpecialEventPeriod` starting at `offset`.
262pub fn decode_special_event_period(
263    data: &[u8],
264    offset: usize,
265) -> Result<(SpecialEventPeriod, usize), Error> {
266    let (tag, pos) = tags::decode_tag(data, offset)?;
267    if tag.class != tags::TagClass::Context {
268        return Err(Error::decoding(offset, "Period: expected context tag"));
269    }
270
271    match tag.number {
272        0 => {
273            if !tag.is_opening {
274                return Err(Error::decoding(
275                    offset,
276                    "Period::CalendarEntry: expected [0] opening tag",
277                ));
278            }
279            let (entry, p1) = decode_calendar_entry(data, pos)?;
280            let (close, p2) = tags::decode_tag(data, p1)?;
281            if !close.is_closing_tag(0) {
282                return Err(Error::decoding(
283                    p1,
284                    "Period::CalendarEntry: expected [0] closing tag",
285                ));
286            }
287            Ok((SpecialEventPeriod::CalendarEntry(entry), p2))
288        }
289        1 => {
290            if tag.length != 4 {
291                return Err(Error::decoding(
292                    offset,
293                    format!(
294                        "Period::CalendarReference: expected length 4, got {}",
295                        tag.length
296                    ),
297                ));
298            }
299            let end = pos + 4;
300            if end > data.len() {
301                return Err(Error::buffer_too_short(end, data.len()));
302            }
303            let oid = ObjectIdentifier::decode(&data[pos..end])?;
304            Ok((SpecialEventPeriod::CalendarReference(oid), end))
305        }
306        other => Err(Error::decoding(
307            offset,
308            format!("Period: unknown CHOICE tag [{other}]"),
309        )),
310    }
311}
312
313// ---------------------------------------------------------------------------
314// BACnetSpecialEvent
315// ---------------------------------------------------------------------------
316//
317// BACnetSpecialEvent ::= SEQUENCE {
318//     period CHOICE { calendar-entry [0] ..., calendar-reference [1] ... },
319//     list-of-time-values [2] SEQUENCE OF BACnetTimeValue,
320//     event-priority      [3] Unsigned (1..16)
321// }
322
323/// Encode a `BACnetSpecialEvent` (period + list-of-time-values + priority).
324pub fn encode_special_event(buf: &mut BytesMut, e: &BACnetSpecialEvent) {
325    encode_special_event_period(buf, &e.period);
326
327    // [2] list-of-time-values
328    tags::encode_opening_tag(buf, 2);
329    for tv in &e.list_of_time_values {
330        encode_time_value(buf, tv);
331    }
332    tags::encode_closing_tag(buf, 2);
333
334    // [3] event-priority
335    primitives::encode_ctx_unsigned(buf, 3, e.event_priority as u64);
336}
337
338/// Decode a `BACnetSpecialEvent` starting at `offset`.
339pub fn decode_special_event(
340    data: &[u8],
341    offset: usize,
342) -> Result<(BACnetSpecialEvent, usize), Error> {
343    let (period, mut pos) = decode_special_event_period(data, offset)?;
344
345    // [2] list-of-time-values opening
346    let (open, p1) = tags::decode_tag(data, pos)?;
347    if !open.is_opening_tag(2) {
348        return Err(Error::decoding(
349            pos,
350            "SpecialEvent: expected [2] opening tag for list-of-time-values",
351        ));
352    }
353    pos = p1;
354
355    let mut list_of_time_values = Vec::new();
356    loop {
357        let (peek, _) = tags::decode_tag(data, pos)?;
358        if peek.is_closing_tag(2) {
359            break;
360        }
361        if list_of_time_values.len() >= MAX_DECODED_ITEMS {
362            return Err(Error::decoding(
363                pos,
364                "SpecialEvent: list-of-time-values exceeds MAX_DECODED_ITEMS",
365            ));
366        }
367        let (tv, next) = decode_time_value(data, pos)?;
368        list_of_time_values.push(tv);
369        pos = next;
370    }
371    // consume the closing tag
372    let (_close, p2) = tags::decode_tag(data, pos)?;
373    pos = p2;
374
375    // [3] event-priority
376    let (prio_tag, p3) = tags::decode_tag(data, pos)?;
377    if !prio_tag.is_context(3) {
378        return Err(Error::decoding(
379            pos,
380            "SpecialEvent: expected [3] event-priority",
381        ));
382    }
383    let prio_end = p3 + prio_tag.length as usize;
384    if prio_end > data.len() {
385        return Err(Error::buffer_too_short(prio_end, data.len()));
386    }
387    let prio = primitives::decode_unsigned(&data[p3..prio_end])?;
388    let event_priority = validate_priority(prio, pos)?;
389
390    Ok((
391        BACnetSpecialEvent {
392            period,
393            list_of_time_values,
394            event_priority,
395        },
396        prio_end,
397    ))
398}
399
400/// Validate the event-priority field per Clause 21 (Unsigned 1..16).
401///
402/// We accept the raw value at decode time but reject anything outside
403/// 1..=16 because that's spec-required and a wider value almost always
404/// means a malformed payload (or a different field landed in this slot).
405/// Erroring here gives the caller a precise location instead of letting a
406/// 0 or 99 silently flow into Schedule's priority resolution.
407fn validate_priority(raw: u64, offset: usize) -> Result<u8, Error> {
408    if !(1..=16).contains(&raw) {
409        return Err(Error::decoding(
410            offset,
411            format!("SpecialEvent: event-priority {raw} outside 1..=16"),
412        ));
413    }
414    Ok(raw as u8)
415}
416
417// ---------------------------------------------------------------------------
418// weekly-schedule (BACnetARRAY[7] OF BACnetDailySchedule)
419// ---------------------------------------------------------------------------
420//
421// When read as the whole property (no array index), the ARRAY[7] is encoded
422// as 7 BACnetDailySchedule values back-to-back. Each BACnetDailySchedule is
423// a SEQUENCE with one [0]-tagged field:
424//
425//     BACnetDailySchedule ::= SEQUENCE { day-schedule [0] SEQUENCE OF BACnetTimeValue }
426//
427// On the wire that's `[0]opening` ... `[0]closing` per day.
428
429/// Encode a 7-day weekly schedule (`BACnetARRAY[7] OF BACnetDailySchedule`).
430///
431/// Mon..Sun in array order, matching the BACnet day-of-week numbering
432/// (1=Mon..7=Sun) — `days[0]` is Monday's TimeValue list.
433pub fn encode_weekly_schedule(buf: &mut BytesMut, days: &[Vec<BACnetTimeValue>; 7]) {
434    for day in days {
435        tags::encode_opening_tag(buf, 0);
436        for tv in day {
437            encode_time_value(buf, tv);
438        }
439        tags::encode_closing_tag(buf, 0);
440    }
441}
442
443/// Decode a 7-day weekly schedule.
444///
445/// Errors if the payload doesn't contain exactly 7 daily schedules — the
446/// spec requires a fixed-size ARRAY[7] and a count mismatch is the
447/// signature of a malformed or truncated response.
448pub fn decode_weekly_schedule(data: &[u8]) -> Result<[Vec<BACnetTimeValue>; 7], Error> {
449    let mut days: [Vec<BACnetTimeValue>; 7] = Default::default();
450    let mut pos = 0usize;
451    for (i, day) in days.iter_mut().enumerate() {
452        let (open, p1) = tags::decode_tag(data, pos)
453            .map_err(|e| Error::decoding(pos, format!("WeeklySchedule day {i}: {e}")))?;
454        if !open.is_opening_tag(0) {
455            return Err(Error::decoding(
456                pos,
457                format!("WeeklySchedule day {i}: expected [0] opening tag"),
458            ));
459        }
460        pos = p1;
461
462        loop {
463            let (peek, _) = tags::decode_tag(data, pos)
464                .map_err(|e| Error::decoding(pos, format!("WeeklySchedule day {i}: {e}")))?;
465            if peek.is_closing_tag(0) {
466                break;
467            }
468            if day.len() >= MAX_DECODED_ITEMS {
469                return Err(Error::decoding(
470                    pos,
471                    format!("WeeklySchedule day {i}: exceeds MAX_DECODED_ITEMS"),
472                ));
473            }
474            let (tv, next) = decode_time_value(data, pos)
475                .map_err(|e| Error::decoding(pos, format!("WeeklySchedule day {i}: {e}")))?;
476            day.push(tv);
477            pos = next;
478        }
479        let (_close, p2) = tags::decode_tag(data, pos)
480            .map_err(|e| Error::decoding(pos, format!("WeeklySchedule day {i}: {e}")))?;
481        pos = p2;
482    }
483
484    if pos != data.len() {
485        return Err(Error::decoding(
486            pos,
487            format!(
488                "WeeklySchedule: {} trailing byte(s) after 7 daily schedules",
489                data.len() - pos
490            ),
491        ));
492    }
493    Ok(days)
494}
495
496// ---------------------------------------------------------------------------
497// exception-schedule (SEQUENCE OF BACnetSpecialEvent)
498// ---------------------------------------------------------------------------
499
500/// Encode the exception-schedule property value (concatenated SpecialEvents).
501pub fn encode_exception_schedule(buf: &mut BytesMut, events: &[BACnetSpecialEvent]) {
502    for e in events {
503        encode_special_event(buf, e);
504    }
505}
506
507/// Decode the exception-schedule property value (zero or more SpecialEvents
508/// back-to-back).
509pub fn decode_exception_schedule(data: &[u8]) -> Result<Vec<BACnetSpecialEvent>, Error> {
510    let mut events = Vec::new();
511    let mut pos = 0usize;
512    while pos < data.len() {
513        if events.len() >= MAX_DECODED_ITEMS {
514            return Err(Error::decoding(
515                pos,
516                "ExceptionSchedule: exceeds MAX_DECODED_ITEMS",
517            ));
518        }
519        let (event, next) = decode_special_event(data, pos).map_err(|e| {
520            Error::decoding(
521                pos,
522                format!("ExceptionSchedule entry {}: {e}", events.len()),
523            )
524        })?;
525        events.push(event);
526        pos = next;
527    }
528    Ok(events)
529}
530
531// ---------------------------------------------------------------------------
532// Internal helpers
533// ---------------------------------------------------------------------------
534
535fn read_app_date(data: &[u8], offset: usize) -> Result<(Date, usize), Error> {
536    let (tag, pos) = tags::decode_tag(data, offset)?;
537    if tag.class != tags::TagClass::Application
538        || tag.number != tags::app_tag::DATE
539        || tag.length != 4
540    {
541        return Err(Error::decoding(
542            offset,
543            "expected application-tagged Date (4 bytes)",
544        ));
545    }
546    let end = pos + 4;
547    if end > data.len() {
548        return Err(Error::buffer_too_short(end, data.len()));
549    }
550    Ok((Date::decode(&data[pos..end])?, end))
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use bacnet_types::enums::ObjectType;
557
558    fn d(year_offset: u8, month: u8, day: u8, dow: u8) -> Date {
559        Date {
560            year: year_offset,
561            month,
562            day,
563            day_of_week: dow,
564        }
565    }
566
567    fn t(h: u8, m: u8, s: u8) -> Time {
568        Time {
569            hour: h,
570            minute: m,
571            second: s,
572            hundredths: 0,
573        }
574    }
575
576    /// Helper: build a BACnetTimeValue with an application-Real value.
577    fn tv_real(hour: u8, minute: u8, value: f32) -> BACnetTimeValue {
578        let mut buf = BytesMut::new();
579        primitives::encode_app_real(&mut buf, value);
580        BACnetTimeValue {
581            time: t(hour, minute, 0),
582            value: buf.to_vec(),
583        }
584    }
585
586    /// Helper: build a BACnetTimeValue with an application-Null value.
587    fn tv_null(hour: u8, minute: u8) -> BACnetTimeValue {
588        let mut buf = BytesMut::new();
589        primitives::encode_app_null(&mut buf);
590        BACnetTimeValue {
591            time: t(hour, minute, 0),
592            value: buf.to_vec(),
593        }
594    }
595
596    // --- BACnetTimeValue ---------------------------------------------------
597
598    #[test]
599    fn time_value_round_trip_real() {
600        let tv = tv_real(8, 30, 72.5);
601        let mut buf = BytesMut::new();
602        encode_time_value(&mut buf, &tv);
603        let (decoded, end) = decode_time_value(&buf, 0).unwrap();
604        assert_eq!(decoded, tv);
605        assert_eq!(end, buf.len());
606    }
607
608    #[test]
609    fn time_value_round_trip_null() {
610        let tv = tv_null(17, 0);
611        let mut buf = BytesMut::new();
612        encode_time_value(&mut buf, &tv);
613        let (decoded, end) = decode_time_value(&buf, 0).unwrap();
614        assert_eq!(decoded, tv);
615        assert_eq!(end, buf.len());
616    }
617
618    #[test]
619    fn time_value_rejects_context_tagged_value() {
620        let mut buf = BytesMut::new();
621        primitives::encode_app_time(&mut buf, &t(8, 0, 0));
622        primitives::encode_ctx_unsigned(&mut buf, 0, 1);
623        let err = decode_time_value(&buf, 0).unwrap_err();
624        assert!(format!("{err}").contains("expected application tag"));
625    }
626
627    // --- BACnetCalendarEntry ----------------------------------------------
628
629    #[test]
630    fn calendar_entry_date_round_trip() {
631        let e = BACnetCalendarEntry::Date(d(124, 7, 4, 4));
632        let mut buf = BytesMut::new();
633        encode_calendar_entry(&mut buf, &e);
634        let (decoded, end) = decode_calendar_entry(&buf, 0).unwrap();
635        assert_eq!(decoded, e);
636        assert_eq!(end, buf.len());
637    }
638
639    #[test]
640    fn calendar_entry_date_range_round_trip() {
641        let e = BACnetCalendarEntry::DateRange(BACnetDateRange {
642            start_date: d(124, 1, 1, 1),
643            end_date: d(124, 12, 31, 2),
644        });
645        let mut buf = BytesMut::new();
646        encode_calendar_entry(&mut buf, &e);
647        let (decoded, end) = decode_calendar_entry(&buf, 0).unwrap();
648        assert_eq!(decoded, e);
649        assert_eq!(end, buf.len());
650    }
651
652    #[test]
653    fn calendar_entry_week_n_day_round_trip() {
654        let e = BACnetCalendarEntry::WeekNDay(BACnetWeekNDay {
655            month: 0xFF,
656            week_of_month: 1,
657            day_of_week: 1,
658        });
659        let mut buf = BytesMut::new();
660        encode_calendar_entry(&mut buf, &e);
661        let (decoded, end) = decode_calendar_entry(&buf, 0).unwrap();
662        assert_eq!(decoded, e);
663        assert_eq!(end, buf.len());
664    }
665
666    // --- SpecialEventPeriod -----------------------------------------------
667
668    #[test]
669    fn period_calendar_entry_round_trip() {
670        let p = SpecialEventPeriod::CalendarEntry(BACnetCalendarEntry::Date(d(124, 12, 25, 3)));
671        let mut buf = BytesMut::new();
672        encode_special_event_period(&mut buf, &p);
673        let (decoded, end) = decode_special_event_period(&buf, 0).unwrap();
674        assert_eq!(decoded, p);
675        assert_eq!(end, buf.len());
676    }
677
678    #[test]
679    fn period_calendar_reference_round_trip() {
680        let oid = ObjectIdentifier::new(ObjectType::CALENDAR, 1).unwrap();
681        let p = SpecialEventPeriod::CalendarReference(oid);
682        let mut buf = BytesMut::new();
683        encode_special_event_period(&mut buf, &p);
684        let (decoded, end) = decode_special_event_period(&buf, 0).unwrap();
685        assert_eq!(decoded, p);
686        assert_eq!(end, buf.len());
687    }
688
689    // --- BACnetSpecialEvent ------------------------------------------------
690
691    #[test]
692    fn special_event_round_trip_with_calendar_entry() {
693        let e = BACnetSpecialEvent {
694            period: SpecialEventPeriod::CalendarEntry(BACnetCalendarEntry::WeekNDay(
695                BACnetWeekNDay {
696                    month: 11,
697                    week_of_month: 4,
698                    day_of_week: 4,
699                },
700            )),
701            list_of_time_values: vec![tv_real(0, 0, 1.0), tv_null(23, 59)],
702            event_priority: 5,
703        };
704        let mut buf = BytesMut::new();
705        encode_special_event(&mut buf, &e);
706        let (decoded, end) = decode_special_event(&buf, 0).unwrap();
707        assert_eq!(decoded, e);
708        assert_eq!(end, buf.len());
709    }
710
711    #[test]
712    fn special_event_round_trip_with_calendar_reference() {
713        let e = BACnetSpecialEvent {
714            period: SpecialEventPeriod::CalendarReference(
715                ObjectIdentifier::new(ObjectType::CALENDAR, 7).unwrap(),
716            ),
717            list_of_time_values: vec![tv_real(8, 0, 70.0), tv_real(18, 0, 60.0)],
718            event_priority: 16,
719        };
720        let mut buf = BytesMut::new();
721        encode_special_event(&mut buf, &e);
722        let (decoded, end) = decode_special_event(&buf, 0).unwrap();
723        assert_eq!(decoded, e);
724        assert_eq!(end, buf.len());
725    }
726
727    #[test]
728    fn special_event_priority_zero_is_rejected() {
729        // Build a payload with a deliberately invalid priority of 0.
730        let mut buf = BytesMut::new();
731        encode_special_event_period(
732            &mut buf,
733            &SpecialEventPeriod::CalendarReference(
734                ObjectIdentifier::new(ObjectType::CALENDAR, 1).unwrap(),
735            ),
736        );
737        tags::encode_opening_tag(&mut buf, 2);
738        tags::encode_closing_tag(&mut buf, 2);
739        primitives::encode_ctx_unsigned(&mut buf, 3, 0);
740
741        let err = decode_special_event(&buf, 0).unwrap_err();
742        assert!(format!("{err}").contains("event-priority 0"));
743    }
744
745    #[test]
746    fn special_event_priority_seventeen_is_rejected() {
747        let mut buf = BytesMut::new();
748        encode_special_event_period(
749            &mut buf,
750            &SpecialEventPeriod::CalendarReference(
751                ObjectIdentifier::new(ObjectType::CALENDAR, 1).unwrap(),
752            ),
753        );
754        tags::encode_opening_tag(&mut buf, 2);
755        tags::encode_closing_tag(&mut buf, 2);
756        primitives::encode_ctx_unsigned(&mut buf, 3, 17);
757
758        let err = decode_special_event(&buf, 0).unwrap_err();
759        assert!(format!("{err}").contains("event-priority 17"));
760    }
761
762    // --- weekly-schedule ---------------------------------------------------
763
764    #[test]
765    fn weekly_schedule_empty_round_trip() {
766        let days: [Vec<BACnetTimeValue>; 7] = Default::default();
767        let mut buf = BytesMut::new();
768        encode_weekly_schedule(&mut buf, &days);
769        let decoded = decode_weekly_schedule(&buf).unwrap();
770        assert_eq!(decoded, days);
771        // Empty schedule = 7 pairs of opening/closing tags = 14 bytes.
772        assert_eq!(buf.len(), 14);
773    }
774
775    #[test]
776    fn weekly_schedule_partially_populated_round_trip() {
777        // Monday: heat to 70 at 6am, setback to 65 at 10pm. Wed: only 8am setpoint.
778        let mut days: [Vec<BACnetTimeValue>; 7] = Default::default();
779        days[0] = vec![tv_real(6, 0, 70.0), tv_real(22, 0, 65.0)];
780        days[2] = vec![tv_real(8, 0, 72.0)];
781        let mut buf = BytesMut::new();
782        encode_weekly_schedule(&mut buf, &days);
783        let decoded = decode_weekly_schedule(&buf).unwrap();
784        assert_eq!(decoded, days);
785    }
786
787    #[test]
788    fn weekly_schedule_rejects_six_days() {
789        // Truncated: only 6 daily schedules instead of 7.
790        let mut buf = BytesMut::new();
791        for _ in 0..6 {
792            tags::encode_opening_tag(&mut buf, 0);
793            tags::encode_closing_tag(&mut buf, 0);
794        }
795        let err = decode_weekly_schedule(&buf).unwrap_err();
796        assert!(format!("{err}").contains("day 6"));
797    }
798
799    #[test]
800    fn weekly_schedule_rejects_trailing_bytes() {
801        let days: [Vec<BACnetTimeValue>; 7] = Default::default();
802        let mut buf = BytesMut::new();
803        encode_weekly_schedule(&mut buf, &days);
804        buf.extend_from_slice(&[0xAA, 0xBB]); // garbage past the schedule
805        let err = decode_weekly_schedule(&buf).unwrap_err();
806        assert!(format!("{err}").contains("trailing byte"));
807    }
808
809    // --- exception-schedule ------------------------------------------------
810
811    #[test]
812    fn exception_schedule_empty_round_trip() {
813        let events: Vec<BACnetSpecialEvent> = Vec::new();
814        let mut buf = BytesMut::new();
815        encode_exception_schedule(&mut buf, &events);
816        assert!(buf.is_empty());
817        let decoded = decode_exception_schedule(&buf).unwrap();
818        assert!(decoded.is_empty());
819    }
820
821    #[test]
822    fn exception_schedule_two_events_round_trip() {
823        let events = vec![
824            BACnetSpecialEvent {
825                period: SpecialEventPeriod::CalendarEntry(BACnetCalendarEntry::Date(d(
826                    124, 12, 25, 3,
827                ))),
828                list_of_time_values: vec![tv_real(0, 0, 1.0)],
829                event_priority: 1,
830            },
831            BACnetSpecialEvent {
832                period: SpecialEventPeriod::CalendarReference(
833                    ObjectIdentifier::new(ObjectType::CALENDAR, 1).unwrap(),
834                ),
835                list_of_time_values: vec![tv_null(0, 0), tv_real(12, 0, 70.0)],
836                event_priority: 8,
837            },
838        ];
839        let mut buf = BytesMut::new();
840        encode_exception_schedule(&mut buf, &events);
841        let decoded = decode_exception_schedule(&buf).unwrap();
842        assert_eq!(decoded, events);
843    }
844}