docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s

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
This commit is contained in:
Joseph Doherty
2026-07-15 16:40:36 -04:00
parent 37cdbef7a5
commit 8fc147d8d4
17 changed files with 6408 additions and 0 deletions
+356
View File
@@ -0,0 +1,356 @@
# 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>)