Files
lmxopcua/docs/plans/2026-07-15-bacnet-ip-driver-design.md
T
Joseph Doherty fd0bec4ffe
v2-ci / build (push) Successful in 3m30s
v2-ci / unit-tests (push) Failing after 8m53s
docs(driver-expansion): resolve all open design concerns from the review pass
Every concern the 7-agent review parked is now decided and integrated:

- universal browser: in-flight capture coalescing keyed (driverType,
  config-hash) + global cap of 4 concurrent captures; CanBrowse catches a
  throwing TryCreate and defensively shuts down the throwaway instance;
  cleanup ShutdownAsync bounded at 10s (R2-01); picker Refresh = close +
  re-capture. Coalesced sessions share an immutable tree, so DisposeAsync
  drops the session's reference, not the tree.
- mtconnect: UNAVAILABLE pinned to BadNoCommunication (fleet's
  BadCommunicationError stays reserved for the driver's own transport
  failures); TIME_SERIES sampleCount flows into ArrayDim; Subscribe timeout
  bounds only the stream-start handshake; library version/TFM folded into
  the license checklist.
- mqtt: browse rebirth is an explicit DriverOperator-gated "Request rebirth"
  button (RequestRebirthAsync(scope)) - never fired by open/root/expand;
  hand-rolled reconnect loop committed for P1 (MQTTnet v5 dropped
  ManagedMqttClient); Wave-2-start library re-verification checkbox;
  implement from the Sparkplug v3.0 spec text.
- bacnet: P1 opens with a first-spike checklist (package TFM, BBMD/
  segmented-RPM/COV API smoke, unicast-I-Am fixture behavior); AdminUI-node
  browse registers a second foreign device - BBMD FD-table sizing note.
- sql-poll: split-node browse needs env parity for Sql__ConnectionStrings__
  refs on admin nodes (actionable error + session-only pasted literal);
  one-row-per-key query contract (last-wins + rate-limited warning);
  absent key -> BadNoData; operationTimeout > commandTimeout authoring rule;
  Browser->Driver.Sql SqlClient transitive on AdminUI accepted on record.
- omron: FINS framer gated on W227 + live golden vectors; CIP string layout
  is the first P1 live-gate item; AbCip harness static-init anti-pattern
  note; writable-defaults-true operator warning.
- modbus-rtu: first P1 step confirms pymodbus exposes framer=rtu on a TCP
  server (custom-script fallback otherwise).
