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}