# 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
```
- **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** (1–16), 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 1–16); 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> 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` + `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:")` = 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());
```
**`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` (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.