Files
lmxopcua/docs/research/drivers/bacnet-ip.md
Joseph Doherty 8fc147d8d4
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s
docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
Adds the driver-expansion program design (umbrella: universal Discover-backed
browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU;
MELSEC deferred) plus the per-driver research reports.

All docs went through a 7-agent parallel review against the codebase before
this commit. Highlights fixed in review:

- universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle
  + FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the
  program doc's SupportsOnlineDiscovery=false verdict)
- modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads ->
  linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak
  System.IO.Ports into Contracts
- bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live
  suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs)
- sql-poll: real tier registration via DriverFactoryRegistry.Register;
  blackhole gate must not docker-pause the shared central SQL Server
- mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted
- omron: host hardcodes isIdempotent:false today (retry seam unshipped);
  v1 scopes UDTs to dotted-leaf access
- mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern
- program doc: both valid enum-serialization patterns; IRediscoverable is
  change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
2026-07-15 16:40:36 -04:00

357 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BACnet/IP driver — research & implementation design
**Status:** Research / design proposal (not yet implemented)
**Author:** research sweep, 2026-07-15
**Scope:** A new **Equipment-kind** driver `ZB.MOM.WW.OtOpcUa.Driver.Bacnet` exposing
BACnet/IP facility/HVAC/energy data under the unified namespace, following the same shape as
Modbus / S7 / AbCip / OpcUaClient. BACnet is the closest analog to the **OpcUaClient** driver:
it has native **device discovery** and native **change-of-value push subscriptions**, so it is a
first-class *browseable* + *subscribe-capable* driver, not a bare poller.
---
## 1. Protocol summary + .NET library options
### 1.1 BACnet/IP in one screen
BACnet (ASHRAE Standard 135 / ISO 16484-5) is the dominant building-automation protocol
(HVAC, lighting, energy metering, access control). **BACnet/IP** carries BACnet over UDP,
default port **47808 (0xBAC0)**, with a layered framing:
- **BVLC** (BACnet Virtual Link Control) — the UDP-facing header. Distinguishes unicast,
broadcast, and the **BBMD / foreign-device** distribution messages (see §1.3).
- **NPDU** (Network layer) — source/destination network numbers + hop count; enables routing
across BACnet networks (e.g. an IP↔MS/TP router).
- **APDU** (Application layer) — the actual service request/response
(ReadProperty, WriteProperty, SubscribeCOV, Who-Is/I-Am, …), plus **segmentation** for
payloads larger than a single frame (a large `object-list` or `ReadPropertyMultiple` result
is segmented across several APDUs with windowed ack).
**Object model.** A BACnet device is a `Device` object plus a collection of **objects**, each
with a well-known **object-type** and an **object-instance** number. Common object types:
| Object type | Typical meaning | Present-Value type |
|---|---|---|
| Analog Input / Output / Value (AI/AO/AV) | sensor / setpoint / calc'd analog | REAL (float) |
| Binary Input / Output / Value (BI/BO/BV) | contact / relay / flag | enumerated (0/1, active/inactive) |
| Multistate Input / Output / Value (MSI/MSO/MSV) | discrete state (Off/Low/High) | unsigned int |
| Device | identity, object-list, vendor, APDU limits | — |
| Schedule, Calendar | time-based control | — |
| Trend Log, Trend Log Multiple | historical samples | — |
| Loop, Notification Class, File, … | control / infra | — |
Every object exposes **properties** addressed by a **property-identifier** (an enum). The ones
that matter for a tag driver:
- **`present-value` (PROP_PRESENT_VALUE, 85)** — the live value. The read/subscribe target.
- **`object-name` (77)**, **`description` (28)** — labels for browse.
- **`units` (117)** — an *Engineering-Units* enum (degrees-C, kW, percent, …) on analog objects.
- **`status-flags` (111)** — in-alarm / fault / overridden / out-of-service → maps to OPC UA quality.
- **`object-list` (76)** — on the Device object: the array of every object the device holds
(the enumeration backbone for discovery + browse).
- **`priority-array` (87)** + **`relinquish-default` (104)** — for commandable outputs; writes
target a priority slot (see §2 Write).
**Services used by a client/gateway:**
- **Who-Is / I-Am** — discovery. Client broadcasts `Who-Is` (optionally with a device-instance
low/high range); each device answers `I-Am` carrying its device-instance, max-APDU,
segmentation support, and vendor-id. *This is asynchronous and time-bounded* (broadcast, then
collect I-Ams over a window) — the key wrinkle for the browse seam (§4).
- **ReadProperty (RP)** — read one property of one object.
- **ReadPropertyMultiple (RPM)** — read many properties across many objects in one request; the
efficient bulk-read path (subject to segmentation for big results).
- **WriteProperty (WP) / WritePropertyMultiple** — write, usually `present-value` at a priority.
- **SubscribeCOV (Change-Of-Value)** — subscribe to an object; the device **pushes**
`COVNotification` when `present-value` (or `status-flags`) changes beyond the COV increment.
Subscriptions carry a **lifetime** (seconds) and must be **renewed** before expiry, or made
*confirmed* vs *unconfirmed*. `SubscribeCOVProperty` targets a specific property.
### 1.3 Cross-subnet: BBMD & foreign-device registration
UDP broadcasts (Who-Is, I-Am, unconfirmed-COV) don't cross IP routers. BACnet solves this with
**BBMDs** (BACnet Broadcast Management Devices): one BBMD per subnet forwards broadcasts to peer
BBMDs via a **Broadcast Distribution Table (BDT)**. A client on a subnet *without* a BBMD (or on
a different network, e.g. the OtOpcUa server VM) registers as a **Foreign Device** with a remote
BBMD (`Register-Foreign-Device`, with a TTL that must be renewed). Once registered, the BBMD
relays broadcasts to it. **For OtOpcUa this is the common case** — the server usually is not on
the same L2 segment as the controllers, so foreign-device registration to a site BBMD is the
realistic connect path. This is a first-class config knob (§3), not an afterthought.
### 1.2 .NET library options
| Library | Package / repo | License | Frameworks | Maturity | Verdict |
|---|---|---|---|---|---|
| **System.IO.BACnet (ela-compil fork)** | NuGet **`BACnet`** — [github.com/ela-compil/BACnet](https://github.com/ela-compil/BACnet) | **MIT** | net48, netstandard2.0, **net8.0, net10.0** | ~247★ / 45 releases / v4.0; core library used by **YABE** (Yet Another BACnet Explorer) | **RECOMMENDED** |
| bacnet-stack (C) | [github.com/bacnet-stack/bacnet-stack](https://github.com/bacnet-stack/bacnet-stack) | GPL/BSD (dual) | C, P/Invoke only | Reference impl; used by the docker sims | No — C interop, not a .NET API |
| BACsharp / gecambridge / sxul forks | various | MIT | older TFMs | Stale forks of the same SVN origin | No — ela-compil is the live one |
**Chosen library: `System.IO.BACnet` (ela-compil fork), NuGet `BACnet`, MIT.**
Why: it is the *actively maintained* fork of the canonical Morten Kvistgaard / F. Chaxel
`System.IO.BACnet` port of Steve Karg's bacnet-stack, **it explicitly multi-targets `net10.0`**
(so no TFM friction with this repo), MIT license fits, and it is the engine inside YABE so the
protocol coverage is battle-tested. It explicitly supports everything we need:
**Who-Is/I-Am, ReadProperty, ReadPropertyMultiple, WriteProperty, SubscribeCOV /
SubscribeCOVProperty + COV notifications, and BACnet/IP with BBMD + foreign-device registration**
(confirmed on the repo's feature list and in `ela-compil/BACnet.Examples`:
`BasicReadWrite`, `ObjectBrowseSample`, `BasicAdviseCOV`, `DemoBBMD`).
Honest caveats:
- The API is **event/callback-driven and largely synchronous-with-callbacks**, not
`async`/`Task`-first. `BacnetClient.WhoIs()` fires `OnIam` events; `ReadPropertyRequest` has a
blocking overload + a begin/end async pattern. The driver must **wrap it behind an async seam**
(`TaskCompletionSource` + timeout), exactly the way the OpcUaClient driver already wraps the
OPC UA SDK's session calls. Budget for a thin adapter layer.
- BACnet values come back as `BacnetValue` (a tagged union / `IList<BacnetValue>` for arrays);
the driver owns the BACnet-type → `DriverDataType` mapping.
- Transport is a single shared UDP socket (`BacnetIpUdpProtocolTransport`) per client — all
devices on the network multiplex over it. One `BacnetClient` per driver instance (per network
interface / BBMD), **not** one per device — mirrors "single Session per OpcUaClient driver."
---
## 2. Capability mapping (IDriver + capability interfaces)
The driver implements `IDriver` plus the capability interfaces it can honor. Recommended set:
`IDriver, ITagDiscovery, IReadable, ISubscribable, IWritable, IHostConnectivityProbe`
(and later `IHistoryProvider` via Trend Log, `IAlarmSource` via COV/event enrollment — phase 2+).
| OtOpcUa capability | BACnet mechanism |
|---|---|
| **`IDriver.InitializeAsync`** | Bind the UDP socket on the configured interface/port; if `ForeignDevice` configured, `Register-Foreign-Device` to the BBMD (+ start TTL-renew timer). Optionally fire an initial `Who-Is` to warm the device table. Health = Healthy once the socket is bound (BACnet has no "connection"; liveness is per-device via COV/read). |
| **`ITagDiscovery.DiscoverAsync`** | `Who-Is` (bounded collect window) → for each `I-Am`, read Device `object-list` (RPM, segmented) → for each object read `object-name` + `units` + `object-type` → register a variable node per (device, object[, property]). `RediscoverPolicy = Once` (like OpcUaClient); redeploy re-runs it. Note: for large sites, discovery is authored via the **browse picker** (§4) rather than a blind full-network discover — same as OpcUaClient where tags are usually hand-picked. |
| **`IReadable.ReadAsync`** | Group the batch's `FullReference`s by device; issue **ReadPropertyMultiple** per device for `present-value` (+ `status-flags` for quality). Fall back to per-object ReadProperty for devices that report no RPM support in their `I-Am`. Map BACnet result → `DataValueSnapshot` (value + StatusCode from status-flags + timestamp=now). |
| **`ISubscribable.SubscribeAsync`** | **SubscribeCOV** per object (the real win — native push). Register an `OnCOVNotification` handler that fans changes to `OnDataChange`. Maintain a **lifetime-renew timer** (re-subscribe before expiry). Devices that reject COV (return error) **degrade to a poll loop** at `publishingInterval` transparently — so the subscribe surface always works. |
| **`IWritable.WriteAsync`** | **WriteProperty** `present-value` at a configurable **priority** (116; default 8 "Manual Operator" or 16 "lowest"), or `null` to relinquish. Non-idempotent → surface `BadTimeout` on ambiguous outcome (mirror OpcUaClient's write-result honesty). **Verdict: ship read-only in v1**, add write in phase 2 (priority-array semantics + commandable-vs-non-commandable validation are a correctness minefield; see §6). |
| **`IHostConnectivityProbe`** | Periodic `Who-Is` to a specific device-instance (or a cheap RP of the Device object's `system-status`); Running↔Stopped transitions raise `OnHostStatusChanged`. For foreign-device mode, probe = "is the BBMD registration alive." |
| **`GetHealth` / `GetMemoryFootprint` / `FlushOptionalCachesAsync`** | Standard. Footprint ≈ discovered-object count × constant (mirror OpcUaClient). Flush drops the device/object browse cache. |
### 2.1 Data-type mapping (BACnet → DriverDataType)
`DriverDataType` members available: `Boolean, Int16/32/64, UInt16/32/64, Float32, Float64,
String, DateTime, Reference`.
| BACnet present-value / property type | `DriverDataType` | Notes |
|---|---|---|
| REAL (analog present-value) | `Float32` | AI/AO/AV |
| DOUBLE | `Float64` | |
| BOOLEAN | `Boolean` | |
| Enumerated: binary Active/Inactive (BI/BO/BV) | `Boolean` | 0=inactive,1=active |
| Enumerated: multistate (MSI/MSO/MSV) | `UInt16` (or `UInt32`) | present-value is 1-based state index; expose `state-text` array as metadata later |
| Unsigned Integer | `UInt32` | |
| Signed Integer | `Int32` | |
| CharacterString (`object-name`, `description`) | `String` | |
| BACnetDateTime / Date / Time | `DateTime` | Schedules, Trend Log timestamps |
| ObjectIdentifier / property references | `Reference` / `String` | rarely a tag target |
| Array property (element index N) | element type + `IsArray` | ValueRank=1 with ArrayDimensions |
**Engineering Units** (the BACnet `units` enum: `degrees-celsius`, `kilowatt-hours`, `percent`,
…) map to the OPC UA node's **EngineeringUnits** (EUInformation) — surface it as node metadata
(the address-space builder's variable metadata), captured at discovery/browse time so the UNS
node carries proper units. **`status-flags`** (in-alarm/fault/overridden/out-of-service) →
OPC UA StatusCode: `out-of-service` or `fault` ⇒ Bad/Uncertain quality; else Good.
---
## 3. TagConfig JSON shape
Two layers, matching every other Equipment-kind driver: a **driver-level config** (the
`DriverConfig` blob bound in `InitializeAsync`, analogous to `ModbusDriverOptions`) and a
**per-tag `TagConfig`** blob (authored on the `/uns` TagModal; the driver's equipment-tag parser
turns it into an addressing record — mirror `ModbusEquipmentTagParser`).
### 3.1 Driver-level config (`BacnetDriverOptions`)
```jsonc
{
"localEndpoint": "0.0.0.0", // interface to bind; "0.0.0.0" = all
"port": 47808, // 0xBAC0 default
"localDeviceInstance": 4194302, // our own Device instance id (must be unique on the net)
"foreignDevice": { // null when on-segment / a BBMD is local
"bbmdAddress": "10.20.0.1",
"bbmdPort": 47808,
"ttlSeconds": 900 // registration TTL; driver auto-renews at ~TTL/2
},
"apduTimeoutMs": 3000,
"apduRetries": 3,
"maxSegmentsAccepted": 16, // segmentation window we advertise
"discovery": {
"whoIsWindowMs": 4000, // collect I-Am for this long (the time-bounded wrinkle)
"deviceInstanceLow": 0, // optional Who-Is range filter
"deviceInstanceHigh": 4194303
},
"cov": {
"preferConfirmed": true, // confirmed vs unconfirmed COV
"lifetimeSeconds": 300, // per-subscription lifetime; renewed before expiry
"fallbackPollMs": 1000 // poll interval for devices that reject COV
},
"probe": { "enabled": true, "deviceInstance": 100, "intervalMs": 5000 }
}
```
### 3.2 Per-tag `TagConfig` (authored on the TagModal / emitted by the browse picker)
A BACnet tag address is fully described by **device-instance + object-type + object-instance +
property-id (+ optional array index)**. `present-value` is the default property.
```jsonc
{
"deviceInstance": 100, // the target device (from I-Am)
"objectType": "AnalogInput", // enum: AnalogInput/AnalogOutput/AnalogValue/
// BinaryInput/.../MultistateValue/...
"objectInstance": 3, // object-instance number
"propertyId": "PresentValue", // default; may be StatusFlags, Units, etc.
"arrayIndex": null, // non-null → element of an array property
"dataType": "Float32", // driver data-type hint (from browse-time units/type)
"writable": false, // v1: always false (read-only); v2: enables WriteProperty
"writePriority": 8 // v2 only: BACnet command priority 1..16
}
```
This blob is stored verbatim as the equipment tag's `TagConfig.FullName` reference; the driver's
`BacnetEquipmentTagParser.TryParse` (leading `{` ⇒ TagConfig blob, same convention as
`ModbusEquipmentTagParser`) turns it into the internal address record used for RP/RPM/COV keys.
The parser publishes values back keyed by the same reference string so the runtime forward-router
resolves them — identical to the Modbus contract.
---
## 4. BROWSEABILITY VERDICT + browse design
### VERDICT: **YES — strongly browseable.** BACnet is arguably *more* browseable than most
drivers: Who-Is enumerates devices, `object-list` enumerates a device's objects, and per-object
property reads give names/units/types. This is a first-class `IDriverBrowser`, on par with the
OpcUaClient browser.
New project **`ZB.MOM.WW.OtOpcUa.Driver.Bacnet.Browser`**, mirroring
`ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser`: one `IDriverBrowser` (`DriverType = "Bacnet"`)
that `OpenAsync(configJson)` → binds a transient `BacnetClient` (+ BBMD reg if configured) and
returns a `BacnetBrowseSession : IBrowseSession`. Registered in
`AdminUI/EndpointRouteBuilderExtensions.cs` alongside the other two:
`services.AddSingleton<IDriverBrowser, BacnetDriverBrowser>();`.
Three-level tree mapped onto `RootAsync / ExpandAsync / AttributesAsync`:
| Seam | BACnet action | Returns |
|---|---|---|
| **`RootAsync`** | Broadcast `Who-Is` (range from config), **collect `I-Am` over `whoIsWindowMs`**, dedupe by device-instance | one `BrowseNode` per device (`NodeId = "dev:<instance>"`, `Kind=Folder`, `HasChildrenHint=true`, DisplayName = device `object-name` if a quick RP resolves, else `Device <instance>`) |
| **`ExpandAsync("dev:<instance>")`** | Read the device's `object-list` (RPM, segmented); optionally batch-read each object's `object-name` | one `BrowseNode` per object (`NodeId = "dev:<instance>/<objtype>:<objinst>"`, `Kind=Leaf` — an object commits to a tag, `DisplayName = object-name`) |
| **`AttributesAsync("dev:.../<objtype>:<objinst>")`** | RPM the object's key properties: `present-value, units, status-flags, description` (+ `state-text` for multistate) | `AttributeInfo[]` for the side-panel — surfaces the resolved `DriverDataType`, units, writability (present iff commandable object type), so the picker pre-fills the TagConfig |
**Commit mapping.** Selecting an object (a Leaf) + `present-value` → the picker emits the §3.2
TagConfig blob: `deviceInstance` from the device node, `objectType`/`objectInstance` from the
object node, `propertyId = "PresentValue"`, `dataType` from `AttributesAsync`. (Selecting a
non-default property is an advanced path — emit the chosen `propertyId`.)
### The time-bounded-discovery wrinkle
Unlike OPC UA's synchronous `Browse` request/response, **Who-Is is a broadcast + asynchronous
I-Am collection**: there is no "the response." The session handles this by making `RootAsync`
*block for `whoIsWindowMs`* while accumulating `OnIam` callbacks into a concurrent set, then
returning the snapshot. Design points:
- Cache the discovered device table on the session after the first `RootAsync`; a re-expand
doesn't re-broadcast. Offer a cheap "rescan" that re-broadcasts and merges (devices that
came online later appear). LastUsedUtc refresh + TTL reaper eviction as usual.
- The window is a **latency floor** on the first `RootAsync` (a few seconds). That's acceptable
for an interactive picker (show a spinner "discovering devices…"), and it matches the
`ConnectBudget` pattern the Galaxy browser already uses (30s connect budget). Bound it hard.
- Foreign-device mode: the transient browse client must register with the BBMD before Who-Is,
or no I-Ams cross the router — reuse the runtime driver's registration code in the browser
(same "mirror the runtime option shape" discipline the Galaxy browser follows).
- `object-list` on a big device can be large ⇒ **segmentation** must work in the browse client
(the ela-compil client handles segmented RPM; just don't cap `maxSegmentsAccepted` too low).
---
## 5. Test-fixture strategy
BACnet has good open simulators; all are UDP servers we can containerize on the shared docker
host (`10.100.0.35`) with a `project=lmxopcua` label, driven by `lmxopcua-fix up bacnet`:
1. **`bacnet-stack` demo server** ([bacnet-stack/bacnet-stack](https://github.com/bacnet-stack/bacnet-stack))
— the reference C stack's `bacserv` demo. Configurable device-instance + object set via env;
answers Who-Is/I-Am, RP/RPM, WP, and **SubscribeCOV**. The canonical target.
2. **`bacstack-compliance-docker`** ([fh1ch/bacstack-compliance-docker](https://github.com/fh1ch/bacstack-compliance-docker))
— a ready-made **BACnet server simulator as a Docker container** (DockerHub image), built on
bacnet-stack. Lowest-friction "just run a device."
3. **`bacnet-docker`** ([mnp/bacnet-docker](https://github.com/mnp/bacnet-docker) /
[desolat/bacnet-docker](https://github.com/desolat/bacnet-docker)) — docker-compose framework
to stand up **multiple** BACnet/IP servers + clients on one host; good for a **multi-device
Who-Is** discovery test and a **BBMD/foreign-device** topology test (put a BBMD + a device on
one compose network, register the driver as a foreign device).
4. **YABE** (Yet Another BACnet Explorer, Windows GUI) — manual cross-check that our reads/COV
match a known-good client (it uses the same ela-compil library, so it's an apples-to-apples
oracle). Also a good source of protocol-behavior sanity.
5. **ela-compil `BACnet.Examples/BasicServer`** — a pure-.NET device sim we can embed directly in
the integration test process (no docker), useful for deterministic COV-notification unit/
integration tests without UDP-on-CI flakiness. Pair with `BasicAdviseCOV` as the client oracle.
**Gotcha — UDP + broadcast on docker/CI:** BACnet relies on subnet **broadcast** for Who-Is;
bridged docker networks and macOS-hosted CI don't broadcast cleanly. Two mitigations: (a) run
BACnet fixtures on the Linux docker host with `network_mode: host` or a dedicated bridge and test
from a peer on that host (the `10.100.0.35` pattern), and (b) for hermetic unit tests use the
embedded `BasicServer` + **directed (unicast) Who-Is to a known instance/address**, sidestepping
broadcast entirely. Expect an **env-gated live-integration suite** (like the historian/S7 live
gates) rather than pure in-proc for the discovery/COV/BBMD legs.
---
## 6. Effort / risk / phasing
**Overall effort: Medium-Large.** Bigger than Modbus (Modbus has no discovery, no push), roughly
on par with or slightly above the OpcUaClient driver, because of the async-wrapping of a
callback-style library + the COV lifetime/renewal machinery + BBMD.
Suggested phasing (each phase independently shippable, mirroring how OpcUaClient landed across PRs):
- **Phase 1 — Connect + Read + Discover + Browse (read-only).** `BacnetDriverOptions`,
`BacnetDriver : IDriver, ITagDiscovery, IReadable, IHostConnectivityProbe`; the async adapter
over `BacnetClient`; RP/RPM read path; Who-Is/object-list discovery; the
`Bacnet.Browser` project + AdminUI registration; typed tag editor (`TagConfigEditorMap` +
`TagConfigValidator`) or start on the raw-JSON fallback. **Foreign-device/BBMD included here**
because without it the driver can't reach most real sites. *This is the MVP and delivers real value.*
- **Phase 2 — SubscribeCOV push.** `ISubscribable` via SubscribeCOV + lifetime-renew timer +
poll fallback for non-COV devices. The headline feature over poll-only drivers.
- **Phase 3 — Write.** `IWritable` via WriteProperty with priority-array semantics + commandable
validation. Deferred deliberately (see risk below).
- **Phase 4 — History / Alarms.** `IHistoryProvider` over **Trend Log** objects
(`ReadRange`); `IAlarmSource` over intrinsic/algorithmic **event enrollment** + COV of
`status-flags`. Nice-to-have; large surface.
### Top risks
1. **BBMD / foreign-device registration + UDP broadcast reachability (HIGH).** The single most
likely thing to make it "work on my bench, fail at the customer." The server is usually not on
the controllers' L2 segment, so correct foreign-device registration (and its TTL renewal) is
load-bearing, and it's exactly what's hardest to reproduce in docker/CI. Mitigation: build BBMD
in from Phase 1, test it explicitly with the `bacnet-docker` multi-network compose, and gate a
live suite against a real site BBMD (the established env-gated live-gate pattern).
2. **COV lifetime/renewal + non-COV device fallback (HIGH).** Subscriptions silently expire if
not renewed; some devices cap concurrent COV subscriptions or don't support COV at all
(must degrade to polling without the operator noticing a gap). Getting the renewal timer,
the confirmed-vs-unconfirmed choice, and the transparent poll fallback right is subtle and
won't be caught by unit tests — needs the live/sim COV soak (mirror the continuous-historization
live-gate discipline).
Secondary risks: the ela-compil library's **callback/blocking API** needs a careful async wrapper
(TCS + timeout + cancellation) to fit the driver's `async` seams; **write priority-array**
semantics (why Phase 3 is deferred — writing the wrong priority can fight the BMS's own control
logic, a safety concern); and **segmentation** for large `object-list`/RPM results (the library
handles it, but `maxSegmentsAccepted` must not be set too low).
---
## Sources
- ela-compil/BACnet (System.IO.BACnet, MIT, net10.0): <https://github.com/ela-compil/BACnet>
- ela-compil/BACnet.Examples (WhoIs/OnIam, RPM, SubscribeCOV, BBMD, ObjectBrowse):
<https://github.com/ela-compil/BACnet.Examples>
- bacnet-stack (reference C stack + demo server): <https://github.com/bacnet-stack/bacnet-stack>
- fh1ch/bacstack-compliance-docker (BACnet device sim container): <https://github.com/fh1ch/bacstack-compliance-docker>
- mnp/bacnet-docker (multi-device compose rig): <https://github.com/mnp/bacnet-docker>
- BACnet protocol / object model / COV overviews: Chipkin (<https://docs.chipkin.com/protocols/bacnet/>),
Actility (<https://www.actility.com/what-is-the-bacnet-object-model/>),
Software Toolbox (<https://softwaretoolbox.com/resources/what-is-bacnet>)