Files
lmxopcua/docs/plans/2026-07-15-bacnet-ip-driver-design.md
T
Joseph Doherty 8fc147d8d4
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s
docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
Adds the driver-expansion program design (umbrella: universal Discover-backed
browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU;
MELSEC deferred) plus the per-driver research reports.

All docs went through a 7-agent parallel review against the codebase before
this commit. Highlights fixed in review:

- universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle
  + FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the
  program doc's SupportsOnlineDiscovery=false verdict)
- modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads ->
  linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak
  System.IO.Ports into Contracts
- bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live
  suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs)
- sql-poll: real tier registration via DriverFactoryRegistry.Register;
  blackhole gate must not docker-pause the shared central SQL Server
- mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted
- omron: host hardcodes isIdempotent:false today (retry seam unshipped);
  v1 scopes UDTs to dotted-leaf access
- mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern
- program doc: both valid enum-serialization patterns; IRediscoverable is
  change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
2026-07-15 16:40:36 -04:00

35 KiB
Raw Blame History

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 Tags 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:

<!-- Directory.Packages.props -->
<PackageVersion Include="BACnet" Version="4.0.0" />   <!-- confirm the exact current release when implementing -->
<!-- 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 DriverInstances 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:

public sealed class BacnetDriver
    : IDriver, ITagDiscovery, IReadable, IHostConnectivityProbe,   // P1
      ISubscribable,                                                // P2
      IWritable,                                                    // P3
      IDisposable, IAsyncDisposable

DriverType => "BACnet" (see §7 for the exact casing decision).

OtOpcUa capability BACnet mechanism Phase
IDriver.InitializeAsync Bind the UDP socket on the configured interface/port; if ForeignDevice is configured, Register-Foreign-Device to the BBMD and start the TTL-renew timer (renew at ~TTL/2); optionally fire one warm-up Who-Is. Health → Healthy once the socket is bound (BACnet has no session; liveness is per-device via read/COV, surfaced through the probe). P1
ITagDiscovery.DiscoverAsync Bounded Who-Is collect → per-device Device object-list (RPM, segmented) → per-object object-name + units + object-type → stream folders/variables into IAddressSpaceBuilder. SupportsOnlineDiscovery => true; RediscoverPolicy => DiscoveryRediscoverPolicy.Once. P1
IReadable.ReadAsync Group the batch's references by device-instance; issue ReadPropertyMultiple per device for present-value (+ status-flags for quality). Fall back to per-object ReadProperty for devices that reported no RPM/segmentation support in their I-Am. Map result → DataValueSnapshot. P1
IHostConnectivityProbe Per-device liveness: a cheap RP of each known device's system-status (or a directed Who-Is). Running↔Stopped transitions raise OnHostStatusChanged, scoping Bad-quality fan-out to that device's subtree. Foreign-device mode: probe also asserts "BBMD registration alive." P1
ISubscribable.SubscribeAsync SubscribeCOV per object (native push — the headline win). OnCOVNotification handler fans changes to OnDataChange. Lifetime-renew timer re-subscribes before expiry. Devices that reject COV degrade transparently to a poll loop at publishingInterval, so the subscribe surface always works. P2
IWritable.WriteAsync WriteProperty present-value at a configured priority (116), or null to relinquish. Verdict: NOT in v1 (§3.3). P3
GetHealth / GetMemoryFootprint / FlushOptionalCachesAsync Standard. Footprint ≈ discovered-object count × ~512 B (mirror OpcUaClientDriver.GetMemoryFootprint). Flush drops the device/object browse cache + resets the footprint counter. P1

3.1 IReadable shape (concrete)

Follow OpcUaClientDriver.ReadAsync structure: return one DataValueSnapshot per requested reference in order; report per-reference failures via the snapshot's StatusCode (Bad-coded), throw only if the whole client is unreachable. BACnet specifics:

  1. Parse each fullReference with BacnetEquipmentTagParser.TryParse (§5); a malformed blob → BadNodeIdUnknown snapshot without touching the wire.
  2. Group parsed addresses by deviceInstance. Resolve device network address from the discovered device table (cached from Who-Is/I-Am); a device not in the table → directed Who-Is with a short deadline, else BadCommunicationError.
  3. Per device: one RPM for all (object, present-value + status-flags) pairs, under the per-request APDU deadline (§8). Non-RPM devices → sequential RP per object.
  4. Map BacnetValueDataValueSnapshot(value, statusCode, sourceTs=null, serverTs=now). status-flags out-of-service/fault ⇒ Uncertain/Bad; else Good (see §3.4).

