Skip to main content

bacnet_encoding/constructed/
mod.rs

1//! Full ASN.1 framing codecs for constructed Clause-21 types whose property
2//! values are CHOICE/SEQUENCE productions — context-tagged framing per
3//! ASHRAE 135-2020 Clause 20.2.1.5/20.2.1.6.
4//!
5//! These codecs encode to and decode from raw application-layer bytes, the
6//! same bytes carried by [`bacnet_types::primitives::PropertyValue::ApplicationData`].
7//! Objects serve framed properties (e.g. `Event_Parameters`,
8//! `Fault_Parameters`, `Recipient_List`) by encoding with these functions;
9//! the flat application-tagged model in [`crate::primitives::encode_property_value`]
10//! cannot express their wire form.
11//!
12//! Tag-form rules applied here:
13//!
14//! - A CHOICE alternative over a `SEQUENCE` is an opening/closing context tag
15//!   pair around the SEQUENCE's members.
16//! - A CHOICE alternative over a primitive base type is a context-specific
17//!   primitive tag holding the raw contents (e.g. `none [0] NULL`).
18//! - A context-tagged inner `CHOICE` is always explicitly tagged: an
19//!   opening/closing pair around the alternative's own encoding.
20//! - Inner members declared `[n] T` over primitive `T` are context-tagged
21//!   primitives; inner `SEQUENCE OF` / embedded `SEQUENCE` members are
22//!   constructed opening/closing pairs holding application-tagged elements.
23
24use bacnet_types::constructed::{
25    BACnetDeviceObjectPropertyReference, BACnetExtendedPropertyState, BACnetPropertyStates,
26    BACnetProprietaryPropertyState,
27};
28use bacnet_types::error::Error;
29use bacnet_types::primitives::ObjectIdentifier;
30use bytes::BytesMut;
31
32use crate::primitives;
33use crate::tags::{self, TagClass};
34
35mod audit_notification;
36mod audit_record;
37pub mod cov_subscription;
38pub mod event_parameter;
39pub mod fault_parameter;
40pub mod object_property_reference;
41pub mod recipient;
42pub mod staging;
43
44pub use audit_notification::{decode_audit_notification_at, encode_audit_notification};
45pub use audit_record::{
46    decode_audit_log_record, decode_audit_log_record_result_at, encode_audit_log_record,
47    encode_audit_log_record_result,
48};
49pub use cov_subscription::{encode_cov_subscription, encode_cov_subscription_list};
50pub use event_parameter::{decode_event_parameter, encode_event_parameter};
51pub use fault_parameter::{decode_fault_parameters, encode_fault_parameters};
52pub use object_property_reference::{
53    decode_object_property_reference, decode_setpoint_reference, encode_object_property_reference,
54    encode_setpoint_reference,
55};
56pub use recipient::{
57    decode_destination, decode_destination_list, decode_recipient, encode_destination,
58    encode_destination_list, encode_recipient,
59};
60pub use staging::{
61    decode_device_object_reference, decode_stage_limit_value, encode_device_object_reference,
62    encode_stage_limit_value,
63};
64
65/// Upper bound on decoded SEQUENCE OF / list lengths, mirroring the socket-
66/// facing posture of `bacnet-services`' `MAX_DECODED_ITEMS`. Prevents memory
67/// exhaustion from malformed framed payloads.
68const MAX_FRAMED_ITEMS: usize = 10_000;
69
70// ---------------------------------------------------------------------------
71// Small tagged-field helpers
72// ---------------------------------------------------------------------------
73
74/// Require an opening context tag `tag` at `offset`; return the offset of its
75/// content.
76fn expect_opening(data: &[u8], offset: usize, tag: u8, what: &str) -> Result<usize, Error> {
77    let (t, pos) = tags::decode_tag(data, offset)?;
78    if !t.is_opening_tag(tag) {
79        return Err(Error::decoding(
80            offset,
81            format!("{what}: expected opening tag [{tag}]"),
82        ));
83    }
84    Ok(pos)
85}
86
87/// Require a closing context tag `tag` at `offset`; return the offset past it.
88fn expect_closing(data: &[u8], offset: usize, tag: u8, what: &str) -> Result<usize, Error> {
89    let (t, pos) = tags::decode_tag(data, offset)?;
90    if !t.is_closing_tag(tag) {
91        return Err(Error::decoding(
92            offset,
93            format!("{what}: expected closing tag [{tag}]"),
94        ));
95    }
96    Ok(pos)
97}
98
99/// Decode a primitive context tag `tag` holding unsigned contents.
100fn decode_ctx_unsigned(
101    data: &[u8],
102    offset: usize,
103    tag: u8,
104    what: &str,
105) -> Result<(u64, usize), Error> {
106    let (t, pos) = tags::decode_tag(data, offset)?;
107    if !t.is_context(tag) {
108        return Err(Error::decoding(
109            offset,
110            format!("{what}: expected context tag [{tag}]"),
111        ));
112    }
113    let end = pos
114        .checked_add(t.length as usize)
115        .ok_or_else(|| Error::decoding(pos, format!("{what}: length overflow")))?;
116    if end > data.len() {
117        return Err(Error::buffer_too_short(end, data.len()));
118    }
119    Ok((primitives::decode_unsigned(&data[pos..end])?, end))
120}
121
122/// Decode a primitive context tag `tag` holding a 4-octet REAL.
123fn decode_ctx_real(data: &[u8], offset: usize, tag: u8, what: &str) -> Result<(f32, usize), Error> {
124    let (t, pos) = tags::decode_tag(data, offset)?;
125    if !t.is_context(tag) || t.length != 4 {
126        return Err(Error::decoding(
127            offset,
128            format!("{what}: expected context tag [{tag}] REAL (4 octets)"),
129        ));
130    }
131    let end = pos + 4;
132    if end > data.len() {
133        return Err(Error::buffer_too_short(end, data.len()));
134    }
135    Ok((primitives::decode_real(&data[pos..end])?, end))
136}
137
138/// Decode a primitive context tag `tag` holding a BIT STRING
139/// `(first octet = unused-bits count)`.
140fn decode_ctx_bit_string(
141    data: &[u8],
142    offset: usize,
143    tag: u8,
144    what: &str,
145) -> Result<((u8, Vec<u8>), usize), Error> {
146    let (t, pos) = tags::decode_tag(data, offset)?;
147    if !t.is_context(tag) {
148        return Err(Error::decoding(
149            offset,
150            format!("{what}: expected context tag [{tag}] BIT STRING"),
151        ));
152    }
153    let end = pos
154        .checked_add(t.length as usize)
155        .ok_or_else(|| Error::decoding(pos, format!("{what}: length overflow")))?;
156    if end > data.len() {
157        return Err(Error::buffer_too_short(end, data.len()));
158    }
159    let (unused_bits, bits) = primitives::decode_bit_string(&data[pos..end])?;
160    Ok(((unused_bits, bits), end))
161}
162
163/// Decode one application-tagged BIT STRING (`SEQUENCE OF BIT STRING` item).
164fn decode_app_bit_string(
165    data: &[u8],
166    offset: usize,
167    what: &str,
168) -> Result<((u8, Vec<u8>), usize), Error> {
169    let (t, pos) = tags::decode_tag(data, offset)?;
170    if t.class != TagClass::Application || t.number != tags::app_tag::BIT_STRING {
171        return Err(Error::decoding(
172            offset,
173            format!("{what}: expected application-tagged BIT STRING"),
174        ));
175    }
176    let end = pos
177        .checked_add(t.length as usize)
178        .ok_or_else(|| Error::decoding(pos, format!("{what}: length overflow")))?;
179    if end > data.len() {
180        return Err(Error::buffer_too_short(end, data.len()));
181    }
182    let (unused_bits, bits) = primitives::decode_bit_string(&data[pos..end])?;
183    Ok(((unused_bits, bits), end))
184}
185
186/// Decode one application-tagged ENUMERATED (`SEQUENCE OF enumerated` item).
187fn decode_app_enumerated(data: &[u8], offset: usize, what: &str) -> Result<(u32, usize), Error> {
188    let (t, pos) = tags::decode_tag(data, offset)?;
189    if t.class != TagClass::Application || t.number != tags::app_tag::ENUMERATED {
190        return Err(Error::decoding(
191            offset,
192            format!("{what}: expected application-tagged ENUMERATED"),
193        ));
194    }
195    let end = pos
196        .checked_add(t.length as usize)
197        .ok_or_else(|| Error::decoding(pos, format!("{what}: length overflow")))?;
198    if end > data.len() {
199        return Err(Error::buffer_too_short(end, data.len()));
200    }
201    let value = u32::try_from(primitives::decode_unsigned(&data[pos..end])?)
202        .map_err(|_| Error::decoding(pos, format!("{what}: ENUMERATED exceeds u32")))?;
203    Ok((value, end))
204}
205
206/// Decode one application-tagged CharacterString.
207fn decode_app_character_string(
208    data: &[u8],
209    offset: usize,
210    what: &str,
211) -> Result<(String, usize), Error> {
212    let (t, pos) = tags::decode_tag(data, offset)?;
213    if t.class != TagClass::Application || t.number != tags::app_tag::CHARACTER_STRING {
214        return Err(Error::decoding(
215            offset,
216            format!("{what}: expected application-tagged CharacterString"),
217        ));
218    }
219    let end = pos
220        .checked_add(t.length as usize)
221        .ok_or_else(|| Error::decoding(pos, format!("{what}: length overflow")))?;
222    if end > data.len() {
223        return Err(Error::buffer_too_short(end, data.len()));
224    }
225    Ok((primitives::decode_character_string(&data[pos..end])?, end))
226}
227
228// ---------------------------------------------------------------------------
229// BACnetPropertyStates (Clause 21 CHOICE) — spec-tagged framing
230// ---------------------------------------------------------------------------
231
232/// Encode a [`BACnetPropertyStates`] using the Standard 135-2020 Clause 21 tags.
233///
234/// Returns an error without modifying `buf` when a constructed proprietary
235/// value does not contain a BACnet TLV sequence.
236pub fn encode_property_state(
237    buf: &mut BytesMut,
238    state: &BACnetPropertyStates,
239) -> Result<(), Error> {
240    use BACnetPropertyStates as S;
241    if let S::Other(value) = state {
242        if value.is_constructed() {
243            validate_tlv_sequence(value.data(), "proprietary property-state body")?;
244            let mut framed = BytesMut::new();
245            tags::encode_opening_tag(&mut framed, value.tag());
246            let (_, body_start) = tags::decode_tag(&framed, 0)?;
247            framed.extend_from_slice(value.data());
248            tags::encode_closing_tag(&mut framed, value.tag());
249            let (_, end) = tags::extract_context_value(&framed, body_start, value.tag())?;
250            if end != framed.len() {
251                return Err(Error::decoding(
252                    end,
253                    "proprietary property-state body has trailing data",
254                ));
255            }
256        }
257    }
258    match state {
259        S::BooleanValue(v) => primitives::encode_ctx_boolean(buf, 0, *v),
260        S::BinaryValue(v) => primitives::encode_ctx_enumerated(buf, 1, *v),
261        S::EventType(v) => primitives::encode_ctx_enumerated(buf, 2, *v),
262        S::Polarity(v) => primitives::encode_ctx_enumerated(buf, 3, *v),
263        S::ProgramChange(v) => primitives::encode_ctx_enumerated(buf, 4, *v),
264        S::ProgramState(v) => primitives::encode_ctx_enumerated(buf, 5, *v),
265        S::ReasonForHalt(v) => primitives::encode_ctx_enumerated(buf, 6, *v),
266        S::Reliability(v) => primitives::encode_ctx_enumerated(buf, 7, *v),
267        S::State(v) => primitives::encode_ctx_enumerated(buf, 8, *v),
268        S::SystemStatus(v) => primitives::encode_ctx_enumerated(buf, 9, *v),
269        S::Units(v) => primitives::encode_ctx_enumerated(buf, 10, *v),
270        S::UnsignedValue(v) => primitives::encode_ctx_unsigned(buf, 11, *v as u64),
271        S::LifeSafetyMode(v) => primitives::encode_ctx_enumerated(buf, 12, *v),
272        S::LifeSafetyState(v) => primitives::encode_ctx_enumerated(buf, 13, *v),
273        S::RestartReason(v) => primitives::encode_ctx_enumerated(buf, 14, *v),
274        S::DoorAlarmState(v) => primitives::encode_ctx_enumerated(buf, 15, *v),
275        S::Action(v) => primitives::encode_ctx_enumerated(buf, 16, *v),
276        S::DoorSecuredStatus(v) => primitives::encode_ctx_enumerated(buf, 17, *v),
277        S::DoorStatus(v) => primitives::encode_ctx_enumerated(buf, 18, *v),
278        S::DoorValue(v) => primitives::encode_ctx_enumerated(buf, 19, *v),
279        S::FileAccessMethod(v) => primitives::encode_ctx_enumerated(buf, 20, *v),
280        S::LockStatus(v) => primitives::encode_ctx_enumerated(buf, 21, *v),
281        S::LifeSafetyOperation(v) => primitives::encode_ctx_enumerated(buf, 22, *v),
282        S::Maintenance(v) => primitives::encode_ctx_enumerated(buf, 23, *v),
283        S::NodeType(v) => primitives::encode_ctx_enumerated(buf, 24, *v),
284        S::NotifyType(v) => primitives::encode_ctx_enumerated(buf, 25, *v),
285        S::ShedState(v) => primitives::encode_ctx_enumerated(buf, 27, *v),
286        S::SilencedState(v) => primitives::encode_ctx_enumerated(buf, 28, *v),
287        S::AccessEvent(v) => primitives::encode_ctx_enumerated(buf, 30, *v),
288        S::ZoneOccupancyState(v) => primitives::encode_ctx_enumerated(buf, 31, *v),
289        S::AccessCredentialDisableReason(v) => primitives::encode_ctx_enumerated(buf, 32, *v),
290        S::AccessCredentialDisable(v) => primitives::encode_ctx_enumerated(buf, 33, *v),
291        S::AuthenticationStatus(v) => primitives::encode_ctx_enumerated(buf, 34, *v),
292        S::BackupState(v) => primitives::encode_ctx_enumerated(buf, 36, *v),
293        S::WriteStatus(v) => primitives::encode_ctx_enumerated(buf, 37, *v),
294        S::LightingInProgress(v) => primitives::encode_ctx_enumerated(buf, 38, *v),
295        S::LightingOperation(v) => primitives::encode_ctx_enumerated(buf, 39, *v),
296        S::LightingTransition(v) => primitives::encode_ctx_enumerated(buf, 40, *v),
297        S::IntegerValue(v) => primitives::encode_ctx_signed(buf, 41, *v),
298        S::BinaryLightingValue(v) => primitives::encode_ctx_enumerated(buf, 42, *v),
299        S::TimerState(v) => primitives::encode_ctx_enumerated(buf, 43, *v),
300        S::TimerTransition(v) => primitives::encode_ctx_enumerated(buf, 44, *v),
301        S::BacnetIpMode(v) => primitives::encode_ctx_enumerated(buf, 45, *v),
302        S::NetworkPortCommand(v) => primitives::encode_ctx_enumerated(buf, 46, *v),
303        S::NetworkType(v) => primitives::encode_ctx_enumerated(buf, 47, *v),
304        S::NetworkNumberQuality(v) => primitives::encode_ctx_enumerated(buf, 48, *v),
305        S::EscalatorOperationDirection(v) => primitives::encode_ctx_enumerated(buf, 49, *v),
306        S::EscalatorFault(v) => primitives::encode_ctx_enumerated(buf, 50, *v),
307        S::EscalatorMode(v) => primitives::encode_ctx_enumerated(buf, 51, *v),
308        S::LiftCarDirection(v) => primitives::encode_ctx_enumerated(buf, 52, *v),
309        S::LiftCarDoorCommand(v) => primitives::encode_ctx_enumerated(buf, 53, *v),
310        S::LiftCarDriveStatus(v) => primitives::encode_ctx_enumerated(buf, 54, *v),
311        S::LiftCarMode(v) => primitives::encode_ctx_enumerated(buf, 55, *v),
312        S::LiftGroupMode(v) => primitives::encode_ctx_enumerated(buf, 56, *v),
313        S::LiftFault(v) => primitives::encode_ctx_enumerated(buf, 57, *v),
314        S::ProtocolLevel(v) => primitives::encode_ctx_enumerated(buf, 58, *v),
315        S::AuditLevel(v) => primitives::encode_ctx_enumerated(buf, 59, *v),
316        S::AuditOperation(v) => primitives::encode_ctx_enumerated(buf, 60, *v),
317        S::ExtendedValue(v) => primitives::encode_ctx_unsigned(buf, 63, v.encoded() as u64),
318        S::Other(v) if v.is_constructed() => {
319            tags::encode_opening_tag(buf, v.tag());
320            buf.extend_from_slice(v.data());
321            tags::encode_closing_tag(buf, v.tag());
322        }
323        S::Other(v) => primitives::encode_ctx_octet_string(buf, v.tag(), v.data()),
324    }
325    Ok(())
326}
327
328/// Decode one [`BACnetPropertyStates`] CHOICE element at `offset`.
329///
330/// Proprietary tags 64 through 254 retain their encoded contents in
331/// [`BACnetPropertyStates::Other`]. Reserved standard tags are rejected.
332pub fn decode_property_state(
333    data: &[u8],
334    offset: usize,
335) -> Result<(BACnetPropertyStates, usize), Error> {
336    use BACnetPropertyStates as S;
337    let (tag, pos) = tags::decode_tag(data, offset)?;
338    if tag.class != TagClass::Context || tag.is_closing {
339        return Err(Error::decoding(
340            offset,
341            "BACnetPropertyStates: expected a context tag",
342        ));
343    }
344    if tag.is_opening {
345        if !(64..=254).contains(&tag.number) {
346            return Err(Error::decoding(
347                offset,
348                "BACnetPropertyStates: constructed form requires a proprietary tag",
349            ));
350        }
351        let (content, end) = tags::extract_context_value(data, pos, tag.number)?;
352        validate_tlv_sequence(content, "proprietary property-state body")?;
353        return Ok((
354            S::Other(BACnetProprietaryPropertyState::constructed(
355                tag.number,
356                content.to_vec(),
357            )?),
358            end,
359        ));
360    }
361    let end = pos
362        .checked_add(tag.length as usize)
363        .ok_or_else(|| Error::decoding(pos, "BACnetPropertyStates: length overflow"))?;
364    if end > data.len() {
365        return Err(Error::buffer_too_short(end, data.len()));
366    }
367    let content = &data[pos..end];
368    let unsigned = || -> Result<u32, Error> {
369        u32::try_from(primitives::decode_unsigned(content)?)
370            .map_err(|_| Error::decoding(pos, "BACnetPropertyStates: contents exceed u32"))
371    };
372    let state = match tag.number {
373        0 => {
374            if content.len() != 1 {
375                return Err(Error::decoding(
376                    offset,
377                    format!(
378                        "BACnetPropertyStates boolean-value: expected 1 contents octet, got {}",
379                        content.len()
380                    ),
381                ));
382            }
383            match content[0] {
384                0 => S::BooleanValue(false),
385                1 => S::BooleanValue(true),
386                value => {
387                    return Err(Error::decoding(
388                        pos,
389                        format!("BACnetPropertyStates boolean-value must be 0 or 1, got {value}"),
390                    ));
391                }
392            }
393        }
394        1 => S::BinaryValue(unsigned()?),
395        2 => S::EventType(unsigned()?),
396        3 => S::Polarity(unsigned()?),
397        4 => S::ProgramChange(unsigned()?),
398        5 => S::ProgramState(unsigned()?),
399        6 => S::ReasonForHalt(unsigned()?),
400        7 => S::Reliability(unsigned()?),
401        8 => S::State(unsigned()?),
402        9 => S::SystemStatus(unsigned()?),
403        10 => S::Units(unsigned()?),
404        11 => S::UnsignedValue(unsigned()?),
405        12 => S::LifeSafetyMode(unsigned()?),
406        13 => S::LifeSafetyState(unsigned()?),
407        14 => S::RestartReason(unsigned()?),
408        15 => S::DoorAlarmState(unsigned()?),
409        16 => S::Action(unsigned()?),
410        17 => S::DoorSecuredStatus(unsigned()?),
411        18 => S::DoorStatus(unsigned()?),
412        19 => S::DoorValue(unsigned()?),
413        20 => S::FileAccessMethod(unsigned()?),
414        21 => S::LockStatus(unsigned()?),
415        22 => S::LifeSafetyOperation(unsigned()?),
416        23 => S::Maintenance(unsigned()?),
417        24 => S::NodeType(unsigned()?),
418        25 => S::NotifyType(unsigned()?),
419        27 => S::ShedState(unsigned()?),
420        28 => S::SilencedState(unsigned()?),
421        30 => S::AccessEvent(unsigned()?),
422        31 => S::ZoneOccupancyState(unsigned()?),
423        32 => S::AccessCredentialDisableReason(unsigned()?),
424        33 => S::AccessCredentialDisable(unsigned()?),
425        34 => S::AuthenticationStatus(unsigned()?),
426        36 => S::BackupState(unsigned()?),
427        37 => S::WriteStatus(unsigned()?),
428        38 => S::LightingInProgress(unsigned()?),
429        39 => S::LightingOperation(unsigned()?),
430        40 => S::LightingTransition(unsigned()?),
431        41 => S::IntegerValue(primitives::decode_signed_canonical(content)?),
432        42 => S::BinaryLightingValue(unsigned()?),
433        43 => S::TimerState(unsigned()?),
434        44 => S::TimerTransition(unsigned()?),
435        45 => S::BacnetIpMode(unsigned()?),
436        46 => S::NetworkPortCommand(unsigned()?),
437        47 => S::NetworkType(unsigned()?),
438        48 => S::NetworkNumberQuality(unsigned()?),
439        49 => S::EscalatorOperationDirection(unsigned()?),
440        50 => S::EscalatorFault(unsigned()?),
441        51 => S::EscalatorMode(unsigned()?),
442        52 => S::LiftCarDirection(unsigned()?),
443        53 => S::LiftCarDoorCommand(unsigned()?),
444        54 => S::LiftCarDriveStatus(unsigned()?),
445        55 => S::LiftCarMode(unsigned()?),
446        56 => S::LiftGroupMode(unsigned()?),
447        57 => S::LiftFault(unsigned()?),
448        58 => S::ProtocolLevel(unsigned()?),
449        59 => S::AuditLevel(unsigned()?),
450        60 => S::AuditOperation(unsigned()?),
451        63 => S::ExtendedValue(BACnetExtendedPropertyState::from_encoded(unsigned()?)?),
452        other @ 64..=254 => S::Other(BACnetProprietaryPropertyState::primitive(
453            other,
454            content.to_vec(),
455        )?),
456        reserved => {
457            return Err(Error::decoding(
458                offset,
459                format!("BACnetPropertyStates context tag {reserved} is reserved"),
460            ));
461        }
462    };
463    Ok((state, end))
464}
465
466// ---------------------------------------------------------------------------
467// BACnetDeviceObjectPropertyReference (Clause 21 SEQUENCE)
468// ---------------------------------------------------------------------------
469
470/// Encode the context-tagged members of a `BACnetDeviceObjectPropertyReference`:
471/// `object-identifier [0]`, `property-identifier [1]`, optional
472/// `property-array-index [2]`, optional `device-identifier [3]` — the body
473/// that sits between the enclosing field's opening/closing tags.
474pub(crate) fn encode_dopr_body(buf: &mut BytesMut, r: &BACnetDeviceObjectPropertyReference) {
475    primitives::encode_ctx_object_id(buf, 0, &r.object_identifier);
476    primitives::encode_ctx_unsigned(buf, 1, r.property_identifier as u64);
477    if let Some(index) = r.property_array_index {
478        primitives::encode_ctx_unsigned(buf, 2, index as u64);
479    }
480    if let Some(ref device) = r.device_identifier {
481        primitives::encode_ctx_object_id(buf, 3, device);
482    }
483}
484
485/// Decode the members written by [`encode_dopr_body`], stopping before the
486/// enclosing closing tag.
487pub(crate) fn decode_dopr_body(
488    data: &[u8],
489    offset: usize,
490    what: &str,
491) -> Result<(BACnetDeviceObjectPropertyReference, usize), Error> {
492    // [0] object-identifier
493    let (t, pos) = tags::decode_tag(data, offset)?;
494    if !t.is_context(0) || t.length != 4 {
495        return Err(Error::decoding(
496            offset,
497            format!("{what}: expected [0] object-identifier (4 octets)"),
498        ));
499    }
500    let end = pos + 4;
501    if end > data.len() {
502        return Err(Error::buffer_too_short(end, data.len()));
503    }
504    let object_identifier = ObjectIdentifier::decode(&data[pos..end])?;
505    let mut offset = end;
506
507    // [1] property-identifier
508    let (raw, new_offset) = decode_ctx_unsigned(data, offset, 1, what)?;
509    let property_identifier = u32::try_from(raw)
510        .map_err(|_| Error::decoding(offset, format!("{what}: property-identifier exceeds u32")))?;
511    offset = new_offset;
512
513    // [2] property-array-index OPTIONAL
514    let mut property_array_index = None;
515    if offset < data.len() {
516        let (peek, peek_pos) = tags::decode_tag(data, offset)?;
517        if peek.is_context(2) {
518            let end = peek_pos
519                .checked_add(peek.length as usize)
520                .ok_or_else(|| Error::decoding(peek_pos, format!("{what}: length overflow")))?;
521            if end > data.len() {
522                return Err(Error::buffer_too_short(end, data.len()));
523            }
524            let index = primitives::decode_unsigned(&data[peek_pos..end])?;
525            property_array_index = Some(u32::try_from(index).map_err(|_| {
526                Error::decoding(offset, format!("{what}: property-array-index exceeds u32"))
527            })?);
528            offset = end;
529        }
530    }
531
532    // [3] device-identifier OPTIONAL
533    let mut device_identifier = None;
534    if offset < data.len() {
535        let (peek, peek_pos) = tags::decode_tag(data, offset)?;
536        if peek.is_context(3) {
537            if peek.length != 4 {
538                return Err(Error::decoding(
539                    offset,
540                    format!("{what}: expected [3] device-identifier (4 octets)"),
541                ));
542            }
543            let end = peek_pos + 4;
544            if end > data.len() {
545                return Err(Error::buffer_too_short(end, data.len()));
546            }
547            device_identifier = Some(ObjectIdentifier::decode(&data[peek_pos..end])?);
548            offset = end;
549        }
550    }
551
552    Ok((
553        BACnetDeviceObjectPropertyReference {
554            object_identifier,
555            property_identifier,
556            property_array_index,
557            device_identifier,
558        },
559        offset,
560    ))
561}
562
563/// Validate a BACnet TLV sequence without normalizing its encoded values.
564///
565/// This checks matching context tags, the context nesting limit, and
566/// application-value forms while preserving defined CharacterString encodings.
567pub fn validate_tlv_sequence(data: &[u8], what: &str) -> Result<(), Error> {
568    let mut offset = 0;
569    let mut count = 0;
570    while offset < data.len() {
571        if count >= MAX_FRAMED_ITEMS {
572            return Err(Error::decoding(
573                offset,
574                format!("{what}: sequence exceeds item limit"),
575            ));
576        }
577        let (tag, content) = tags::decode_tag(data, offset)?;
578        if tag.is_opening {
579            let (inner, next) = tags::extract_context_value(data, content, tag.number)?;
580            validate_tlv_sequence(inner, what)?;
581            offset = next;
582        } else if tag.class == TagClass::Application {
583            offset = primitives::validate_application_value(data, offset)?;
584        } else {
585            let (_, next) = primitives::decode_application_value(data, offset)?;
586            offset = next;
587        }
588        count += 1;
589    }
590    Ok(())
591}
592
593/// Validate the shared Extended Event/Fault `parameters` production.
594/// Its only context-tagged CHOICE is `reference [0]` over
595/// `BACnetDeviceObjectPropertyReference`; NotificationParameters uses a
596/// different Extended production with `property-value [0]`.
597pub(crate) fn validate_extended_parameters(data: &[u8], what: &str) -> Result<(), Error> {
598    let mut offset = 0;
599    let mut count = 0;
600    while offset < data.len() {
601        if count >= MAX_FRAMED_ITEMS {
602            return Err(Error::decoding(
603                offset,
604                format!("{what}: parameters exceed item limit"),
605            ));
606        }
607        let (tag, content) = tags::decode_tag(data, offset)?;
608        if tag.class == TagClass::Context {
609            if !tag.is_opening_tag(0) {
610                return Err(Error::decoding(
611                    offset,
612                    format!("{what}: expected reference opening tag [0]"),
613                ));
614            }
615            let (_, after_reference) = decode_dopr_body(data, content, what)?;
616            offset = expect_closing(data, after_reference, 0, what)?;
617        } else {
618            offset = primitives::validate_application_value(data, offset)?;
619        }
620        count += 1;
621    }
622    Ok(())
623}
624
625#[cfg(test)]
626mod tests;