- program doc: new cross-cutting rule - driver ctors must be connection-free
  (the universal browser's CanBrowse throwaway instances depend on it).
2026-07-15 16:54:09 -04:00

591 lines
38 KiB
Markdown
Raw 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 — executable implementation design
**Status:** design, build-ready. 2026-07-15.
**Supersedes/derives from:** the research report `docs/research/drivers/bacnet-ip.md` (protocol
survey, library selection, browse verdict). This document turns that research into a concrete,
file-by-file build plan for `ZB.MOM.WW.OtOpcUa.Driver.Bacnet`.
**Reconciles with:** `docs/plans/2026-07-15-universal-discovery-browser-design.md` (BACnet browse is
delivered by the **universal discovery browser** in v1, not a bespoke `IBrowseSession`).
---
## 1. Motivation + scope
BACnet/IP (ASHRAE 135 / ISO 16484-5) is the dominant building-automation protocol — HVAC, lighting,
energy metering, access control. A BACnet driver lets the OtOpcUa server pull **facility / HVAC /
energy** points (space temperatures, setpoints, kW/kWh meters, occupancy, equipment status) into the
unified namespace alongside process data (Modbus/S7/Galaxy), so a plant's OT and facility signals live
under one OPC UA address space.
BACnet is the closest analog to the **OpcUaClient** driver in this repo: it has native **device
discovery** (Who-Is/I-Am) and native **change-of-value push** (SubscribeCOV), so it is a first-class
*browseable* + *subscribe-capable* Equipment-kind driver, not a bare poller.
**Scope (this driver is a standard Equipment-kind driver.)** BACnet points are ordinary equipment
`Tag`s bound to the BACnet driver via `TagConfig.FullName` (a JSON addressing blob — see §5), authored
through the standard `/uns` TagModal + address picker, exactly like Modbus / S7 / OpcUaClient. No alias
machinery, no bespoke namespace kind.
**v1 scope: Connect + Read + Discover (+ universal browse) + BBMD/foreign-device. COV push is P2, Write
is P3.** See the phasing in §10.
Research reference: `docs/research/drivers/bacnet-ip.md`.
---
## 2. Project layout
Two new projects, mirroring the Modbus / OpcUaClient split (transport project + a lean Contracts project
the AdminUI can reference without dragging in the UDP transport):
| Project | Contents | References |
|---|---|---|
| **`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Bacnet`** | `BacnetDriver` (IDriver + capability surfaces), the async adapter over the ela-compil client (`BacnetClientAdapter`), `BacnetDriverProbe`, `BacnetDriverFactoryExtensions`, data-type map | `Core.Abstractions`, `Core` (the `Core.Hosting` namespace — `DriverFactoryRegistry` — lives inside the `Core` project), `.Bacnet.Contracts`, NuGet `BACnet` |
| **`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Bacnet.Contracts`** | `BacnetDriverOptions` (+ nested `ForeignDeviceOptions`/`DiscoveryOptions`/`CovOptions`/`ProbeOptions`), the `BacnetObjectType` / `BacnetPropertyId` enums the AdminUI editor binds, `BacnetTagAddress` record, `BacnetEquipmentTagParser` | `Core.Abstractions` only (zero transport deps — same discipline as `Modbus.Contracts` / `Modbus.Addressing`) |
**No `.Browser` project in v1.** BACnet browse comes *for free* from the universal discovery browser
(§4). A bespoke lazy `ZB.MOM.WW.OtOpcUa.Driver.Bacnet.Browser` is a documented **P-later** graduation for
large multi-device sites (§4.2), not v1 work.
**Contracts csproj** — copy `ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj` minus its
`Modbus.Addressing` reference (BACnet has no separate addressing project): `net10.0`,
`Nullable`/`ImplicitUsings` enable, `TreatWarningsAsErrors`, `ProjectReference` to `Core.Abstractions`
only (it owns the shared `TagConfigJson` field readers used by the parser).
**NuGet.** Central package management: pin the version in `Directory.Packages.props` and reference
(version-less) from the transport project only:
```xml
<!-- Directory.Packages.props -->
<PackageVersion Include="BACnet" Version="4.0.0" /> <!-- placeholder — the exact version + net10 TFM load is item 1 of the P1 first-spike checklist (§10) -->
<!-- Driver.Bacnet.csproj -->
<PackageReference Include="BACnet" />
```
- **Library: `System.IO.BACnet`, ela-compil fork, NuGet package id `BACnet`, MIT license.** The engine
inside YABE; explicitly multi-targets **`net10.0`** (no TFM friction); covers everything we need —
Who-Is/I-Am, ReadProperty, ReadPropertyMultiple, WriteProperty, SubscribeCOV/SubscribeCOVProperty +
COV notifications, and BACnet/IP with BBMD + foreign-device registration.
- **The async wrinkle (§4).** The library API is **event/callback-driven and blocking-with-callbacks**,
not `async`/`Task`-first: `BacnetClient.WhoIs()` fires `OnIam` events; `ReadPropertyRequest` has a
blocking overload + a begin/end pair. The driver **must** wrap it behind a thin async adapter
(`TaskCompletionSource` + timeout + cancellation) exactly as `OpcUaClientDriver` wraps the OPC UA SDK.
Budget a dedicated `BacnetClientAdapter` layer for this.
- **One `BacnetClient` per driver instance**, not per device — a single shared
`BacnetIpUdpProtocolTransport` UDP socket multiplexes every device on the network (mirrors "single
`Session` per OpcUaClient driver"). **Port-sharing caveat:** receiving *broadcast* traffic requires
binding UDP 47808, and only one exclusive binder per host interface gets it — so **two BACnet
`DriverInstance`s deployed to the same Host node contend for the port** (shared-socket binds make
datagram delivery nondeterministic). Constrain to one BACnet driver instance per node/interface, or
give additional instances distinct `port` values (fine for sims; real devices always speak 47808).
Unicast flows (directed Who-Is, RP/RPM, COV notifications — devices reply to the request's source
address:port) work from any local port, which is what makes the probe's ephemeral-port bind (§7) safe.
---
## 3. Capability mapping (`IDriver` + capability interfaces)
Recommended surface, landing across phases:
```csharp
public sealed class BacnetDriver
: IDriver, ITagDiscovery, IReadable, IHostConnectivityProbe, // P1
ISubscribable, // P2
IWritable, // P3
IDisposable, IAsyncDisposable
```
`DriverType => "BACnet"` (see §7 for the exact casing decision).
| OtOpcUa capability | BACnet mechanism | Phase |
|---|---|---|
| `IDriver.InitializeAsync` | Bind the UDP socket on the configured interface/port; if `ForeignDevice` is configured, `Register-Foreign-Device` to the BBMD **and start the TTL-renew timer** (renew at ~TTL/2); optionally fire one warm-up `Who-Is`. Health → Healthy once the socket is bound (BACnet has no session; liveness is per-device via read/COV, surfaced through the probe). | P1 |
| `ITagDiscovery.DiscoverAsync` | Bounded `Who-Is` collect → per-device Device `object-list` (RPM, segmented) → per-object `object-name` + `units` + `object-type` → stream folders/variables into `IAddressSpaceBuilder`. `SupportsOnlineDiscovery => true`; `RediscoverPolicy => DiscoveryRediscoverPolicy.Once`. | P1 |
| `IReadable.ReadAsync` | Group the batch's references by device-instance; issue **ReadPropertyMultiple** per device for `present-value` (+ `status-flags` for quality). Fall back to per-object **ReadProperty** for devices that reported no RPM/segmentation support in their I-Am. Map result → `DataValueSnapshot`. | P1 |
| `IHostConnectivityProbe` | Per-device liveness: a cheap RP of each known device's `system-status` (or a directed Who-Is). Running↔Stopped transitions raise `OnHostStatusChanged`, scoping Bad-quality fan-out to that device's subtree. Foreign-device mode: probe also asserts "BBMD registration alive." | P1 |
| `ISubscribable.SubscribeAsync` | **SubscribeCOV** per object (native push — the headline win). `OnCOVNotification` handler fans changes to `OnDataChange`. **Lifetime-renew timer** re-subscribes before expiry. Devices that reject COV **degrade transparently to a poll loop** at `publishingInterval`, so the subscribe surface always works. | P2 |
| `IWritable.WriteAsync` | **WriteProperty** `present-value` at a configured **priority** (116), or `null` to relinquish. **Verdict: NOT in v1 (§3.3).** | P3 |
| `GetHealth` / `GetMemoryFootprint` / `FlushOptionalCachesAsync` | Standard. Footprint ≈ discovered-object count × ~512 B (mirror `OpcUaClientDriver.GetMemoryFootprint`). Flush drops the device/object browse cache + resets the footprint counter. | P1 |
### 3.1 `IReadable` shape (concrete)
Follow `OpcUaClientDriver.ReadAsync` structure: return one `DataValueSnapshot` per requested reference in
order; report **per-reference** failures via the snapshot's `StatusCode` (Bad-coded), throw only if the
whole client is unreachable. BACnet specifics:
1. Parse each `fullReference` with `BacnetEquipmentTagParser.TryParse` (§5); a malformed blob →
`BadNodeIdUnknown` snapshot without touching the wire.
2. Group parsed addresses by `deviceInstance`. Resolve device network address from the discovered device
table (cached from Who-Is/I-Am); a device not in the table → directed Who-Is with a short deadline,
else `BadCommunicationError`.
3. Per device: one **RPM** for all `(object, present-value + status-flags)` pairs, under the per-request
APDU deadline (§8). Non-RPM devices → sequential **RP** per object.
4. Map `BacnetValue``DataValueSnapshot(value, statusCode, sourceTs=null, serverTs=now)`.
`status-flags` `out-of-service`/`fault` ⇒ Uncertain/Bad; else Good (see §3.4).
### 3.2 `ISubscribable` shape (P2 — the real advantage over poll drivers)
- `SubscribeAsync` issues **SubscribeCOV** (`preferConfirmed` from config) per object, tracking a
`subscriberProcessIdentifier` per subscription. Fire the OPC UA initial-data callback with a current
read so clients get a value immediately.
- **Lifetime-renew timer.** Each subscription carries `lifetimeSeconds`; a per-driver timer re-issues
SubscribeCOV at ~`lifetime/2`. This is load-bearing — an un-renewed subscription silently expires and
the node goes stale-Good with no error. (Same class of bug as the redundancy-heartbeat and
continuous-historization renewal traps in this repo's memory — treat renewal as first-class.)
- **Transparent poll fallback.** On a SubscribeCOV reject (error-class `services`/`object`, or a device
whose I-Am advertised no COV), the driver **silently** switches that object to an internal poll loop at
`cov.fallbackPollMs` and still raises `OnDataChange`. The operator sees data either way; the
diagnostic id records which mode each object is in.
- `UnsubscribeAsync` sends SubscribeCOV with lifetime 0 (cancel) and stops any fallback poll.
### 3.3 `IWritable` — verdict: NOT v1 (P3), and why
BACnet writes target a **priority array**: a commandable output (AO/BO/MSV with a `priority-array`
property) resolves its effective value from the highest-priority non-null slot, with `relinquish-default`
as the floor. Writing `present-value` actually writes a *slot* (priority 116); writing the wrong slot,
or writing an absolute value where the site's BMS control logic expects to own a slot, **fights the
building's own control loops** — a genuine safety concern (e.g. commanding a damper the BMS is
sequencing). Correctly modelling commandable-vs-non-commandable objects, slot selection, and relinquish
semantics is a correctness minefield that deserves its own phase with explicit validation. So:
- **v1: read-only.** Every tag's parsed address carries `writable=false`; the driver does **not**
implement `IWritable` in P1/P2.
- **P3: `IWritable`** with an explicit per-tag `writePriority` (default 8 "Manual Operator", never 1), a
commandable-object guard (reject writes to non-commandable object types), and a `null`-relinquish path.
Mirror `OpcUaClientDriver.WriteAsync` honesty: a timeout after dispatch → `BadTimeout` (outcome
unknown), a pre-wire failure → `BadCommunicationError`.
### 3.4 Data-type mapping (`BacnetObjectType`/property → `DriverDataType`)
Driver-side map lives in `Driver.Bacnet/BacnetDataTypeMap.cs` (parallel to Galaxy's `DataTypeMap.cs`).
`DriverDataType` members available: `Boolean, Int16/32/64, UInt16/32/64, Float32, Float64, String,
DateTime, Reference`.
| BACnet present-value / property type | `DriverDataType` | Notes |
|---|---|---|
| REAL (AI/AO/AV present-value) | `Float32` | the common analog case |
| DOUBLE | `Float64` | |
| BOOLEAN | `Boolean` | |
| Enumerated Active/Inactive (BI/BO/BV) | `Boolean` | 0=inactive, 1=active |
| Enumerated multistate (MSI/MSO/MSV) | `UInt16` | present-value is a 1-based state index; `state-text` array surfaced as metadata later |
| Unsigned Integer | `UInt32` | |
| Signed Integer | `Int32` | |
| CharacterString (`object-name`, `description`) | `String` | |
| BACnetDateTime / Date / Time | `DateTime` | Schedules, Trend Log timestamps |
| ObjectIdentifier / property reference | `String` | rarely a tag target |
| Array property (element index N) | element type + `IsArray=true` | ValueRank=1, ArrayDimensions from the array length |
**Engineering Units.** The BACnet `units` enum (`degrees-celsius`, `kilowatt-hours`, `percent`, …) is
captured at discovery/browse time and surfaced as OPC UA `EngineeringUnits` (EUInformation) node metadata
via `IAddressSpaceBuilder.AddProperty`, so UNS nodes carry proper units.
**Quality from `status-flags`.** `out-of-service` or `fault` ⇒ Bad/Uncertain `StatusCode`; else Good.
Read RPM should always co-fetch `status-flags` alongside `present-value`.
---
## 4. The async wrinkle + how discovery/browse are delivered
### 4.1 The callback→async adapter
`BacnetClientAdapter` (new, in the transport project) is the single seam that turns the ela-compil
callback/blocking API into the `Task`-returning calls the driver's capability methods need. Pattern
(identical intent to how `OpcUaClientDriver` wraps the SDK):
- **Who-Is is a broadcast + asynchronous I-Am collection — there is no "the response".** The adapter
exposes `Task<IReadOnlyList<DiscoveredDevice>> WhoIsAsync(range, TimeSpan window, ct)`: subscribe
`OnIam`, `client.WhoIs(low, high)`, accumulate callbacks into a concurrent set, then `Task.Delay(window,
ct)` and return the snapshot. The `window` (`discovery.whoIsWindowMs`, default 4 s) is a **hard latency
floor** on discovery — bound it, show a spinner in the picker.
- **RP/RPM/WP/SubscribeCOV** each get a `TaskCompletionSource<T>` + `CancellationTokenSource.CancelAfter`
wrapper around the library's begin/end (or blocking) call, so per-request APDU deadlines (§8) are
enforced independently of any socket timeout — the R2-01 frozen-peer lesson (an async call must not
outlive its own wall-clock deadline just because the transport didn't time out).
- COV notifications arrive on the library's callback thread; the adapter marshals them onto the driver's
`OnDataChange` event.
### 4.2 Browse: universal in v1, bespoke-lazy P-later
**Reconciliation with `2026-07-15-universal-discovery-browser-design.md`:** BACnet browse **is** discovery
(Who-Is → object-list → properties), and `DiscoverAsync` already streams exactly the tree the picker
wants into `IAddressSpaceBuilder`. So for **small/medium sites, v1 plugs straight into the universal
`DiscoveryDriverBrowser`** — **no bespoke browser code, no `.Browser` project.** The requirements:
1. Add the opt-in flag on `ITagDiscovery` (the universal-browser design specifies this default member;
it is **not yet in `ITagDiscovery.cs`** — adding it is part of that design's P1, and BACnet consumes
it):
```csharp
bool SupportsOnlineDiscovery => false; // add to ITagDiscovery
```
`BacnetDriver` overrides it to `true`.
2. BACnet needs **no `PatchForBrowse` entry** — discovery is unconditional (unlike AbCip/TwinCAT which
are config-gated). The picker's authoring config is sufficient to open + discover.
3. The universal browser runs `InitializeAsync → DiscoverAsync → ShutdownAsync` under its open-timeout and
serves the captured tree from memory. BACnet's Who-Is window fits inside that open-timeout (default
60 s ≫ 4 s window).
The universal browser's eager, whole-tree, one-shot capture is fine for a handful of devices with modest
`object-list`s. **For large multi-device sites (dozens/hundreds of controllers, thousands of objects) the
eager one-shot is too heavy** — it Who-Ises the whole network and RPMs every device's full object-list at
open. That is the documented **P-later graduation** to a bespoke lazy browser:
> **P-later — bespoke `ZB.MOM.WW.OtOpcUa.Driver.Bacnet.Browser`.** A hand-written `IBrowseSession`
> (registered `IDriverBrowser` with `DriverType="BACnet"`, which *overrides* the universal fallback per
> §3.1 of the universal design) with three lazy levels: `RootAsync` = bounded Who-Is → devices;
> `ExpandAsync("dev:<n>")` = that one device's `object-list` on demand; `AttributesAsync(...)` = RPM the
> object's key properties. Only build this when a real large site makes the eager capture painful.
**v1 uses the universal browser. State this clearly in the PR: BACnet ships with `SupportsOnlineDiscovery
= true` and zero browser code.**
**Deployment note — AdminUI node network posture.** The universal browser runs its
`InitializeAsync → DiscoverAsync` capture **in the AdminUI process**, so the **AdminUI node itself needs
UDP reachability to the BACnet network** for the picker to browse — this is the same in-process posture as
the existing OpcUaClient/Galaxy browsers, but BACnet is the **first UDP/broadcast** one, so firewall/VLAN
rules that only whitelist the runtime driver node will silently break browse. When the AdminUI node is
off-segment, its browse capture registers as a **second foreign device** with the site BBMD (in addition
to the runtime driver's standing registration) for the duration of the capture — some BBMDs cap their
foreign-device-table entries, so size the FD table (and any per-source firewall rules) for **both**
registrations, not one.
---
## 5. TagConfig + driver-config JSON
Two layers, matching every Equipment-kind driver.
### 5.1 Driver-level config (`BacnetDriverOptions`, in `.Contracts`)
```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 (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, // per-request APDU deadline (§8)
"apduRetries": 3,
"maxSegmentsAccepted": 16, // segmentation window we advertise — do NOT set too low
"discovery": {
"whoIsWindowMs": 4000, // I-Am collect window (the time-bounded wrinkle, §4.1)
"deviceInstanceLow": 0, // optional Who-Is range filter
"deviceInstanceHigh": 4194303
},
"cov": { // P2
"preferConfirmed": true,
"lifetimeSeconds": 300, // per-subscription lifetime; renewed before expiry
"fallbackPollMs": 1000 // poll interval for devices that reject COV
},
"probe": { "enabled": true, "intervalMs": 5000 }
}
```
`BacnetDriverOptions` is a plain record/class with `[JsonStringEnumConverter]`-friendly nested option
types; the factory and probe deserialize it identically (§6). Enum knobs authored as **string names**.
### 5.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, // target device (from I-Am)
"objectType": "AnalogInput", // BacnetObjectType enum (string name)
"objectInstance": 3, // object-instance number
"propertyId": "PresentValue", // BacnetPropertyId enum; default PresentValue
"arrayIndex": null, // non-null → element of an array property
"dataType": "Float32", // DriverDataType hint (from browse-time units/type)
"writable": false, // v1: always false; P3 enables WriteProperty
"writePriority": 8 // P3 only: BACnet command priority 1..16
}
```
### 5.3 `DriverAttributeInfo.FullName` encoding + the parser
**`FullName` is the verbatim TagConfig JSON blob** (the §5.2 object serialized to a compact string) —
exactly the Modbus convention (`ModbusEquipmentTagParser`: a leading `{` marks an equipment-tag blob).
This is what the picker commits as `TagConfig.FullName`, what discovery writes into each
`DriverAttributeInfo.FullName`, and what the driver publishes values back under, so the runtime
forward-router resolves them. There is **no separate `deviceInstance.objectType:objectInstance` string
form** — the JSON blob is the single canonical reference.
`BacnetEquipmentTagParser.TryParse(string reference, out BacnetTagAddress addr)` in `.Contracts` (copy the
Modbus parser's shape):
- Leading `{` gate; `JsonDocument.Parse`; require `deviceInstance` (number), `objectType` (enum),
`objectInstance` (number).
- **Strict enum reads** via `TagConfigJson.TryReadEnumStrict` for `objectType` / `propertyId` — a
present-but-typo'd enum **rejects** the tag (→ `BadNodeIdUnknown`) rather than silently defaulting to a
wrong object type (the R2-11 strictness lesson the Modbus parser encodes).
- `propertyId` defaults to `PresentValue` when absent; `arrayIndex` optional; `writable` defaults false
in v1.
- Produce `BacnetTagAddress(Reference: reference, DeviceInstance, ObjectType, ObjectInstance, PropertyId,
ArrayIndex, DataType)` where `Reference == reference` (the def identity, so publish keys match).
- Provide an `Inspect(reference)` warnings method (deploy-time surfacing of invalid enums / unparseable
blobs), parallel to `ModbusEquipmentTagParser.Inspect`.
---
## 6. Typed editor + validator + the enum-serialization trap
Add a driver-typed tag editor so the TagModal dispatches by `DriverType="BACnet"` instead of falling back
to the raw-JSON textarea. Copy the Modbus template pair (`Components/Shared/Uns/TagEditors/` razor shell
+ `Uns/TagEditors/*Model.cs` pure model).
1. **`BacnetTagConfigModel`** (`src/Server/.../AdminUI/Uns/TagEditors/BacnetTagConfigModel.cs`) — pure
`FromJson`/`ToJson`/`Validate`, preserving unknown keys via the `JsonObject` bag (mirror
`ModbusTagConfigModel`). Fields: `DeviceInstance:int`, `ObjectType:BacnetObjectType`,
`ObjectInstance:int`, `PropertyId:BacnetPropertyId`, `ArrayIndex:int?`, `DataType:DriverDataType`,
`Writable:bool?`, `WritePriority:int?`. `Validate()`: `deviceInstance` in 0..4194303, `objectInstance`
≥ 0, `writePriority` in 1..16 when present.
2. **`BacnetTagConfigEditor.razor`** (`Components/Shared/Uns/TagEditors/`) — thin shell binding the model;
enum dropdowns for `ObjectType` / `PropertyId` from the `.Contracts` enums.
3. **Register in both maps:**
```csharp
// TagConfigEditorMap.cs
["BACnet"] = typeof(Components.Shared.Uns.TagEditors.BacnetTagConfigEditor),
// TagConfigValidator.cs
["BACnet"] = j => BacnetTagConfigModel.FromJson(j).Validate(),
```
(Both dictionaries are `OrdinalIgnoreCase`, so casing of the key is forgiving, but keep it consistent
with `DriverType` — see §7.)
**The `JsonStringEnumConverter` enum-serialization trap (must-fix, systemic in this repo).** AdminUI
pages/models that serialize enums **numerically** while the driver factory/probe DTOs are **string-typed**
produce configs that fault the driver at runtime (documented systemic bug: driver enum-serialization).
Guard rails:
- The `BacnetTagConfigModel.ToJson` writes enums as **name strings** — use the repo's `TagConfigJson.Set`
helper (it emits enum names, as `ModbusTagConfigModel` does), never `JsonSerializer` with default enum
handling.
- `BacnetDriverFactoryExtensions.JsonOptions` **and** `BacnetDriverProbe`'s options **must** carry
`Converters = { new JsonStringEnumConverter() }` + `PropertyNameCaseInsensitive = true` +
`UnmappedMemberHandling.Skip` — identical options in both, so factory and probe parse a given config
byte-for-byte the same (copy `OpcUaClientDriverFactoryExtensions.JsonOptions`).
- Any AdminUI **driver-config** page for BACnet (the driver-level options form, when it lands) must also
register `JsonStringEnumConverter` so `ObjectType`/`PropertyId`/nested option enums round-trip as
strings.
---
## 7. Factory + registration
**`DriverType = "BACnet"`.** (Chosen casing; the editor/validator/probe/registry keys all use `"BACnet"`.
`DriverType` string comparisons in the tag-editor maps are `OrdinalIgnoreCase`, but keep every registration
site consistent to avoid the Modbus `"ModbusTcp"` vs `"Modbus"` drift that once dropped a driver to the
raw-JSON editor.)
**`BacnetDriverFactoryExtensions`** (copy `OpcUaClientDriverFactoryExtensions`):
```csharp
public static class BacnetDriverFactoryExtensions
{
public const string DriverTypeName = "BACnet";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? lf = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, lf));
}
public static BacnetDriver CreateInstance(string id, string json, ILoggerFactory? lf = null) { /* deserialize BacnetDriverOptions, new BacnetDriver(...) */ }
}
```
**Wire into the two Host bootstrap sites** (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs`):
```csharp
// alongside the other Driver.*.Register(registry, loggerFactory) calls (~line 128-135):
Driver.Bacnet.BacnetDriverFactoryExtensions.Register(registry, loggerFactory);
// alongside the IDriverProbe TryAddEnumerable block (~line 108-114):
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, BacnetDriverProbe>());
```
**`BacnetDriverProbe`** (`IDriverProbe`, `DriverType => "BACnet"`): deserialize `BacnetDriverOptions` with
the *same* `JsonOptions`; the "cheap connection" is a **bind + directed Who-Is** — bind the UDP socket
**on an ephemeral port** (never the configured 47808 — the runtime driver instance may already hold it
on the same node, and unicast replies come back to the probe's source port anyway; see the §2
port-sharing caveat), in foreign-device mode `Register-Foreign-Device` first, then broadcast/directed
Who-Is bounded by the probe `timeout`, return `Ok=true` with latency if ≥1 I-Am arrives, else a clear
"no BACnet devices answered / BBMD registration failed" message. Never throw — return `Ok=false` +
message (per `IDriverProbe` contract). Note the UDP-broadcast-on-CI caveat still applies to the probe
(§9).
**Probe on admin nodes.** Per the repo's split-role gotcha, `IDriverProbe` must be registered where the
AdminUI resolves `IEnumerable<IDriverProbe>` (admin-pinned), not only on driver nodes — the
`TryAddEnumerable` line above is in the shared bootstrap, which covers it, but verify on the split-role
MAIN cluster.
---
## 8. Resilience / timeout
- **Per-request APDU deadline (R2-01 frozen-peer lesson).** Every RP/RPM/WP/SubscribeCOV call runs under
`apduTimeoutMs` enforced by the adapter's `CancellationTokenSource.CancelAfter` (§4.1), **independent**
of any socket-level timeout. This is the exact class of bug R2-01 found in S7 (async read ignored socket
ReadTimeout → frozen-peer poll wedge): a BACnet device that ACKs at the BVLC layer but never returns the
APDU must surface `BadTimeout`/`BadCommunicationError` within the deadline, not wedge the read loop.
`apduRetries` bounds retry attempts for **idempotent** reads only (never writes — §3.3).
- **COV lifetime/renewal (P2).** The renew timer at ~`lifetime/2` is mandatory (§3.2). A **watchdog** on
the non-COV fallback path: if a fallback-polled object produces no successful read within N intervals,
mark it Bad and log, so a silently-dead poll doesn't masquerade as stale-Good.
- **Foreign-device TTL renewal.** In foreign-device mode a TTL-renew timer re-registers with the BBMD at
~TTL/2; a failed re-registration transitions the driver `Degraded` and the probe surfaces it. Losing the
registration silently is the top field failure mode (§2 / research §6 risk 1).
- **Segmentation.** Large `object-list` / RPM results segment across APDUs; the ela-compil client handles
windowed segment ack, but `maxSegmentsAccepted` must **not** be set too low or a big device's
object-list truncates. Default 16; document that lowering it risks truncated discovery.
- **Per-device fault scoping.** `IHostConnectivityProbe` reports per-device Running/Stopped so one dead
controller Bad-quality-fans-out only its own subtree, not the whole driver namespace (mirror Galaxy's
per-host model).
---
## 9. Test fixtures
BACnet has good open simulators; all are UDP servers we containerize on the shared docker host
(`10.100.0.35`) with a `project=lmxopcua` label, driven by `lmxopcua-fix up bacnet` (add a
`tests/.../Docker/docker-compose.yml` under a new BACnet test project, then `lmxopcua-fix sync bacnet`).
1. **`bacnet-stack` demo server (`bacserv`)** — the reference C stack's demo; configurable
device-instance + object set via env; answers Who-Is/I-Am, RP/RPM, WP, SubscribeCOV. The canonical
target. Also `fh1ch/bacstack-compliance-docker` (a ready-made DockerHub device-sim image) for the
lowest-friction single device.
2. **`mnp/bacnet-docker` (or `desolat/bacnet-docker`)** — docker-compose framework to stand up **multiple**
BACnet/IP servers + a BBMD on one host: the **multi-device Who-Is** discovery test and the
**BBMD/foreign-device topology** test (BBMD + device on one compose network; register the driver as a
foreign device across it).
3. **ela-compil `BACnet.Examples/BasicServer`** — a pure-.NET device sim embeddable **directly in the
integration-test process** (no docker): deterministic COV-notification + RP/RPM tests via **directed
(unicast) Who-Is to a known instance/address**, sidestepping broadcast entirely. Pair with
`BasicAdviseCOV` as an in-proc client oracle. This is the hermetic-unit path.
4. **YABE** (Windows GUI, same ela-compil library) — manual apples-to-apples oracle for cross-checking
reads/COV against a known-good client.
**The UDP-broadcast-on-CI gotcha.** BACnet Who-Is relies on subnet **broadcast**; bridged docker networks
and macOS-hosted CI don't broadcast cleanly. Two mitigations, exactly mirroring the existing S7/historian
live-gate discipline:
- **Hermetic unit/integration:** embedded `BasicServer` + **directed unicast Who-Is** to a known
address — no broadcast, runs on macOS/CI.
- **Broadcast / BBMD / multi-device:** an **env-gated live-integration suite** (e.g.
`BACNET_FIXTURE_ENDPOINT` present ⇒ run, absent ⇒ skip cleanly), fixture on the Linux docker host with
`network_mode: host` (or a dedicated bridge). Category `LiveIntegration` like the historian gate.
**Cross-VM reachability — be explicit about what routes:** this repo's tests execute on the dev
machine, *not* on `10.100.0.35`, and a subnet-broadcast Who-Is from an off-subnet peer **does not
route** (and bridged docker networks don't forward subnet broadcasts into containers either). So the
suite as run from the dev machine has exactly two working paths: (a) **directed unicast Who-Is** at
the fixture's host IP:port (host-mode `bacserv` answers a unicast Who-Is with a unicast I-Am to the
request's source address — **verifying this against the pinned `bacserv`/`BasicServer` builds is item 3
of the P1 first-spike checklist (§10)**, since spec-strict stacks may broadcast the I-Am, which would
never arrive; if they broadcast, this leg collapses to the BBMD/foreign-device leg below); and (b) the
**foreign-device/BBMD leg** —
register with a BBMD container on the docker host; BVLC `Forwarded-NPDU` relay is *unicast* and routes
across subnets, so this leg exercises real broadcast distribution cross-VM and is exactly the field
topology. A genuinely broadcast-only leg (plain Who-Is with no BBMD) can only run **on the docker host
itself** (ssh, containers sharing a compose network) — keep it a separate opt-in job. Never gate CI on
broadcast.
**Parser + map unit tests** (fully hermetic, no UDP): `BacnetEquipmentTagParser` round-trip + strictness
(typo'd `objectType` rejects), `BacnetDataTypeMap` coverage, `BacnetTagConfigModel` FromJson/ToJson
unknown-key preservation + enum-as-string emission, `TagConfigValidator["BACnet"]` bounds. Live-verify the
typed editor on docker-dev `/uns` (Razor binding bugs pass unit tests — the repo's standing rule).
---
## 10. Phasing + effort
**Overall effort: Medium-Large** — bigger than Modbus (no discovery/push there), roughly on par with or
slightly above OpcUaClient, driven by the callback→async adapter + COV lifetime machinery + BBMD.
Each phase is independently shippable (mirror how OpcUaClient landed across PRs):
- **P1 — Connect + Read + Discover (+ universal browse) + BBMD. (MVP, delivers real value.)**
**P1 opens with a first-spike verification checklist.** The library and fixture facts below came from
research (`docs/research/drivers/bacnet-ip.md`) and were **unverifiable offline** — verify them on the
wire before building anything on top of them:
1. **NuGet package reality.** Confirm the exact current version of the `BACnet` package (ela-compil
fork) and that its TFM set actually restores + loads on **net10.0** (pin the confirmed version in
`Directory.Packages.props` — see the §2 placeholder).
2. **API-surface smoke test.** Exercise the specific API surface this design depends on:
BBMD/foreign-device registration (`Register-Foreign-Device` + TTL), **segmented**
ReadPropertyMultiple (a large `object-list` fetch), and SubscribeCOV callback delivery
(`OnCOVNotification`). Any gap here reshapes §3/§4 before real work starts.
3. **Fixture unicast I-Am behavior.** Verify the pinned `bacserv` and `BasicServer` fixture builds
answer a **unicast** Who-Is with a **unicast** I-Am to the request's source address — a spec-strict
stack may broadcast the I-Am, which would **silently break the hermetic cross-VM test path** §9
relies on (the directed-Who-Is leg from the dev machine). If either fixture broadcasts, adjust the
cross-VM suite to the **BBMD/foreign-device leg only** and keep directed-unicast tests on the
docker host itself.
Then the P1 build proper:
`.Contracts` (`BacnetDriverOptions`, enums, `BacnetTagAddress`, `BacnetEquipmentTagParser`);
`BacnetClientAdapter` (async wrap); `BacnetDriver : IDriver, ITagDiscovery, IReadable,
IHostConnectivityProbe` with `SupportsOnlineDiscovery=true` + `RediscoverPolicy =>
DiscoveryRediscoverPolicy.Once`; RP/RPM read;
Who-Is/object-list discovery streamed to `IAddressSpaceBuilder`; **foreign-device/BBMD registration +
TTL renewal included here** (without it the driver can't reach most real sites); factory + probe +
Host registration; typed tag editor + validator. Browse works via the universal browser — **no browser
code**.
- **P2 — SubscribeCOV push.** `ISubscribable` via SubscribeCOV + lifetime-renew timer + transparent poll
fallback + fallback watchdog. The headline feature over poll-only drivers.
- **P3 — Write.** `IWritable` via WriteProperty with priority-array semantics + commandable-object
validation + relinquish. Deferred deliberately (§3.3 safety minefield).
- **P4 — History / Alarms.** `IHistoryProvider` over **Trend Log** objects (`ReadRange`); `IAlarmSource`
over intrinsic/algorithmic **event enrollment** + COV of `status-flags` → Part 9 conditions
(`DriverAttributeInfo.IsAlarm` + `MarkAsAlarmCondition`). Nice-to-have, large surface.
### Top risks (from research §6, carried forward)
1. **BBMD / foreign-device registration + UDP broadcast reachability (HIGH — the top risk).** The server
is usually not on the controllers' L2 segment, so correct foreign-device registration + TTL renewal is
load-bearing and is exactly what's hardest to reproduce in docker/CI. Note the AdminUI browse capture
adds a **second** foreign-device registration off-segment (§4.2 deployment note) — account for it in
BBMD FD-table sizing. Mitigation: build BBMD in from
P1, test explicitly with the `bacnet-docker` multi-network compose, gate a live suite against a real
site BBMD.
2. **COV lifetime/renewal + non-COV fallback (HIGH, P2).** Subscriptions silently expire if not renewed;
some devices cap or lack COV and must degrade to polling without an operator-visible gap. Won't be
caught by unit tests — needs the live/sim COV soak (mirror the continuous-historization live-gate
discipline).
Secondary: the callback/blocking library needs a careful TCS+timeout+cancellation adapter; write
priority-array semantics (why P3 is deferred); segmentation for large object-lists (`maxSegmentsAccepted`
not too low).
---
## Appendix A — new/changed files checklist (P1)
**New — `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Bacnet.Contracts/`:** `BacnetDriverOptions.cs` (+ nested
option records), `BacnetObjectType.cs`, `BacnetPropertyId.cs`, `BacnetTagAddress.cs`,
`BacnetEquipmentTagParser.cs`, `.csproj` (Core.Abstractions ref only).
**New — `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Bacnet/`:** `BacnetDriver.cs`, `BacnetClientAdapter.cs`,
`BacnetDataTypeMap.cs`, `BacnetDriverProbe.cs`, `BacnetDriverFactoryExtensions.cs`, `.csproj`
(Core.Abstractions + Core + .Contracts + NuGet `BACnet` — mirror the Modbus/OpcUaClient transport
csproj reference set).
**New — AdminUI:** `Uns/TagEditors/BacnetTagConfigModel.cs`,
`Components/Shared/Uns/TagEditors/BacnetTagConfigEditor.razor`.
**Changed:** `Directory.Packages.props` (+ `BACnet` version); the `.slnx` (add both projects);
`ITagDiscovery.cs` (+ `SupportsOnlineDiscovery` default member — shared with the universal-browser work);
`DriverFactoryBootstrap.cs` (Register + probe TryAddEnumerable); `TagConfigEditorMap.cs` +
`TagConfigValidator.cs` (`"BACnet"` entries).
**Tests:** `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Bacnet.Tests/` (parser, datatype map, adapter with fake
client), `...Driver.Bacnet.IntegrationTests/` (embedded `BasicServer` hermetic + env-gated
`LiveIntegration` for broadcast/BBMD/COV), plus AdminUI `BacnetTagConfigModel` + validator tests.