3.2 ISubscribable shape (P2 — the real advantage over poll drivers)

  • SubscribeAsync issues SubscribeCOV (preferConfirmed from config) per object, tracking a subscriberProcessIdentifier per subscription. Fire the OPC UA initial-data callback with a current read so clients get a value immediately.
  • Lifetime-renew timer. Each subscription carries lifetimeSeconds; a per-driver timer re-issues SubscribeCOV at ~lifetime/2. This is load-bearing — an un-renewed subscription silently expires and the node goes stale-Good with no error. (Same class of bug as the redundancy-heartbeat and continuous-historization renewal traps in this repo's memory — treat renewal as first-class.)
  • Transparent poll fallback. On a SubscribeCOV reject (error-class services/object, or a device whose I-Am advertised no COV), the driver silently switches that object to an internal poll loop at cov.fallbackPollMs and still raises OnDataChange. The operator sees data either way; the diagnostic id records which mode each object is in.
  • UnsubscribeAsync sends SubscribeCOV with lifetime 0 (cancel) and stops any fallback poll.

3.3 IWritable — verdict: NOT v1 (P3), and why

BACnet writes target a priority array: a commandable output (AO/BO/MSV with a priority-array property) resolves its effective value from the highest-priority non-null slot, with relinquish-default as the floor. Writing present-value actually writes a slot (priority 116); writing the wrong slot, or writing an absolute value where the site's BMS control logic expects to own a slot, fights the building's own control loops — a genuine safety concern (e.g. commanding a damper the BMS is sequencing). Correctly modelling commandable-vs-non-commandable objects, slot selection, and relinquish semantics is a correctness minefield that deserves its own phase with explicit validation. So:

  • v1: read-only. Every tag's parsed address carries writable=false; the driver does not implement IWritable in P1/P2.
  • P3: IWritable with an explicit per-tag writePriority (default 8 "Manual Operator", never 1), a commandable-object guard (reject writes to non-commandable object types), and a null-relinquish path. Mirror OpcUaClientDriver.WriteAsync honesty: a timeout after dispatch → BadTimeout (outcome unknown), a pre-wire failure → BadCommunicationError.

3.4 Data-type mapping (BacnetObjectType/property → DriverDataType)

Driver-side map lives in Driver.Bacnet/BacnetDataTypeMap.cs (parallel to Galaxy's DataTypeMap.cs). DriverDataType members available: Boolean, Int16/32/64, UInt16/32/64, Float32, Float64, String, DateTime, Reference.

BACnet present-value / property type DriverDataType Notes
REAL (AI/AO/AV present-value) Float32 the common analog case
DOUBLE Float64
BOOLEAN Boolean
Enumerated Active/Inactive (BI/BO/BV) Boolean 0=inactive, 1=active
Enumerated multistate (MSI/MSO/MSV) UInt16 present-value is a 1-based state index; state-text array surfaced as metadata later
Unsigned Integer UInt32
Signed Integer Int32
CharacterString (object-name, description) String
BACnetDateTime / Date / Time DateTime Schedules, Trend Log timestamps
ObjectIdentifier / property reference String rarely a tag target
Array property (element index N) element type + IsArray=true ValueRank=1, ArrayDimensions from the array length

Engineering Units. The BACnet units enum (degrees-celsius, kilowatt-hours, percent, …) is captured at discovery/browse time and surfaced as OPC UA EngineeringUnits (EUInformation) node metadata via IAddressSpaceBuilder.AddProperty, so UNS nodes carry proper units.

Quality from status-flags. out-of-service or fault ⇒ Bad/Uncertain StatusCode; else Good. Read RPM should always co-fetch status-flags alongside present-value.


4. The async wrinkle + how discovery/browse are delivered

4.1 The callback→async adapter

BacnetClientAdapter (new, in the transport project) is the single seam that turns the ela-compil callback/blocking API into the Task-returning calls the driver's capability methods need. Pattern (identical intent to how OpcUaClientDriver wraps the SDK):

  • Who-Is is a broadcast + asynchronous I-Am collection — there is no "the response". The adapter exposes Task<IReadOnlyList<DiscoveredDevice>> WhoIsAsync(range, TimeSpan window, ct): subscribe OnIam, client.WhoIs(low, high), accumulate callbacks into a concurrent set, then Task.Delay(window, ct) and return the snapshot. The window (discovery.whoIsWindowMs, default 4 s) is a hard latency floor on discovery — bound it, show a spinner in the picker.
  • RP/RPM/WP/SubscribeCOV each get a TaskCompletionSource<T> + CancellationTokenSource.CancelAfter wrapper around the library's begin/end (or blocking) call, so per-request APDU deadlines (§8) are enforced independently of any socket timeout — the R2-01 frozen-peer lesson (an async call must not outlive its own wall-clock deadline just because the transport didn't time out).
  • COV notifications arrive on the library's callback thread; the adapter marshals them onto the driver's OnDataChange event.

4.2 Browse: universal in v1, bespoke-lazy P-later

Reconciliation with 2026-07-15-universal-discovery-browser-design.md: BACnet browse is discovery (Who-Is → object-list → properties), and DiscoverAsync already streams exactly the tree the picker wants into IAddressSpaceBuilder. So for small/medium sites, v1 plugs straight into the universal DiscoveryDriverBrowserno 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):
    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-lists. 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.


5. TagConfig + driver-config JSON

Two layers, matching every Equipment-kind driver.

5.1 Driver-level config (BacnetDriverOptions, in .Contracts)

{
  "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.

{
  "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:
    // 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):

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):

// 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 — verify this against the pinned bacserv build early, since spec-strict stacks may broadcast the I-Am, which would never arrive); 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.) .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. 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.