Skip to content

fix: segmented response conformance and routed I-Am replies#198

Merged
gralin merged 4 commits into
masterfrom
fix/segmentation-size-and-routed-iam
Jul 4, 2026
Merged

fix: segmented response conformance and routed I-Am replies#198
gralin merged 4 commits into
masterfrom
fix/segmentation-size-and-routed-iam

Conversation

@gralin

@gralin gralin commented Jul 4, 2026

Copy link
Copy Markdown
Member

Four conformance/robustness fixes in the segmented-response encoder, I-Am addressing and the BACnet/IP transport, found while interop-testing this library against bacnet-stack 1.5.0 through a BACnet router (two isolated BACnet/IP networks, DNET 1 ↔ DNET 2).

Changes

  1. Segment sizing (fix): the segment encoder opened its window at offset + GetMaxApdu() regardless of the physical transport buffer (default maxPayload: 1472 vs the 1476-octet B/IP APDU), throwing IndexOutOfRangeException on the first segmented response — and sized segments only by the local transport limit, ignoring the requester's max-APDU-length-accepted. Per ASHRAE 135 §5.2.1.2 the APDU shall be the smallest of the local limit (a), what the internetwork conveys (b) and what the peer accepts (c); for ComplexACKs (c) comes from the request header. Segments now honor all of it; the requester value is captured automatically when GetSegmentBuffer(maxSegments) is called inside a request event handler (thread-static, so existing handlers become conformant with zero changes), or passed explicitly via the new GetSegmentBuffer(maxSegments, requesterMaxAdpu) overload. EncodeBuffer additionally reports NotEnoughBuffer instead of writing out of bounds.
  2. Reserved bits (fix): segmented ComplexACKs carried the SERVER flag in the PDU type octet. §20.1.5: reserved [3] Unsigned (0..3) -- shall be set to zero; the server flag exists only in SegmentACK PDUs. YABE and bacnet-stack both encode these bits as zero. (Checked all published addenda through 135-2024cu — the bits remain unclaimed, so clearing them is also forward-compatible.)
  3. Routed I-Am (fix): Iam() used the receiver address as both NPDU destination and datalink destination, so a reply to a Who-Is forwarded by a router died at the router. §16.10.4: the I-Am "shall be sent in such a manner that the BACnet-user that sent the Who-Is will receive the resulting I-Am". With a RoutedSource on the receiver, the I-Am is now NPDU-addressed to the original source and sent back through the router — the same behaviour as bacnet-stack's BTL-tested Send_I_Am_Unicast.
  4. MTU-safe max APDU (new): BacnetIpUdpProtocolTransport gets a maxApdu constructor parameter. The 1476 default predates UDP/IP overhead, so full-size frames exceed a 1500-byte MTU and rely on IP fragmentation; MAX_APDU1024 keeps every frame under the MTU. MaxAdpuLength drives both the I-Am advertisement and segment sizing, so the endpoint stays self-consistent.

Verification

Unit tests: 8 new (EncodeBufferTests, BacnetClientSegmentationTests with a full segmented round-trip over an in-memory transport, BacnetClientIamTests). Each commit builds and passes the suite independently (128/128 → 131/131).

Container before/after matrix — executed against the branch tip (dotnet pack of commit 0d34744 → MinVer 4.0.0-beta.1.14) vs the published 4.0.0-beta.1 from nuget.org. The library version under test is printed by the device on startup in every run.

flowchart LR
    C["client container<br/>bacnet-stack 1.5.0<br/>bacrpr: ReadProperty client<br/>+ segment reassembly"]
    R["router container<br/>bacnet-stack 1.5.0 router app<br/>172.28.1.10 / 172.28.2.10"]
    D["device container<br/>BACnet .NET, this PR vs beta.1<br/>device 1234, 800 analog-values"]
    C <-->|"bacnet-net1 bridge<br/>172.28.1.0/24<br/>BACnet DNET 1, udp/47808"| R
    R <-->|"bacnet-net2 bridge<br/>172.28.2.0/24<br/>BACnet DNET 2, udp/47808"| D
