# 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.