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