Loading

The two Docker bridge networks are isolated from each other, so the only path between client and device is the BACnet router container — the tests cannot pass via direct IP.

Fix Test 4.0.0-beta.1 (before) 4.0.0-beta.1.14 = this PR (after)
1 object-list read (~4 kB), default transport IndexOutOfRangeException, client gets BACnet Error: services: other ✅ 3 segments (1451 B, buffer-clamped), value decoded
1 client advertises max-APDU 480 ❌ segments of 1471 B ✅ 9 segments of ≤475 B
2 type octet of every segment 0x3D / 0x39 (reserved bit set) 0x3C / 0x38
4 router IP-fragmentation counters across a read ReasmReqds +4, FragCreates +4 (with the old maxPayload: 1600 workaround) maxApdu: 1024 → 4 segments ≤1019 B, all counters +0
3 Who-Is via router → I-Am → read APDU Timeout! (I-Am dies at the router) ✅ binds through the router, reads the value

Note: the before-rows for fixes 1 (480), 2 and 4 required the old maxPayload: 1600 workaround so beta.1 could segment at all — on beta.1 one must choose between crashing (fix 1) and IP fragmentation (fix 4).

Raw console output — before (published 4.0.0-beta.1)
Transport: maxPayload=1472, maxApdu=MAX_APDU1476
BACnet device 1234 (NuGet BACnet 4.0.0-beta.1) listening on udp/47808

# object-list, default transport
Sending ReadProperty device 1234 object-list (segmented response accepted, max 16 segments, max APDU 1476) ...
BACnet Error: services: other
device log: ReadProperty failed: Index was outside the bounds of the array.

# dynamic discovery via router
Using dynamic binding: broadcasting Who-Is for device 1234.
Error: APDU Timeout!
device log: Who-Is [1234..1234] received, I-Am sent - never crosses the router

# --max-apdu 480 (device restarted with TRANSPORT_MAX_PAYLOAD=1600 workaround)
Transport: maxPayload=1600, maxApdu=MAX_APDU1476
Sending ReadProperty device 1234 object-list (segmented response accepted, max 16 segments, max APDU 480) ...
Segment 0 received (1471 bytes, type octet 0x3D)
Segment 1 received (1471 bytes, type octet 0x3D)
Segment 2 received (1072 bytes, type octet 0x39) - last
Reassembled 3 segments, 4014 bytes of service data.
router IP frag counters delta: ReasmReqds +4  ReasmOKs +2  FragCreates +4
Raw console output — after (this PR, 4.0.0-beta.1.14)
Transport: maxPayload=1472, maxApdu=MAX_APDU1476
BACnet device 1234 (NuGet BACnet 4.0.0-beta.1.14) listening on udp/47808

# object-list, default transport (no workaround)
Segment 0 received (1451 bytes, type octet 0x3C)
Segment 1 received (1451 bytes, type octet 0x3C)
Segment 2 received (1112 bytes, type octet 0x38) - last
Reassembled 3 segments, 4014 bytes of service data.
Success: value read through the BACnet router using 3-segment response.

# --max-apdu 480
Segment 0..7 received (475 bytes each, type octet 0x3C)
Segment 8 received (214 bytes, type octet 0x38) - last
Reassembled 9 segments, 4014 bytes of service data.
Success: value read through the BACnet router using 9-segment response.

# dynamic discovery via router
Using dynamic binding: broadcasting Who-Is for device 1234.
Bound to device 1234: next-hop 172.28.1.10:47808, DNET 2, DADR 172.28.2.20:47808 (reached through a BACnet router)
"BACnet interop lab device"
Success: value read through the BACnet router.

