Skip to main content

bacnet_encoding/
npdu.rs

1//! NPDU encoding and decoding per ASHRAE 135-2020 Clause 6.
2//!
3//! The Network Protocol Data Unit carries either an application-layer APDU
4//! or a network-layer message, with optional source/destination routing
5//! information for multi-hop BACnet internetworks.
6
7use bacnet_types::enums::NetworkPriority;
8use bacnet_types::error::Error;
9use bacnet_types::MacAddr;
10use bytes::{BufMut, Bytes, BytesMut};
11
12/// BACnet protocol version (always 1).
13pub const BACNET_PROTOCOL_VERSION: u8 = 1;
14
15// ---------------------------------------------------------------------------
16// Address used in NPDU routing fields
17// ---------------------------------------------------------------------------
18
19/// Network-layer address: network number + MAC address.
20///
21/// Used for source/destination fields in routed NPDUs.
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct NpduAddress {
24    /// Network number (1-65534, or 0xFFFF for global broadcast destination).
25    pub network: u16,
26    /// MAC-layer address (variable length, empty for broadcast).
27    pub mac_address: MacAddr,
28}
29
30// ---------------------------------------------------------------------------
31// NPDU struct
32// ---------------------------------------------------------------------------
33
34/// Decoded Network Protocol Data Unit (Clause 6.2).
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Npdu {
37    /// Whether this is a network-layer message (vs application-layer APDU).
38    pub is_network_message: bool,
39    /// Whether the sender expects a reply.
40    pub expecting_reply: bool,
41    /// Message priority.
42    pub priority: NetworkPriority,
43    /// Remote destination address, if routed.
44    pub destination: Option<NpduAddress>,
45    /// Originating source address (populated by routers).
46    pub source: Option<NpduAddress>,
47    /// Remaining hop count for routed messages (0-255).
48    pub hop_count: u8,
49    /// Network message type (when `is_network_message` is true).
50    pub message_type: Option<u8>,
51    /// Vendor ID for proprietary network messages (message_type >= 0x80).
52    pub vendor_id: Option<u16>,
53    /// Payload: either APDU bytes or network message data.
54    pub payload: Bytes,
55}
56
57impl Default for Npdu {
58    fn default() -> Self {
59        Self {
60            is_network_message: false,
61            expecting_reply: false,
62            priority: NetworkPriority::NORMAL,
63            destination: None,
64            source: None,
65            hop_count: 255,
66            message_type: None,
67            vendor_id: None,
68            payload: Bytes::new(),
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Encoding
75// ---------------------------------------------------------------------------
76
77/// Encode an NPDU to wire format.
78pub fn encode_npdu(buf: &mut BytesMut, npdu: &Npdu) -> Result<(), Error> {
79    buf.put_u8(BACNET_PROTOCOL_VERSION);
80
81    let mut control: u8 = npdu.priority.to_raw() & 0x03;
82    if npdu.is_network_message {
83        control |= 0x80;
84    }
85    if npdu.destination.is_some() {
86        control |= 0x20;
87    }
88    if npdu.source.is_some() {
89        control |= 0x08;
90    }
91    if npdu.expecting_reply {
92        control |= 0x04;
93    }
94    buf.put_u8(control);
95
96    if let Some(dest) = &npdu.destination {
97        if dest.network == 0 {
98            return Err(Error::Encoding("NPDU DNET must not be 0".into()));
99        }
100        buf.put_u16(dest.network);
101        if dest.mac_address.len() > 255 {
102            return Err(Error::Encoding(
103                "NPDU destination MAC address exceeds 255 bytes".into(),
104            ));
105        }
106        buf.put_u8(dest.mac_address.len() as u8);
107        buf.put_slice(&dest.mac_address);
108    }
109
110    if let Some(src) = &npdu.source {
111        if src.network == 0 || src.network == 0xFFFF {
112            return Err(Error::Encoding(format!(
113                "NPDU SNET must be 1..65534, got {}",
114                src.network
115            )));
116        }
117        if src.mac_address.is_empty() {
118            return Err(Error::Encoding("NPDU SLEN must not be 0".into()));
119        }
120        buf.put_u16(src.network);
121        if src.mac_address.len() > 255 {
122            return Err(Error::Encoding(
123                "NPDU source MAC address exceeds 255 bytes".into(),
124            ));
125        }
126        buf.put_u8(src.mac_address.len() as u8);
127        buf.put_slice(&src.mac_address);
128    }
129
130    if npdu.destination.is_some() {
131        buf.put_u8(npdu.hop_count);
132    }
133
134    if npdu.is_network_message {
135        if let Some(msg_type) = npdu.message_type {
136            buf.put_u8(msg_type);
137            if msg_type >= 0x80 {
138                buf.put_u16(npdu.vendor_id.unwrap_or(0));
139            }
140        }
141    }
142
143    buf.put_slice(&npdu.payload);
144
145    Ok(())
146}
147
148// ---------------------------------------------------------------------------
149// Decoding
150// ---------------------------------------------------------------------------
151
152/// Decode an NPDU from raw bytes.
153///
154/// Returns the decoded [`Npdu`]. The `payload` field contains either the
155/// APDU bytes or network message data.
156pub fn decode_npdu(data: Bytes) -> Result<Npdu, Error> {
157    if data.len() < 2 {
158        return Err(Error::buffer_too_short(2, data.len()));
159    }
160
161    let version = data[0];
162    if version != BACNET_PROTOCOL_VERSION {
163        return Err(Error::decoding(
164            0,
165            format!("unsupported BACnet protocol version: {version}"),
166        ));
167    }
168
169    let control = data[1];
170    let is_network_message = control & 0x80 != 0;
171    let has_destination = control & 0x20 != 0;
172    let has_source = control & 0x08 != 0;
173    let expecting_reply = control & 0x04 != 0;
174    let priority = NetworkPriority::from_raw(control & 0x03);
175
176    if control & 0x50 != 0 {
177        tracing::warn!(
178            control_byte = control,
179            "NPDU control byte has reserved bits set (bits 4 or 6)"
180        );
181    }
182
183    let mut offset = 2;
184    let mut destination = None;
185    let mut source = None;
186    let mut hop_count: u8 = 255;
187
188    if has_destination {
189        if offset + 3 > data.len() {
190            return Err(Error::decoding(
191                offset,
192                "NPDU too short for destination fields",
193            ));
194        }
195        let dnet = u16::from_be_bytes([data[offset], data[offset + 1]]);
196        offset += 2;
197        let dlen = data[offset] as usize;
198        offset += 1;
199
200        if dlen > 0 && offset + dlen > data.len() {
201            return Err(Error::decoding(
202                offset,
203                format!("NPDU destination address truncated: DLEN={dlen}"),
204            ));
205        }
206        let dadr = MacAddr::from_slice(&data[offset..offset + dlen]);
207        offset += dlen;
208
209        if dnet == 0 {
210            return Err(Error::decoding(
211                offset - dlen - 3, // point back to DNET field
212                "NPDU destination network 0 is invalid",
213            ));
214        }
215
216        destination = Some(NpduAddress {
217            network: dnet,
218            mac_address: dadr,
219        });
220    }
221
222    if has_source {
223        if offset + 3 > data.len() {
224            return Err(Error::decoding(offset, "NPDU too short for source fields"));
225        }
226        let snet = u16::from_be_bytes([data[offset], data[offset + 1]]);
227        offset += 2;
228        let slen = data[offset] as usize;
229        offset += 1;
230
231        if slen == 0 {
232            return Err(Error::decoding(offset - 1, "NPDU source SLEN=0 is invalid"));
233        }
234
235        if slen > 0 && offset + slen > data.len() {
236            return Err(Error::decoding(
237                offset,
238                format!("NPDU source address truncated: SLEN={slen}"),
239            ));
240        }
241        let sadr = MacAddr::from_slice(&data[offset..offset + slen]);
242        offset += slen;
243
244        source = Some(NpduAddress {
245            network: snet,
246            mac_address: sadr,
247        });
248
249        if snet == 0 {
250            return Err(Error::decoding(
251                offset - slen - 3, // point back to SNET field
252                "NPDU source network 0 is invalid",
253            ));
254        }
255        if snet == 0xFFFF {
256            return Err(Error::decoding(
257                offset - slen - 3,
258                "NPDU source network 0xFFFF is invalid",
259            ));
260        }
261    }
262
263    if has_destination {
264        if offset >= data.len() {
265            return Err(Error::decoding(offset, "NPDU too short for hop count"));
266        }
267        hop_count = data[offset];
268        offset += 1;
269    }
270
271    let mut message_type = None;
272    let mut vendor_id = None;
273
274    if is_network_message {
275        if offset >= data.len() {
276            return Err(Error::decoding(
277                offset,
278                "NPDU too short for network message type",
279            ));
280        }
281        let msg_type = data[offset];
282        offset += 1;
283        message_type = Some(msg_type);
284
285        if msg_type >= 0x80 {
286            if offset + 2 > data.len() {
287                return Err(Error::decoding(
288                    offset,
289                    "NPDU too short for proprietary vendor ID",
290                ));
291            }
292            vendor_id = Some(u16::from_be_bytes([data[offset], data[offset + 1]]));
293            offset += 2;
294        }
295    }
296
297    let payload = data.slice(offset..);
298
299    Ok(Npdu {
300        is_network_message,
301        expecting_reply,
302        priority,
303        destination,
304        source,
305        hop_count,
306        message_type,
307        vendor_id,
308        payload,
309    })
310}
311
312// ---------------------------------------------------------------------------
313// Tests
314// ---------------------------------------------------------------------------
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn encode_to_vec(npdu: &Npdu) -> Vec<u8> {
321        let mut buf = BytesMut::with_capacity(64);
322        encode_npdu(&mut buf, npdu).unwrap();
323        buf.to_vec()
324    }
325
326    #[test]
327    fn minimal_local_apdu() {
328        // Simplest case: local, no routing, with APDU payload
329        let npdu = Npdu {
330            payload: Bytes::from_static(&[0x10, 0x08]), // UnconfirmedRequest WhoIs
331            ..Default::default()
332        };
333        let encoded = encode_to_vec(&npdu);
334        // version=1, control=0x00 (normal priority, no flags), payload
335        assert_eq!(encoded, vec![0x01, 0x00, 0x10, 0x08]);
336
337        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
338        assert_eq!(decoded, npdu);
339    }
340
341    #[test]
342    fn expecting_reply_flag() {
343        let npdu = Npdu {
344            expecting_reply: true,
345            payload: Bytes::from_static(&[0xAA]),
346            ..Default::default()
347        };
348        let encoded = encode_to_vec(&npdu);
349        assert_eq!(encoded[1], 0x04); // control: expecting_reply bit
350        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
351        assert!(decoded.expecting_reply);
352    }
353
354    #[test]
355    fn priority_encoding() {
356        for (prio, expected_bits) in [
357            (NetworkPriority::NORMAL, 0x00),
358            (NetworkPriority::URGENT, 0x01),
359            (NetworkPriority::CRITICAL_EQUIPMENT, 0x02),
360            (NetworkPriority::LIFE_SAFETY, 0x03),
361        ] {
362            let npdu = Npdu {
363                priority: prio,
364                payload: Bytes::new(),
365                ..Default::default()
366            };
367            let encoded = encode_to_vec(&npdu);
368            assert_eq!(encoded[1] & 0x03, expected_bits);
369            let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
370            assert_eq!(decoded.priority, prio);
371        }
372    }
373
374    #[test]
375    fn destination_only_round_trip() {
376        let npdu = Npdu {
377            destination: Some(NpduAddress {
378                network: 1000,
379                mac_address: MacAddr::from_slice(&[0x0A, 0x00, 0x01, 0x01, 0xBA, 0xC0]),
380            }),
381            hop_count: 254,
382            payload: Bytes::from_static(&[0x10, 0x08]),
383            ..Default::default()
384        };
385        let encoded = encode_to_vec(&npdu);
386        // control: has_destination = 0x20
387        assert_eq!(encoded[1] & 0x20, 0x20);
388        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
389        assert_eq!(decoded, npdu);
390    }
391
392    #[test]
393    fn destination_broadcast() {
394        // Global broadcast: DNET=0xFFFF, DLEN=0 (no DADR)
395        let npdu = Npdu {
396            destination: Some(NpduAddress {
397                network: 0xFFFF,
398                mac_address: MacAddr::new(),
399            }),
400            hop_count: 255,
401            payload: Bytes::from_static(&[0x10, 0x08]),
402            ..Default::default()
403        };
404        let encoded = encode_to_vec(&npdu);
405        // version(1) + control(1) + DNET(2) + DLEN(1) + hop_count(1) + payload(2)
406        assert_eq!(encoded.len(), 8);
407        // DNET = 0xFFFF
408        assert_eq!(&encoded[2..4], &[0xFF, 0xFF]);
409        // DLEN = 0
410        assert_eq!(encoded[4], 0);
411
412        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
413        assert_eq!(decoded, npdu);
414    }
415
416    #[test]
417    fn source_only_round_trip() {
418        let npdu = Npdu {
419            source: Some(NpduAddress {
420                network: 500,
421                mac_address: MacAddr::from_slice(&[0x01]),
422            }),
423            payload: Bytes::from_static(&[0x30, 0x01, 0x0C]),
424            ..Default::default()
425        };
426        let encoded = encode_to_vec(&npdu);
427        // control: has_source = 0x08
428        assert_eq!(encoded[1] & 0x08, 0x08);
429        // No hop count (no destination)
430        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
431        assert_eq!(decoded, npdu);
432    }
433
434    #[test]
435    fn source_and_destination_round_trip() {
436        let npdu = Npdu {
437            expecting_reply: true,
438            destination: Some(NpduAddress {
439                network: 2000,
440                mac_address: MacAddr::from_slice(&[0x0A, 0x00, 0x02, 0x01, 0xBA, 0xC0]),
441            }),
442            source: Some(NpduAddress {
443                network: 1000,
444                mac_address: MacAddr::from_slice(&[0x0A, 0x00, 0x01, 0x01, 0xBA, 0xC0]),
445            }),
446            hop_count: 250,
447            payload: Bytes::from_static(&[0x00, 0x05, 0x01, 0x0C]),
448            ..Default::default()
449        };
450        let encoded = encode_to_vec(&npdu);
451        // control: destination(0x20) | source(0x08) | expecting_reply(0x04) = 0x2C
452        assert_eq!(encoded[1], 0x2C);
453        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
454        assert_eq!(decoded, npdu);
455    }
456
457    #[test]
458    fn network_message_round_trip() {
459        let npdu = Npdu {
460            is_network_message: true,
461            message_type: Some(0x01), // I-Am-Router-To-Network
462            payload: Bytes::from_static(&[0x03, 0xE8]), // network 1000
463            ..Default::default()
464        };
465        let encoded = encode_to_vec(&npdu);
466        // control: network_message = 0x80
467        assert_eq!(encoded[1] & 0x80, 0x80);
468        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
469        assert_eq!(decoded, npdu);
470    }
471
472    #[test]
473    fn proprietary_network_message_round_trip() {
474        let npdu = Npdu {
475            is_network_message: true,
476            message_type: Some(0x80), // Proprietary range
477            vendor_id: Some(999),
478            payload: Bytes::from_static(&[0xDE, 0xAD]),
479            ..Default::default()
480        };
481        let encoded = encode_to_vec(&npdu);
482        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
483        assert_eq!(decoded, npdu);
484    }
485
486    #[test]
487    fn wire_format_who_is_broadcast() {
488        // Real-world WhoIs global broadcast:
489        // Version=1, Control=0x20 (dest present), DNET=0xFFFF, DLEN=0,
490        // HopCount=255, APDU=[0x10, 0x08]
491        let wire = [0x01, 0x20, 0xFF, 0xFF, 0x00, 0xFF, 0x10, 0x08];
492        let decoded = decode_npdu(Bytes::copy_from_slice(&wire)).unwrap();
493        assert!(!decoded.is_network_message);
494        assert!(!decoded.expecting_reply);
495        assert_eq!(decoded.priority, NetworkPriority::NORMAL);
496        assert_eq!(
497            decoded.destination,
498            Some(NpduAddress {
499                network: 0xFFFF,
500                mac_address: MacAddr::new(),
501            })
502        );
503        assert!(decoded.source.is_none());
504        assert_eq!(decoded.hop_count, 255);
505        assert_eq!(decoded.payload, vec![0x10, 0x08]);
506
507        // Re-encode should match
508        let reencoded = encode_to_vec(&decoded);
509        assert_eq!(reencoded, wire);
510    }
511
512    #[test]
513    fn decode_too_short() {
514        assert!(decode_npdu(Bytes::new()).is_err());
515        assert!(decode_npdu(Bytes::from_static(&[0x01])).is_err());
516    }
517
518    #[test]
519    fn decode_wrong_version() {
520        assert!(decode_npdu(Bytes::from_static(&[0x02, 0x00])).is_err());
521    }
522
523    #[test]
524    fn decode_truncated_destination() {
525        // Has destination flag but not enough bytes
526        assert!(decode_npdu(Bytes::from_static(&[0x01, 0x20, 0xFF])).is_err());
527    }
528
529    #[test]
530    fn decode_truncated_source() {
531        // Has source flag but not enough bytes after destination
532        assert!(decode_npdu(Bytes::from_static(&[0x01, 0x08, 0x00])).is_err());
533    }
534
535    // --- NPDU edge case tests ---
536
537    #[test]
538    fn npdu_network_zero() {
539        // DNET=0 is invalid per Clause 6.2.2 — rejected at encode time
540        let npdu = Npdu {
541            destination: Some(NpduAddress {
542                network: 0,
543                mac_address: MacAddr::from_slice(&[0x01]),
544            }),
545            hop_count: 255,
546            payload: Bytes::from_static(&[0x10, 0x08]),
547            ..Default::default()
548        };
549        let mut buf = BytesMut::new();
550        let result = encode_npdu(&mut buf, &npdu);
551        assert!(result.is_err());
552        let err = format!("{}", result.unwrap_err());
553        assert!(err.contains("DNET"), "got: {err}");
554    }
555
556    #[test]
557    fn npdu_network_fffe() {
558        // 0xFFFE is the max non-broadcast network number
559        // (0xFFFF is broadcast, 0xFFFE is the largest valid unicast network)
560        let npdu = Npdu {
561            destination: Some(NpduAddress {
562                network: 0xFFFE,
563                mac_address: MacAddr::from_slice(&[0x01, 0x02]),
564            }),
565            hop_count: 200,
566            payload: Bytes::from_static(&[0xAA]),
567            ..Default::default()
568        };
569        let encoded = encode_to_vec(&npdu);
570        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
571        assert_eq!(decoded.destination.as_ref().unwrap().network, 0xFFFE);
572        assert_eq!(decoded.hop_count, 200);
573    }
574
575    #[test]
576    fn npdu_hop_count_zero() {
577        // Hop count 0 is valid (means don't forward further)
578        let npdu = Npdu {
579            destination: Some(NpduAddress {
580                network: 1000,
581                mac_address: MacAddr::new(),
582            }),
583            hop_count: 0,
584            payload: Bytes::from_static(&[0x10, 0x08]),
585            ..Default::default()
586        };
587        let encoded = encode_to_vec(&npdu);
588        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
589        assert_eq!(decoded.hop_count, 0);
590    }
591
592    #[test]
593    fn npdu_source_with_empty_mac() {
594        // SLEN=0 is invalid for source per Clause 6.2.2 — rejected at encode time
595        let npdu = Npdu {
596            source: Some(NpduAddress {
597                network: 500,
598                mac_address: MacAddr::new(),
599            }),
600            payload: Bytes::from_static(&[0xBB]),
601            ..Default::default()
602        };
603        let mut buf = BytesMut::new();
604        let result = encode_npdu(&mut buf, &npdu);
605        assert!(result.is_err());
606        let err = format!("{}", result.unwrap_err());
607        assert!(err.contains("SLEN"), "got: {err}");
608    }
609
610    #[test]
611    fn npdu_destination_dlen_zero_broadcast_accepted() {
612        // DLEN=0 is valid for destination (broadcast) per Clause 6.2.2
613        let npdu = Npdu {
614            destination: Some(NpduAddress {
615                network: 0xFFFF,
616                mac_address: MacAddr::new(),
617            }),
618            hop_count: 255,
619            payload: Bytes::from_static(&[0x10, 0x08]),
620            ..Default::default()
621        };
622        let encoded = encode_to_vec(&npdu);
623        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
624        assert_eq!(decoded.destination.as_ref().unwrap().network, 0xFFFF);
625        assert!(decoded.destination.as_ref().unwrap().mac_address.is_empty());
626    }
627
628    #[test]
629    fn npdu_destination_truncated_mac() {
630        // DNET + DLEN present but MAC bytes are short
631        // Version=1, Control=0x20 (dest present), DNET=1000, DLEN=6, only 2 MAC bytes
632        let data = [0x01, 0x20, 0x03, 0xE8, 0x06, 0x01, 0x02];
633        assert!(decode_npdu(Bytes::copy_from_slice(&data)).is_err());
634    }
635
636    #[test]
637    fn npdu_source_truncated_mac() {
638        // Source present but MAC bytes truncated
639        let data = [0x01, 0x08, 0x01, 0xF4, 0x04, 0x01]; // SNET=500, SLEN=4, only 1 byte
640        assert!(decode_npdu(Bytes::copy_from_slice(&data)).is_err());
641    }
642
643    #[test]
644    fn npdu_missing_hop_count() {
645        // Destination present but data ends before hop count
646        // Version=1, Control=0x20, DNET=0xFFFF, DLEN=0
647        let data = [0x01, 0x20, 0xFF, 0xFF, 0x00];
648        assert!(decode_npdu(Bytes::copy_from_slice(&data)).is_err());
649    }
650
651    #[test]
652    fn npdu_network_message_truncated_type() {
653        // Network message flag set but no message type byte
654        let data = [0x01, 0x80]; // is_network_message = true, but no type byte
655        assert!(decode_npdu(Bytes::copy_from_slice(&data)).is_err());
656    }
657
658    #[test]
659    fn npdu_proprietary_message_truncated_vendor() {
660        // Proprietary message type (>=0x80) but vendor ID missing
661        let data = [0x01, 0x80, 0x80]; // msg_type=0x80, need 2 more bytes for vendor
662        assert!(decode_npdu(Bytes::copy_from_slice(&data)).is_err());
663    }
664
665    #[test]
666    fn npdu_all_flags_round_trip() {
667        // Maximum complexity: all flags set
668        let npdu = Npdu {
669            is_network_message: false,
670            expecting_reply: true,
671            priority: NetworkPriority::LIFE_SAFETY,
672            destination: Some(NpduAddress {
673                network: 2000,
674                mac_address: MacAddr::from_slice(&[0x0A, 0x00, 0x02, 0x01, 0xBA, 0xC0]),
675            }),
676            source: Some(NpduAddress {
677                network: 1000,
678                mac_address: MacAddr::from_slice(&[0x0A, 0x00, 0x01, 0x01, 0xBA, 0xC0]),
679            }),
680            hop_count: 127,
681            payload: Bytes::from_static(&[0xDE, 0xAD, 0xBE, 0xEF]),
682            ..Default::default()
683        };
684        let encoded = encode_to_vec(&npdu);
685        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
686        assert_eq!(decoded, npdu);
687    }
688
689    #[test]
690    fn npdu_empty_payload() {
691        // No payload at all
692        let npdu = Npdu {
693            payload: Bytes::new(),
694            ..Default::default()
695        };
696        let encoded = encode_to_vec(&npdu);
697        let decoded = decode_npdu(Bytes::from(encoded)).unwrap();
698        assert!(decoded.payload.is_empty());
699    }
700
701    #[test]
702    fn reject_snet_zero() {
703        // Source network 0 is invalid per Clause 6.2.2 — rejected at encode time
704        let npdu = Npdu {
705            source: Some(NpduAddress {
706                network: 0,
707                mac_address: MacAddr::from_slice(&[0x01]),
708            }),
709            payload: Bytes::from_static(&[0x10, 0x08]),
710            ..Default::default()
711        };
712        let mut buf = BytesMut::new();
713        let result = encode_npdu(&mut buf, &npdu);
714        assert!(result.is_err());
715        let err = format!("{}", result.unwrap_err());
716        assert!(err.contains("SNET"), "got: {err}");
717    }
718
719    #[test]
720    fn reserved_bits_warning_still_decodes() {
721        // Reserved bits set in control byte should NOT cause decode failure
722        // (warning only). Construct wire bytes manually with reserved bit 6 set.
723        let mut data = vec![0x01, 0x40]; // version=1, control with reserved bit 6
724        data.extend_from_slice(&[0x10, 0x08]);
725
726        // Should decode successfully (warning only, not error)
727        let result = decode_npdu(Bytes::copy_from_slice(&data));
728        assert!(
729            result.is_ok(),
730            "reserved bits should not cause decode failure"
731        );
732    }
733}