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
35 KiB
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 idBACnet, MIT license. The engine inside YABE; explicitly multi-targetsnet10.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()firesOnIamevents;ReadPropertyRequesthas a blocking overload + a begin/end pair. The driver must wrap it behind a thin async adapter (TaskCompletionSource+ timeout + cancellation) exactly asOpcUaClientDriverwraps the OPC UA SDK. Budget a dedicatedBacnetClientAdapterlayer for this. - One
BacnetClientper driver instance, not per device — a single sharedBacnetIpUdpProtocolTransportUDP socket multiplexes every device on the network (mirrors "singleSessionper OpcUaClient driver"). Port-sharing caveat: receiving broadcast traffic requires binding UDP 47808, and only one exclusive binder per host interface gets it — so two BACnetDriverInstances 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 distinctportvalues (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 (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:
- Parse each
fullReferencewithBacnetEquipmentTagParser.TryParse(§5); a malformed blob →BadNodeIdUnknownsnapshot without touching the wire. - 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, elseBadCommunicationError. - 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. - Map
BacnetValue→DataValueSnapshot(value, statusCode, sourceTs=null, serverTs=now).status-flagsout-of-service/fault⇒ Uncertain/Bad; else Good (see §3.4).
3.2 ISubscribable shape (P2 — the real advantage over poll drivers)
SubscribeAsyncissues SubscribeCOV (preferConfirmedfrom config) per object, tracking asubscriberProcessIdentifierper 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 atcov.fallbackPollMsand still raisesOnDataChange. The operator sees data either way; the diagnostic id records which mode each object is in. UnsubscribeAsyncsends 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 implementIWritablein P1/P2. - P3:
IWritablewith an explicit per-tagwritePriority(default 8 "Manual Operator", never 1), a commandable-object guard (reject writes to non-commandable object types), and anull-relinquish path. MirrorOpcUaClientDriver.WriteAsynchonesty: 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): subscribeOnIam,client.WhoIs(low, high), accumulate callbacks into a concurrent set, thenTask.Delay(window, ct)and return the snapshot. Thewindow(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.CancelAfterwrapper 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
OnDataChangeevent.
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:
- Add the opt-in flag on
ITagDiscovery(the universal-browser design specifies this default member; it is not yet inITagDiscovery.cs— adding it is part of that design's P1, and BACnet consumes it):bool SupportsOnlineDiscovery => false; // add to ITagDiscoveryBacnetDriveroverrides it totrue. - BACnet needs no
PatchForBrowseentry — discovery is unconditional (unlike AbCip/TwinCAT which are config-gated). The picker's authoring config is sufficient to open + discover. - The universal browser runs
InitializeAsync → DiscoverAsync → ShutdownAsyncunder 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-writtenIBrowseSession(registeredIDriverBrowserwithDriverType="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'sobject-liston 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; requiredeviceInstance(number),objectType(enum),objectInstance(number). - Strict enum reads via
TagConfigJson.TryReadEnumStrictforobjectType/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). propertyIddefaults toPresentValuewhen absent;arrayIndexoptional;writabledefaults false in v1.- Produce
BacnetTagAddress(Reference: reference, DeviceInstance, ObjectType, ObjectInstance, PropertyId, ArrayIndex, DataType)whereReference == reference(the def identity, so publish keys match). - Provide an
Inspect(reference)warnings method (deploy-time surfacing of invalid enums / unparseable blobs), parallel toModbusEquipmentTagParser.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.cspure model).
BacnetTagConfigModel(src/Server/.../AdminUI/Uns/TagEditors/BacnetTagConfigModel.cs) — pureFromJson/ToJson/Validate, preserving unknown keys via theJsonObjectbag (mirrorModbusTagConfigModel). Fields:DeviceInstance:int,ObjectType:BacnetObjectType,ObjectInstance:int,PropertyId:BacnetPropertyId,ArrayIndex:int?,DataType:DriverDataType,Writable:bool?,WritePriority:int?.Validate():deviceInstancein 0..4194303,objectInstance≥ 0,writePriorityin 1..16 when present.BacnetTagConfigEditor.razor(Components/Shared/Uns/TagEditors/) — thin shell binding the model; enum dropdowns forObjectType/PropertyIdfrom the.Contractsenums.- Register in both maps:
(Both dictionaries are
// TagConfigEditorMap.cs ["BACnet"] = typeof(Components.Shared.Uns.TagEditors.BacnetTagConfigEditor), // TagConfigValidator.cs ["BACnet"] = j => BacnetTagConfigModel.FromJson(j).Validate(),OrdinalIgnoreCase, so casing of the key is forgiving, but keep it consistent withDriverType— 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.ToJsonwrites enums as name strings — use the repo'sTagConfigJson.Sethelper (it emits enum names, asModbusTagConfigModeldoes), neverJsonSerializerwith default enum handling. BacnetDriverFactoryExtensions.JsonOptionsandBacnetDriverProbe's options must carryConverters = { new JsonStringEnumConverter() }+PropertyNameCaseInsensitive = true+UnmappedMemberHandling.Skip— identical options in both, so factory and probe parse a given config byte-for-byte the same (copyOpcUaClientDriverFactoryExtensions.JsonOptions).- Any AdminUI driver-config page for BACnet (the driver-level options form, when it lands) must also
register
JsonStringEnumConvertersoObjectType/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
apduTimeoutMsenforced by the adapter'sCancellationTokenSource.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 surfaceBadTimeout/BadCommunicationErrorwithin the deadline, not wedge the read loop.apduRetriesbounds retry attempts for idempotent reads only (never writes — §3.3). - COV lifetime/renewal (P2). The renew timer at ~
lifetime/2is 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
Degradedand 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, butmaxSegmentsAcceptedmust 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.
IHostConnectivityProbereports 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).
bacnet-stackdemo 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. Alsofh1ch/bacstack-compliance-docker(a ready-made DockerHub device-sim image) for the lowest-friction single device.mnp/bacnet-docker(ordesolat/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).- 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 withBasicAdviseCOVas an in-proc client oracle. This is the hermetic-unit path. - 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_ENDPOINTpresent ⇒ run, absent ⇒ skip cleanly), fixture on the Linux docker host withnetwork_mode: host(or a dedicated bridge). CategoryLiveIntegrationlike the historian gate. Cross-VM reachability — be explicit about what routes: this repo's tests execute on the dev machine, not on10.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-modebacservanswers a unicast Who-Is with a unicast I-Am to the request's source address — verify this against the pinnedbacservbuild 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; BVLCForwarded-NPDUrelay 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, IHostConnectivityProbewithSupportsOnlineDiscovery=true+RediscoverPolicy => DiscoveryRediscoverPolicy.Once; RP/RPM read; Who-Is/object-list discovery streamed toIAddressSpaceBuilder; 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.
ISubscribablevia SubscribeCOV + lifetime-renew timer + transparent poll fallback + fallback watchdog. The headline feature over poll-only drivers. - P3 — Write.
IWritablevia WriteProperty with priority-array semantics + commandable-object validation + relinquish. Deferred deliberately (§3.3 safety minefield). - P4 — History / Alarms.
IHistoryProviderover Trend Log objects (ReadRange);IAlarmSourceover intrinsic/algorithmic event enrollment + COV ofstatus-flags→ Part 9 conditions (DriverAttributeInfo.IsAlarm+MarkAsAlarmCondition). Nice-to-have, large surface.
Top risks (from research §6, carried forward)
- 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-dockermulti-network compose, gate a live suite against a real site BBMD. - 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.