# maxApdu: 1024 via the new ctor parameter
Transport: maxPayload=1472, maxApdu=MAX_APDU1024
Segment 0 received (1019 bytes, type octet 0x3C)
Segment 1 received (1019 bytes, type octet 0x3C)
Segment 2 received (1019 bytes, type octet 0x3C)
Segment 3 received (957 bytes, type octet 0x38) - last
Reassembled 4 segments, 4014 bytes of service data.
router IP frag counters delta: ReasmReqds +0  ReasmOKs +0  FragCreates +0

Also compared against YABE trunk (r642) and downstream forks: none solve these; YABE's unicast reply pattern and bacnet-stack's Send_I_Am_Unicast ("must direct back to src->net to meet BTL tests") match the approach taken here.

Notes for review

  • The thread-static capture in ProcessConfirmedServiceRequest covers the universal synchronous-handler pattern; handlers that respond asynchronously should use the explicit overload. The alternative — adding maxAdpu to every per-service delegate — would break every handler signature.
  • With default settings, segment payloads shrink slightly (1451 vs 1476 content bytes) because the encoder now respects the 1472-byte buffer instead of crashing.
  • Follow-up candidates (not in this PR): apply the routed-destination treatment to other unconfirmed senders (WhoIs, IHave, …); consult the peer's Max_APDU_Length_Accepted Device property for >1476 APDUs if BACnet/SC (Addendum 135-2016bj) support is ever added.

gralin added 4 commits July 4, 2026 10:58
…mented responses

The segment encoder opened its encode window at offset + GetMaxApdu()
regardless of the physical transport buffer (default maxPayload 1472 vs
the 1476-octet B/IP APDU), throwing IndexOutOfRangeException on the
first segmented response. It also sized segments only by the local
transport limit, ignoring the requester max-APDU-length-accepted that
ASHRAE 135 5.2.1.2 requires a responder to honor.

Segments are now sized to the smallest of the transport limit, the
physical buffer (5.2.1.2 (a), a local matter) and the requester max-APDU
(5.2.1.2 (c)). The requester value is captured automatically when
GetSegmentBuffer is called inside a request event handler, or can be
passed explicitly via the new GetSegmentBuffer(maxSegments,
requesterMaxAdpu) overload. EncodeBuffer flags NotEnoughBuffer instead
of writing past the end of a fixed buffer.
Bits 1-0 of the ComplexACK type octet are reserved and shall be set to
zero (ASHRAE 135 20.1.5); the server flag exists only in SegmentACK
PDUs. Strict peers could discard segmented responses carrying the stray
bit. This aligns the wire format with YABE and bacnet-stack.
Iam() used the receiver address as both the NPDU destination and the
datalink destination, so a reply to a Who-Is forwarded by a BACnet
router was delivered to the router itself with no routing information
and never reached the requester. ASHRAE 135 16.10.4 requires the I-Am
response to be sent such that the originator of the Who-Is receives it.

When the receiver address carries a RoutedSource (SNET/SADR of the
forwarded request), the I-Am is now NPDU-addressed to that original
source network/address and the frame goes back through the router that
forwarded the request - the same behaviour as the reference bacnet-stack
implementation (Send_I_Am_Unicast, "must direct back to src->net to
meet BTL tests").
BacnetIpUdpProtocolTransport accepts a maxApdu constructor parameter to
lower the advertised and sent APDU size. The 1476-octet default predates
UDP/IP overhead, so full-size frames exceed a 1500-byte MTU and rely on
IP fragmentation; MAX_APDU1024 keeps every frame, including segmented
responses, below the MTU (ASHRAE 135 5.2.1.2 (b): the APDU is limited by
what the internetwork can convey). MaxAdpuLength drives both what I-Am
advertises and how responses are segmented, so the endpoint stays
self-consistent.
@gralin gralin merged commit 255fabe into master Jul 4, 2026
4 checks passed
@gralin gralin deleted the fix/segmentation-size-and-routed-iam branch July 4, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant