Skip to main content

bacnet_types/
error.rs

1//! BACnet error types.
2//!
3//! Provides the top-level [`Error`] type used throughout the library,
4//! covering protocol errors, encoding/decoding failures, transport issues,
5//! and timeouts.
6
7#[cfg(not(feature = "std"))]
8use alloc::{boxed::Box, string::String, vec::Vec};
9use core::time::Duration;
10
11use crate::constructed::BACnetObjectPropertyReference;
12use crate::data_link::DataLink;
13use crate::enums::{ErrorClass, ErrorCode, PropertyIdentifier, RejectReason};
14
15fn format_protocol_error(class: u32, code: u32) -> String {
16    let class_name = ErrorClass::ALL_NAMED
17        .iter()
18        .find(|(_, v)| v.to_raw() as u32 == class)
19        .map(|(n, _)| n.to_lowercase().replace('_', "-"));
20    let code_name = ErrorCode::ALL_NAMED
21        .iter()
22        .find(|(_, v)| v.to_raw() as u32 == code)
23        .map(|(n, _)| n.to_lowercase().replace('_', "-"));
24
25    match (class_name, code_name) {
26        (Some(cn), Some(co)) => format!("BACnet error: {cn} / {co}"),
27        (Some(cn), None) => format!("BACnet error: {cn} / code={code}"),
28        (None, Some(co)) => format!("BACnet error: class={class} / {co}"),
29        (None, None) => format!("BACnet error: class={class} / code={code}"),
30    }
31}
32
33/// Top-level error type for the BACnet library.
34///
35/// It is `#[non_exhaustive]`, so a `match` on it outside this crate needs a
36/// wildcard arm. `Error::Transport` exists only with the `std` feature, and
37/// any crate in a build can turn that on, so without the attribute a match
38/// written against the `alloc` build would stop compiling (#1604).
39#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41// Clippy fails if the attribute above goes.
42#[deny(clippy::exhaustive_enums)]
43pub enum Error {
44    /// BACnet protocol error response (Clause 20.1.7).
45    #[error("{}", format_protocol_error(*.class, *.code))]
46    Protocol {
47        /// Error class value.
48        class: u32,
49        /// Error code value.
50        code: u32,
51    },
52
53    /// BACnet error response whose Clause 21 production carries more than the
54    /// class and code: ChangeList-Error, CreateObject-Error,
55    /// WritePropertyMultiple-Error, SubscribeCOVPropertyMultiple-Error,
56    /// ConfirmedPrivateTransfer-Error or VTClose-Error. `detail` holds the
57    /// extra fields; a body whose optional fields are all absent is reported
58    /// as [`Error::Protocol`].
59    #[error("{}, {detail}", format_protocol_error(*.class, *.code))]
60    Structured {
61        /// Error class value.
62        class: u32,
63        /// Error code value.
64        code: u32,
65        /// The production's fields after the class and code. Boxed so the
66        /// rare structured error does not widen every `Result<_, Error>`.
67        detail: Box<ErrorDetail>,
68    },
69
70    /// BACnet reject PDU (Clause 20.1.5).
71    #[error("BACnet reject: reason={reason}")]
72    Reject {
73        /// Reject reason value.
74        reason: u8,
75    },
76
77    /// BACnet abort PDU (Clause 20.1.6).
78    #[error("BACnet abort: reason={reason}")]
79    Abort {
80        /// Abort reason value.
81        reason: u8,
82    },
83
84    /// A router reported that the active message was too long for a routed path.
85    #[error("message is too long for routed path to DNET {dnet}")]
86    RoutedPathTooLong {
87        /// Destination network rejected by the router.
88        dnet: u16,
89    },
90
91    /// The client cannot safely allocate state for another routed path.
92    #[error("routed path state capacity of {capacity} entries is exhausted")]
93    RoutedPathCapacityExceeded {
94        /// Maximum number of immediate-router/DNET path entries retained.
95        capacity: usize,
96    },
97
98    /// The endpoint's transport cannot carry the requested operation, such as
99    /// a BBMD management request through a data link other than BACnet/IP.
100    /// Nothing was sent.
101    #[error("operation requires {required}; this transport is {actual}")]
102    UnsupportedTransport {
103        /// Data link the operation needs.
104        required: DataLink,
105        /// Data link the endpoint's transport carries.
106        actual: DataLink,
107    },
108
109    /// Error encoding a PDU.
110    #[error("encoding error: {0}")]
111    Encoding(String),
112
113    /// Error decoding received data.
114    #[error("decoding error at offset {offset}: {message}")]
115    Decoding {
116        /// Byte offset where the error occurred.
117        offset: usize,
118        /// Which kind of fault the decoder found.
119        kind: DecodingKind,
120        /// Description of what went wrong.
121        message: String,
122    },
123
124    /// Transport-level I/O error. Only with the `std` feature, which has
125    /// `std::io`.
126    #[cfg(feature = "std")]
127    #[error("transport error: {0}")]
128    Transport(#[from] std::io::Error),
129
130    /// Request timed out.
131    #[error("request timed out after {0:?}")]
132    Timeout(Duration),
133
134    /// Segmentation assembly error.
135    #[error("segmentation error: {0}")]
136    Segmentation(String),
137
138    /// Buffer too short for the expected data.
139    #[error("buffer too short: need {need} bytes, have {have}")]
140    BufferTooShort {
141        /// Minimum bytes needed.
142        need: usize,
143        /// Bytes actually available.
144        have: usize,
145    },
146
147    /// Value out of valid range.
148    #[error("value out of range: {0}")]
149    OutOfRange(String),
150
151    /// A well-formed ReadRange acknowledgement that breaks a rule the client
152    /// checks against the request it answers, so the client refused it. The
153    /// acknowledgement decoded; a malformed one is [`Error::Decoding`]
154    /// instead. Read again leniently to keep its items.
155    #[error("ReadRange acknowledgement refused: {0}")]
156    ReadRangeViolation(ReadRangeViolation),
157
158    /// A paged log read asked for the records from one sequence number and
159    /// the device answered with a page starting before it, so reading on
160    /// would repeat records instead of advancing; or answered with none
161    /// although its record counts say it holds that record. Some devices
162    /// clamp a reference past their sequence wrap back to the oldest record,
163    /// or number the record after the wrap 0; read such a log by position
164    /// instead.
165    #[error("log read is not advancing: {}", not_advancing(*.requested, *.returned))]
166    LogNotAdvancing {
167        /// The sequence number the read asked for.
168        requested: u64,
169        /// What the device numbered the first record it sent;
170        /// `None` when it returned no records.
171        returned: Option<u64>,
172    },
173}
174
175fn not_advancing(requested: u64, returned: Option<u64>) -> String {
176    match returned {
177        Some(returned) => {
178            format!("asked for sequence number {requested}, the device answered from {returned}")
179        }
180        None => format!(
181            "asked for sequence number {requested}, which the log's counts say it holds, \
182             the device answered with no records"
183        ),
184    }
185}
186
187/// A rule a ReadRange acknowledgement can break against the request it
188/// answers (Clause 15.8).
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub enum ReadRangeViolation {
191    /// The object identifier isn't the one the request named.
192    ObjectMismatch,
193    /// The property identifier isn't the one the request named.
194    PropertyMismatch,
195    /// The array index isn't the one the request named, or is present when
196    /// the request had none, or the reverse.
197    ArrayIndexMismatch,
198    /// A nonempty answer to a By-Sequence or By-Time read without the
199    /// sequence number of its first item (Clause 15.8.1.2.7).
200    MissingFirstSequenceNumber,
201    /// A nonempty answer to a By-Sequence or By-Time read that numbers its
202    /// first item 0, a number logs never assign: they count from 1 and skip
203    /// 0 when they wrap. Some devices do number the record after the wrap 0.
204    ZeroFirstSequenceNumber,
205    /// A first sequence number in the answer to a read that was by position
206    /// or had no range, which carries none (Clause 15.8.1.2.7).
207    UnexpectedFirstSequenceNumber,
208    /// MORE_ITEMS together with the flag that says the page reached the end
209    /// a ranged read moves toward: LAST_ITEM for a forward read, FIRST_ITEM
210    /// for a backward one. Items left out of a full page lie beyond that
211    /// end, so the two contradict each other (Clause 15.8.2). A read with no
212    /// range may keep either end, so for it only all three flags together
213    /// break the rule.
214    MoreItemsPastEnd,
215    /// More items than the request's count asked for.
216    ItemCountExceedsRequest,
217}
218
219impl ReadRangeViolation {
220    /// The rule's name in snake case, as the Python bindings report it.
221    pub fn name(self) -> &'static str {
222        match self {
223            Self::ObjectMismatch => "object_mismatch",
224            Self::PropertyMismatch => "property_mismatch",
225            Self::ArrayIndexMismatch => "array_index_mismatch",
226            Self::MissingFirstSequenceNumber => "missing_first_sequence_number",
227            Self::ZeroFirstSequenceNumber => "zero_first_sequence_number",
228            Self::UnexpectedFirstSequenceNumber => "unexpected_first_sequence_number",
229            Self::MoreItemsPastEnd => "more_items_past_end",
230            Self::ItemCountExceedsRequest => "item_count_exceeds_request",
231        }
232    }
233}
234
235impl core::fmt::Display for ReadRangeViolation {
236    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
237        f.write_str(match self {
238            Self::ObjectMismatch => "object identifier does not match the request",
239            Self::PropertyMismatch => "property identifier does not match the request",
240            Self::ArrayIndexMismatch => "array index does not match the request",
241            Self::MissingFirstSequenceNumber => {
242                "nonempty By Sequence/Time answer has no first sequence number"
243            }
244            Self::ZeroFirstSequenceNumber => {
245                "nonempty By Sequence/Time answer has first sequence number 0"
246            }
247            Self::UnexpectedFirstSequenceNumber => {
248                "answer to a read by position or without a range has a first sequence number"
249            }
250            Self::MoreItemsPastEnd => {
251                "MORE_ITEMS is set with the flag for the end a ranged read moves toward, \
252                 or with both FIRST_ITEM and LAST_ITEM on a read without a range"
253            }
254            Self::ItemCountExceedsRequest => "more items than the request's count",
255        })
256    }
257}
258
259/// Which fault a decoder found in the data it refused, so a responder can
260/// name it (#1446). [`DecodingKind::reject_reason`] gives the Clause 18.9
261/// Reject reason each kind draws when the data was a confirmed request.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
263pub enum DecodingKind {
264    /// Contents whose encoding isn't valid for their datatype: a wrong
265    /// length, an Unsigned of no octets or with padding a canonical field
266    /// refuses, an unknown character set, a BIT STRING's unused-bit count
267    /// past 7, and the like. The kind [`Error::decoding`] makes.
268    InvalidEncoding,
269    /// A value too large for its field's width, or outside the range the
270    /// production or service gives it.
271    OutOfRange,
272    /// More than the decoder takes: a count of items past its limit, or a tag
273    /// length past its sanity bound.
274    Overflow,
275    /// A tag that doesn't fit where it stands: another number, class or form
276    /// than the member there needs, a closing tag that closes no open frame,
277    /// a tag header that is itself malformed, or an opening tag nested
278    /// deeper than the decoder walks.
279    InvalidTag,
280    /// The data ends, or the enclosing frame closes, where a required member
281    /// or a frame's closing tag is due: a member is missing.
282    Missing,
283    /// Octets are left after the last member of a value that must fill its
284    /// input: arguments beyond those the production has.
285    Trailing,
286    /// A well-formed encoding the decoder doesn't handle: a CharacterString
287    /// in a character set it can't convert (IBM/Microsoft DBCS, JIS X 0208
288    /// or UCS-4).
289    Unsupported,
290}
291
292impl DecodingKind {
293    /// The Clause 18.9 Reject reason that names this fault in a confirmed
294    /// request:
295    ///
296    /// | Kind | Reject reason |
297    /// |---|---|
298    /// | [`InvalidEncoding`](Self::InvalidEncoding) | INVALID_DATA_ENCODING |
299    /// | [`OutOfRange`](Self::OutOfRange) | PARAMETER_OUT_OF_RANGE |
300    /// | [`Overflow`](Self::Overflow) | BUFFER_OVERFLOW |
301    /// | [`InvalidTag`](Self::InvalidTag) | INVALID_TAG |
302    /// | [`Missing`](Self::Missing) | MISSING_REQUIRED_PARAMETER |
303    /// | [`Trailing`](Self::Trailing) | TOO_MANY_ARGUMENTS |
304    /// | [`Unsupported`](Self::Unsupported) | OTHER |
305    pub fn reject_reason(self) -> RejectReason {
306        match self {
307            Self::InvalidEncoding => RejectReason::INVALID_DATA_ENCODING,
308            Self::OutOfRange => RejectReason::PARAMETER_OUT_OF_RANGE,
309            Self::Overflow => RejectReason::BUFFER_OVERFLOW,
310            Self::InvalidTag => RejectReason::INVALID_TAG,
311            Self::Missing => RejectReason::MISSING_REQUIRED_PARAMETER,
312            Self::Trailing => RejectReason::TOO_MANY_ARGUMENTS,
313            Self::Unsupported => RejectReason::OTHER,
314        }
315    }
316}
317
318/// The fields a Clause 21 error production adds to the error class and code,
319/// one variant per shape. The service the error answers names the production.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum ErrorDetail {
322    /// ChangeList-Error (AddListElement, RemoveListElement; Clauses 15.1.1.3
323    /// and 15.2.1.3) and CreateObject-Error (Clause 15.3.1.3): the 1-based
324    /// position of the refused element in the request's List of Elements or
325    /// List of Initial Values, or 0 when the failure is not about one element.
326    /// An object refusing one element of a list-valued write uses it too, for
327    /// that element's position in the list it was given; the server's list
328    /// handler turns that into the request's position.
329    FirstFailedElementNumber(u32),
330    /// WritePropertyMultiple-Error (Clause 15.10.1.3): the object, property
331    /// and array index of the first write that failed.
332    FirstFailedWriteAttempt(BACnetObjectPropertyReference),
333    /// SubscribeCOVPropertyMultiple-Error (Clause 13.16.1.3) in its
334    /// first-failed-subscription form: the monitored object and the property
335    /// reference of the COV reference that failed. The other form, a general
336    /// error, has no detail.
337    FirstFailedSubscription(BACnetObjectPropertyReference),
338    /// ConfirmedPrivateTransfer-Error (Clause 16.2.1.3): the vendor and
339    /// service the error answers, and any vendor-defined error parameters.
340    PrivateTransfer {
341        /// Vendor identifier of the private service.
342        vendor_id: u32,
343        /// Vendor-defined service number.
344        service_number: u32,
345        /// Error parameters as encoded inside their context tag 3 frame,
346        /// opaque to the stack; `None` when absent.
347        error_parameters: Option<Vec<u8>>,
348    },
349    /// VTClose-Error (Clause 17.3.1.3) with its list present: the
350    /// requester's local identifiers of the VT sessions that could not be
351    /// closed. Without the list the error has no detail.
352    VtSessionIdentifiers(Vec<u8>),
353}
354
355impl core::fmt::Display for ErrorDetail {
356    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
357        fn reference(
358            f: &mut core::fmt::Formatter<'_>,
359            what: &str,
360            reference: &BACnetObjectPropertyReference,
361        ) -> core::fmt::Result {
362            write!(
363                f,
364                "{what} {} {}",
365                reference.object_identifier,
366                PropertyIdentifier::from_raw(reference.property_identifier)
367            )?;
368            match reference.property_array_index {
369                Some(index) => write!(f, "[{index}]"),
370                None => Ok(()),
371            }
372        }
373        match self {
374            Self::FirstFailedElementNumber(number) => write!(f, "first failed element {number}"),
375            Self::FirstFailedWriteAttempt(attempt) => {
376                reference(f, "first failed write attempt", attempt)
377            }
378            Self::FirstFailedSubscription(subscription) => {
379                reference(f, "first failed subscription", subscription)
380            }
381            Self::PrivateTransfer {
382                vendor_id,
383                service_number,
384                error_parameters,
385            } => {
386                write!(f, "vendor {vendor_id} service {service_number}")?;
387                match error_parameters {
388                    Some(parameters) => {
389                        write!(f, ", {} octets of error parameters", parameters.len())
390                    }
391                    None => Ok(()),
392                }
393            }
394            Self::VtSessionIdentifiers(sessions) => {
395                f.write_str("VT sessions not closed:")?;
396                for session in sessions {
397                    write!(f, " {session}")?;
398                }
399                Ok(())
400            }
401        }
402    }
403}
404
405/// Convenience alias for `Result<T, Error>`.
406pub type Result<T> = core::result::Result<T, Error>;
407
408impl Error {
409    /// The error an Error PDU reports: [`Error::Structured`] when its body
410    /// carried a detail, [`Error::Protocol`] otherwise.
411    pub fn protocol(class: u32, code: u32, detail: Option<ErrorDetail>) -> Self {
412        match detail {
413            Some(detail) => Self::Structured {
414                class,
415                code,
416                detail: Box::new(detail),
417            },
418            None => Self::Protocol { class, code },
419        }
420    }
421
422    /// Create a [`DecodingKind::InvalidEncoding`] decoding error at the given
423    /// byte offset: contents whose encoding isn't valid for their datatype.
424    pub fn decoding(offset: usize, message: impl Into<String>) -> Self {
425        Self::decoding_kind(DecodingKind::InvalidEncoding, offset, message)
426    }
427
428    /// Create a decoding error of `kind` at the given byte offset.
429    pub fn decoding_kind(kind: DecodingKind, offset: usize, message: impl Into<String>) -> Self {
430        Self::Decoding {
431            offset,
432            kind,
433            message: message.into(),
434        }
435    }
436
437    /// Create a [`DecodingKind::InvalidTag`] decoding error: a tag at
438    /// `offset` that doesn't fit there.
439    pub fn invalid_tag(offset: usize, message: impl Into<String>) -> Self {
440        Self::decoding_kind(DecodingKind::InvalidTag, offset, message)
441    }
442
443    /// Create a [`DecodingKind::Missing`] decoding error: a required member
444    /// due at `offset` isn't there.
445    pub fn missing(offset: usize, message: impl Into<String>) -> Self {
446        Self::decoding_kind(DecodingKind::Missing, offset, message)
447    }
448
449    /// Create a [`DecodingKind::Trailing`] decoding error: octets from
450    /// `offset` on that the value leaves unread.
451    pub fn trailing(offset: usize, message: impl Into<String>) -> Self {
452        Self::decoding_kind(DecodingKind::Trailing, offset, message)
453    }
454
455    /// Create a [`DecodingKind::OutOfRange`] decoding error: a value at
456    /// `offset` too large for its field, or outside its range.
457    pub fn out_of_range(offset: usize, message: impl Into<String>) -> Self {
458        Self::decoding_kind(DecodingKind::OutOfRange, offset, message)
459    }
460
461    /// Create a [`DecodingKind::Overflow`] decoding error: more items, or a
462    /// longer tag, than the decoder takes.
463    pub fn overflow(offset: usize, message: impl Into<String>) -> Self {
464        Self::decoding_kind(DecodingKind::Overflow, offset, message)
465    }
466
467    /// The Reject reason a responder answers a confirmed request with when
468    /// this error refused it while decoding its parameters (Clauses 18.9 and
469    /// 20.1.8): the reason itself for [`Error::Reject`], the kind's reason
470    /// for [`Error::Decoding`] (the table on [`DecodingKind::reject_reason`]),
471    /// and MISSING_REQUIRED_PARAMETER for [`Error::BufferTooShort`], a member
472    /// cut short because the data ran out before it was complete. `None` for
473    /// any other error, which no decoder of the request's parameters
474    /// reports.
475    pub fn reject_reason(&self) -> Option<RejectReason> {
476        match self {
477            Self::Reject { reason } => Some(RejectReason::from_raw(*reason)),
478            Self::Decoding { kind, .. } => Some(kind.reject_reason()),
479            Self::BufferTooShort { .. } => Some(RejectReason::MISSING_REQUIRED_PARAMETER),
480            _ => None,
481        }
482    }
483
484    /// This error, met decoding a confirmed request's own parameters, as the
485    /// [`Error::Reject`] a responder answers with: the
486    /// [`reject_reason`](Self::reject_reason) when it is a syntax fault, the
487    /// error unchanged otherwise.
488    ///
489    /// A responder converts only the request's decode with this. A decoding
490    /// error met later, once the service has started to run, is no reason to
491    /// reject the request (Clause 20.1.8), so it stays as it is.
492    pub fn into_request_reject(self) -> Self {
493        match self.reject_reason() {
494            Some(reason) => Self::Reject {
495                reason: reason.to_raw(),
496            },
497            None => self,
498        }
499    }
500
501    /// Create a buffer-too-short error.
502    pub fn buffer_too_short(need: usize, have: usize) -> Self {
503        Self::BufferTooShort { need, have }
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn protocol_error_display() {
513        let err = Error::Protocol { class: 2, code: 31 };
514        assert!(err.to_string().contains("property"));
515        assert!(err.to_string().contains("unknown-object"));
516
517        // Unknown class/code falls back to numeric
518        let err2 = Error::Protocol {
519            class: 999,
520            code: 999,
521        };
522        assert!(err2.to_string().contains("class=999"));
523        assert!(err2.to_string().contains("code=999"));
524    }
525
526    #[test]
527    fn structured_error_display_names_the_detail() {
528        use crate::enums::ObjectType;
529        use crate::primitives::ObjectIdentifier;
530
531        let reference = |index| BACnetObjectPropertyReference {
532            object_identifier: ObjectIdentifier::new(ObjectType::ANALOG_VALUE, 7).unwrap(),
533            property_identifier: PropertyIdentifier::PRESENT_VALUE.to_raw(),
534            property_array_index: index,
535        };
536        let private_transfer = |error_parameters| ErrorDetail::PrivateTransfer {
537            vendor_id: 555,
538            service_number: 7,
539            error_parameters,
540        };
541        for (class, code, detail, expected) in [
542            (
543                5,
544                81,
545                ErrorDetail::FirstFailedElementNumber(2),
546                "BACnet error: services / list-element-not-found, first failed element 2",
547            ),
548            (
549                2,
550                40,
551                ErrorDetail::FirstFailedWriteAttempt(reference(Some(3))),
552                "BACnet error: property / write-access-denied, \
553                 first failed write attempt ANALOG_VALUE,7 PRESENT_VALUE[3]",
554            ),
555            (
556                2,
557                44,
558                ErrorDetail::FirstFailedSubscription(reference(None)),
559                "BACnet error: property / not-cov-property, \
560                 first failed subscription ANALOG_VALUE,7 PRESENT_VALUE",
561            ),
562            (
563                5,
564                0,
565                private_transfer(Some(vec![0x21, 1])),
566                "BACnet error: services / other, vendor 555 service 7, \
567                 2 octets of error parameters",
568            ),
569            (
570                5,
571                0,
572                private_transfer(None),
573                "BACnet error: services / other, vendor 555 service 7",
574            ),
575            (
576                5,
577                39,
578                ErrorDetail::VtSessionIdentifiers(vec![1, 4]),
579                "BACnet error: services / vt-session-termination-failure, \
580                 VT sessions not closed: 1 4",
581            ),
582        ] {
583            assert_eq!(
584                Error::protocol(class, code, Some(detail)).to_string(),
585                expected
586            );
587        }
588        assert!(matches!(
589            Error::protocol(2, 31, None),
590            Error::Protocol { class: 2, code: 31 }
591        ));
592    }
593
594    #[test]
595    fn decoding_error_display() {
596        let err = Error::decoding(42, "unexpected tag");
597        assert!(err.to_string().contains("offset 42"));
598        assert!(err.to_string().contains("unexpected tag"));
599    }
600
601    #[test]
602    fn syntax_faults_name_their_reject_reasons() {
603        for (error, reason) in [
604            (
605                Error::decoding(1, "bad"),
606                RejectReason::INVALID_DATA_ENCODING,
607            ),
608            (
609                Error::out_of_range(1, "wide"),
610                RejectReason::PARAMETER_OUT_OF_RANGE,
611            ),
612            (Error::overflow(1, "many"), RejectReason::BUFFER_OVERFLOW),
613            (
614                Error::decoding_kind(DecodingKind::Unsupported, 1, "DBCS"),
615                RejectReason::OTHER,
616            ),
617            (Error::invalid_tag(1, "tag"), RejectReason::INVALID_TAG),
618            (
619                Error::missing(1, "gone"),
620                RejectReason::MISSING_REQUIRED_PARAMETER,
621            ),
622            (Error::trailing(1, "more"), RejectReason::TOO_MANY_ARGUMENTS),
623            (
624                Error::buffer_too_short(9, 4),
625                RejectReason::MISSING_REQUIRED_PARAMETER,
626            ),
627            (
628                Error::Reject { reason: 8 },
629                RejectReason::UNDEFINED_ENUMERATION,
630            ),
631        ] {
632            assert_eq!(error.reject_reason(), Some(reason), "{error}");
633        }
634        for error in [
635            Error::Protocol { class: 5, code: 0 },
636            Error::Encoding("x".into()),
637            Error::OutOfRange("x".into()),
638            Error::Abort { reason: 1 },
639        ] {
640            assert_eq!(error.reject_reason(), None, "{error}");
641        }
642        // A request's decode error becomes its Reject; any other error stays.
643        assert!(matches!(
644            Error::trailing(3, "more").into_request_reject(),
645            Error::Reject { reason } if reason == RejectReason::TOO_MANY_ARGUMENTS.to_raw()
646        ));
647        assert!(matches!(
648            Error::OutOfRange("x".into()).into_request_reject(),
649            Error::OutOfRange(_)
650        ));
651        // The kind doesn't show in the message.
652        assert_eq!(
653            Error::trailing(3, "two trailing byte(s)").to_string(),
654            "decoding error at offset 3: two trailing byte(s)"
655        );
656    }
657
658    #[test]
659    fn buffer_too_short_display() {
660        let err = Error::buffer_too_short(10, 3);
661        assert!(err.to_string().contains("need 10"));
662        assert!(err.to_string().contains("have 3"));
663    }
664
665    #[test]
666    fn timeout_error_display() {
667        let err = Error::Timeout(Duration::from_secs(3));
668        assert!(err.to_string().contains("3s"));
669    }
670
671    #[test]
672    fn routed_path_too_long_display_preserves_dnet() {
673        let err = Error::RoutedPathTooLong { dnet: 1234 };
674        assert!(err.to_string().contains("1234"));
675    }
676
677    #[test]
678    fn routed_path_capacity_display_preserves_bound() {
679        let err = Error::RoutedPathCapacityExceeded { capacity: 256 };
680        assert!(err.to_string().contains("256"));
681    }
682
683    #[test]
684    fn unsupported_transport_display_names_both_data_links() {
685        let err = Error::UnsupportedTransport {
686            required: DataLink::Bip,
687            actual: DataLink::Mstp,
688        };
689        assert_eq!(
690            err.to_string(),
691            "operation requires BACnet/IP; this transport is MS/TP"
692        );
693    }
694}