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
24 KiB
BACnet/IP driver — research & implementation design
Status: Research / design proposal (not yet implemented)
Author: research sweep, 2026-07-15
Scope: A new Equipment-kind driver ZB.MOM.WW.OtOpcUa.Driver.Bacnet exposing
BACnet/IP facility/HVAC/energy data under the unified namespace, following the same shape as
Modbus / S7 / AbCip / OpcUaClient. BACnet is the closest analog to the OpcUaClient driver:
it has native device discovery and native change-of-value push subscriptions, so it is a
first-class browseable + subscribe-capable driver, not a bare poller.
1. Protocol summary + .NET library options
1.1 BACnet/IP in one screen
BACnet (ASHRAE Standard 135 / ISO 16484-5) is the dominant building-automation protocol (HVAC, lighting, energy metering, access control). BACnet/IP carries BACnet over UDP, default port 47808 (0xBAC0), with a layered framing:
- BVLC (BACnet Virtual Link Control) — the UDP-facing header. Distinguishes unicast, broadcast, and the BBMD / foreign-device distribution messages (see §1.3).
- NPDU (Network layer) — source/destination network numbers + hop count; enables routing across BACnet networks (e.g. an IP↔MS/TP router).
- APDU (Application layer) — the actual service request/response
(ReadProperty, WriteProperty, SubscribeCOV, Who-Is/I-Am, …), plus segmentation for
payloads larger than a single frame (a large
object-listorReadPropertyMultipleresult is segmented across several APDUs with windowed ack).
Object model. A BACnet device is a Device object plus a collection of objects, each
with a well-known object-type and an object-instance number. Common object types:
| Object type | Typical meaning | Present-Value type |
|---|---|---|
| Analog Input / Output / Value (AI/AO/AV) | sensor / setpoint / calc'd analog | REAL (float) |
| Binary Input / Output / Value (BI/BO/BV) | contact / relay / flag | enumerated (0/1, active/inactive) |
| Multistate Input / Output / Value (MSI/MSO/MSV) | discrete state (Off/Low/High) | unsigned int |
| Device | identity, object-list, vendor, APDU limits | — |
| Schedule, Calendar | time-based control | — |
| Trend Log, Trend Log Multiple | historical samples | — |
| Loop, Notification Class, File, … | control / infra | — |
Every object exposes properties addressed by a property-identifier (an enum). The ones that matter for a tag driver:
present-value(PROP_PRESENT_VALUE, 85) — the live value. The read/subscribe target.object-name(77),description(28) — labels for browse.units(117) — an Engineering-Units enum (degrees-C, kW, percent, …) on analog objects.status-flags(111) — in-alarm / fault / overridden / out-of-service → maps to OPC UA quality.object-list(76) — on the Device object: the array of every object the device holds (the enumeration backbone for discovery + browse).priority-array(87) +relinquish-default(104) — for commandable outputs; writes target a priority slot (see §2 Write).
Services used by a client/gateway:
- Who-Is / I-Am — discovery. Client broadcasts
Who-Is(optionally with a device-instance low/high range); each device answersI-Amcarrying its device-instance, max-APDU, segmentation support, and vendor-id. This is asynchronous and time-bounded (broadcast, then collect I-Ams over a window) — the key wrinkle for the browse seam (§4). - ReadProperty (RP) — read one property of one object.
- ReadPropertyMultiple (RPM) — read many properties across many objects in one request; the efficient bulk-read path (subject to segmentation for big results).
- WriteProperty (WP) / WritePropertyMultiple — write, usually
present-valueat a priority. - SubscribeCOV (Change-Of-Value) — subscribe to an object; the device pushes
COVNotificationwhenpresent-value(orstatus-flags) changes beyond the COV increment. Subscriptions carry a lifetime (seconds) and must be renewed before expiry, or made confirmed vs unconfirmed.SubscribeCOVPropertytargets a specific property.
1.3 Cross-subnet: BBMD & foreign-device registration
UDP broadcasts (Who-Is, I-Am, unconfirmed-COV) don't cross IP routers. BACnet solves this with
BBMDs (BACnet Broadcast Management Devices): one BBMD per subnet forwards broadcasts to peer
BBMDs via a Broadcast Distribution Table (BDT). A client on a subnet without a BBMD (or on
a different network, e.g. the OtOpcUa server VM) registers as a Foreign Device with a remote
BBMD (Register-Foreign-Device, with a TTL that must be renewed). Once registered, the BBMD
relays broadcasts to it. For OtOpcUa this is the common case — the server usually is not on
the same L2 segment as the controllers, so foreign-device registration to a site BBMD is the
realistic connect path. This is a first-class config knob (§3), not an afterthought.
1.2 .NET library options
| Library | Package / repo | License | Frameworks | Maturity | Verdict |
|---|---|---|---|---|---|
| System.IO.BACnet (ela-compil fork) | NuGet BACnet — github.com/ela-compil/BACnet |
MIT | net48, netstandard2.0, net8.0, net10.0 | ~247★ / 45 releases / v4.0; core library used by YABE (Yet Another BACnet Explorer) | RECOMMENDED |
| bacnet-stack (C) | github.com/bacnet-stack/bacnet-stack | GPL/BSD (dual) | C, P/Invoke only | Reference impl; used by the docker sims | No — C interop, not a .NET API |
| BACsharp / gecambridge / sxul forks | various | MIT | older TFMs | Stale forks of the same SVN origin | No — ela-compil is the live one |
Chosen library: System.IO.BACnet (ela-compil fork), NuGet BACnet, MIT.
Why: it is the actively maintained fork of the canonical Morten Kvistgaard / F. Chaxel
System.IO.BACnet port of Steve Karg's bacnet-stack, it explicitly multi-targets net10.0
(so no TFM friction with this repo), MIT license fits, and it is the engine inside YABE so the
protocol coverage is battle-tested. It explicitly supports everything we need:
Who-Is/I-Am, ReadProperty, ReadPropertyMultiple, WriteProperty, SubscribeCOV /
SubscribeCOVProperty + COV notifications, and BACnet/IP with BBMD + foreign-device registration
(confirmed on the repo's feature list and in ela-compil/BACnet.Examples:
BasicReadWrite, ObjectBrowseSample, BasicAdviseCOV, DemoBBMD).
Honest caveats:
- The API is event/callback-driven and largely synchronous-with-callbacks, not
async/Task-first.BacnetClient.WhoIs()firesOnIamevents;ReadPropertyRequesthas a blocking overload + a begin/end async pattern. The driver must wrap it behind an async seam (TaskCompletionSource+ timeout), exactly the way the OpcUaClient driver already wraps the OPC UA SDK's session calls. Budget for a thin adapter layer. - BACnet values come back as
BacnetValue(a tagged union /IList<BacnetValue>for arrays); the driver owns the BACnet-type →DriverDataTypemapping. - Transport is a single shared UDP socket (
BacnetIpUdpProtocolTransport) per client — all devices on the network multiplex over it. OneBacnetClientper driver instance (per network interface / BBMD), not one per device — mirrors "single Session per OpcUaClient driver."
2. Capability mapping (IDriver + capability interfaces)
The driver implements IDriver plus the capability interfaces it can honor. Recommended set:
IDriver, ITagDiscovery, IReadable, ISubscribable, IWritable, IHostConnectivityProbe
(and later IHistoryProvider via Trend Log, IAlarmSource via COV/event enrollment — phase 2+).
| OtOpcUa capability | BACnet mechanism |
|---|---|
IDriver.InitializeAsync |
Bind the UDP socket on the configured interface/port; if ForeignDevice configured, Register-Foreign-Device to the BBMD (+ start TTL-renew timer). Optionally fire an initial Who-Is to warm the device table. Health = Healthy once the socket is bound (BACnet has no "connection"; liveness is per-device via COV/read). |
ITagDiscovery.DiscoverAsync |
Who-Is (bounded collect window) → for each I-Am, read Device object-list (RPM, segmented) → for each object read object-name + units + object-type → register a variable node per (device, object[, property]). RediscoverPolicy = Once (like OpcUaClient); redeploy re-runs it. Note: for large sites, discovery is authored via the browse picker (§4) rather than a blind full-network discover — same as OpcUaClient where tags are usually hand-picked. |
IReadable.ReadAsync |
Group the batch's FullReferences by device; issue ReadPropertyMultiple per device for present-value (+ status-flags for quality). Fall back to per-object ReadProperty for devices that report no RPM support in their I-Am. Map BACnet result → DataValueSnapshot (value + StatusCode from status-flags + timestamp=now). |
ISubscribable.SubscribeAsync |
SubscribeCOV per object (the real win — native push). Register an OnCOVNotification handler that fans changes to OnDataChange. Maintain a lifetime-renew timer (re-subscribe before expiry). Devices that reject COV (return error) degrade to a poll loop at publishingInterval transparently — so the subscribe surface always works. |
IWritable.WriteAsync |
WriteProperty present-value at a configurable priority (1–16; default 8 "Manual Operator" or 16 "lowest"), or null to relinquish. Non-idempotent → surface BadTimeout on ambiguous outcome (mirror OpcUaClient's write-result honesty). Verdict: ship read-only in v1, add write in phase 2 (priority-array semantics + commandable-vs-non-commandable validation are a correctness minefield; see §6). |
IHostConnectivityProbe |
Periodic Who-Is to a specific device-instance (or a cheap RP of the Device object's system-status); Running↔Stopped transitions raise OnHostStatusChanged. For foreign-device mode, probe = "is the BBMD registration alive." |
GetHealth / GetMemoryFootprint / FlushOptionalCachesAsync |
Standard. Footprint ≈ discovered-object count × constant (mirror OpcUaClient). Flush drops the device/object browse cache. |
2.1 Data-type mapping (BACnet → DriverDataType)
DriverDataType members available: Boolean, Int16/32/64, UInt16/32/64, Float32, Float64, String, DateTime, Reference.
| BACnet present-value / property type | DriverDataType |
Notes |
|---|---|---|
| REAL (analog present-value) | Float32 |
AI/AO/AV |
| DOUBLE | Float64 |
|
| BOOLEAN | Boolean |
|
| Enumerated: binary Active/Inactive (BI/BO/BV) | Boolean |
0=inactive,1=active |
| Enumerated: multistate (MSI/MSO/MSV) | UInt16 (or UInt32) |
present-value is 1-based state index; expose state-text array as metadata later |
| Unsigned Integer | UInt32 |
|
| Signed Integer | Int32 |
|
CharacterString (object-name, description) |
String |
|
| BACnetDateTime / Date / Time | DateTime |
Schedules, Trend Log timestamps |
| ObjectIdentifier / property references | Reference / String |
rarely a tag target |
| Array property (element index N) | element type + IsArray |
ValueRank=1 with ArrayDimensions |
Engineering Units (the BACnet units enum: degrees-celsius, kilowatt-hours, percent,
…) map to the OPC UA node's EngineeringUnits (EUInformation) — surface it as node metadata
(the address-space builder's variable metadata), captured at discovery/browse time so the UNS
node carries proper units. status-flags (in-alarm/fault/overridden/out-of-service) →
OPC UA StatusCode: out-of-service or fault ⇒ Bad/Uncertain quality; else Good.
3. TagConfig JSON shape
Two layers, matching every other Equipment-kind driver: a driver-level config (the
DriverConfig blob bound in InitializeAsync, analogous to ModbusDriverOptions) and a
per-tag TagConfig blob (authored on the /uns TagModal; the driver's equipment-tag parser
turns it into an addressing record — mirror ModbusEquipmentTagParser).
3.1 Driver-level config (BacnetDriverOptions)
{
"localEndpoint": "0.0.0.0", // interface to bind; "0.0.0.0" = all
"port": 47808, // 0xBAC0 default
"localDeviceInstance": 4194302, // our own Device instance id (must be 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,
"apduRetries": 3,
"maxSegmentsAccepted": 16, // segmentation window we advertise
"discovery": {
"whoIsWindowMs": 4000, // collect I-Am for this long (the time-bounded wrinkle)
"deviceInstanceLow": 0, // optional Who-Is range filter
"deviceInstanceHigh": 4194303
},
"cov": {
"preferConfirmed": true, // confirmed vs unconfirmed COV
"lifetimeSeconds": 300, // per-subscription lifetime; renewed before expiry
"fallbackPollMs": 1000 // poll interval for devices that reject COV
},
"probe": { "enabled": true, "deviceInstance": 100, "intervalMs": 5000 }
}
3.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, // the target device (from I-Am)
"objectType": "AnalogInput", // enum: AnalogInput/AnalogOutput/AnalogValue/
// BinaryInput/.../MultistateValue/...
"objectInstance": 3, // object-instance number
"propertyId": "PresentValue", // default; may be StatusFlags, Units, etc.
"arrayIndex": null, // non-null → element of an array property
"dataType": "Float32", // driver data-type hint (from browse-time units/type)
"writable": false, // v1: always false (read-only); v2: enables WriteProperty
"writePriority": 8 // v2 only: BACnet command priority 1..16
}
This blob is stored verbatim as the equipment tag's TagConfig.FullName reference; the driver's
BacnetEquipmentTagParser.TryParse (leading { ⇒ TagConfig blob, same convention as
ModbusEquipmentTagParser) turns it into the internal address record used for RP/RPM/COV keys.
The parser publishes values back keyed by the same reference string so the runtime forward-router
resolves them — identical to the Modbus contract.
4. BROWSEABILITY VERDICT + browse design
VERDICT: YES — strongly browseable. BACnet is arguably more browseable than most
drivers: Who-Is enumerates devices, object-list enumerates a device's objects, and per-object
property reads give names/units/types. This is a first-class IDriverBrowser, on par with the
OpcUaClient browser.
New project ZB.MOM.WW.OtOpcUa.Driver.Bacnet.Browser, mirroring
ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser: one IDriverBrowser (DriverType = "Bacnet")
that OpenAsync(configJson) → binds a transient BacnetClient (+ BBMD reg if configured) and
returns a BacnetBrowseSession : IBrowseSession. Registered in
AdminUI/EndpointRouteBuilderExtensions.cs alongside the other two:
services.AddSingleton<IDriverBrowser, BacnetDriverBrowser>();.
Three-level tree mapped onto RootAsync / ExpandAsync / AttributesAsync:
| Seam | BACnet action | Returns |
|---|---|---|
RootAsync |
Broadcast Who-Is (range from config), collect I-Am over whoIsWindowMs, dedupe by device-instance |
one BrowseNode per device (NodeId = "dev:<instance>", Kind=Folder, HasChildrenHint=true, DisplayName = device object-name if a quick RP resolves, else Device <instance>) |
ExpandAsync("dev:<instance>") |
Read the device's object-list (RPM, segmented); optionally batch-read each object's object-name |
one BrowseNode per object (NodeId = "dev:<instance>/<objtype>:<objinst>", Kind=Leaf — an object commits to a tag, DisplayName = object-name) |
AttributesAsync("dev:.../<objtype>:<objinst>") |
RPM the object's key properties: present-value, units, status-flags, description (+ state-text for multistate) |
AttributeInfo[] for the side-panel — surfaces the resolved DriverDataType, units, writability (present iff commandable object type), so the picker pre-fills the TagConfig |
Commit mapping. Selecting an object (a Leaf) + present-value → the picker emits the §3.2
TagConfig blob: deviceInstance from the device node, objectType/objectInstance from the
object node, propertyId = "PresentValue", dataType from AttributesAsync. (Selecting a
non-default property is an advanced path — emit the chosen propertyId.)
The time-bounded-discovery wrinkle
Unlike OPC UA's synchronous Browse request/response, Who-Is is a broadcast + asynchronous
I-Am collection: there is no "the response." The session handles this by making RootAsync
block for whoIsWindowMs while accumulating OnIam callbacks into a concurrent set, then
returning the snapshot. Design points:
- Cache the discovered device table on the session after the first
RootAsync; a re-expand doesn't re-broadcast. Offer a cheap "rescan" that re-broadcasts and merges (devices that came online later appear). LastUsedUtc refresh + TTL reaper eviction as usual. - The window is a latency floor on the first
RootAsync(a few seconds). That's acceptable for an interactive picker (show a spinner "discovering devices…"), and it matches theConnectBudgetpattern the Galaxy browser already uses (30s connect budget). Bound it hard. - Foreign-device mode: the transient browse client must register with the BBMD before Who-Is, or no I-Ams cross the router — reuse the runtime driver's registration code in the browser (same "mirror the runtime option shape" discipline the Galaxy browser follows).
object-liston a big device can be large ⇒ segmentation must work in the browse client (the ela-compil client handles segmented RPM; just don't capmaxSegmentsAcceptedtoo low).
5. Test-fixture strategy
BACnet has good open simulators; all are UDP servers we can containerize on the shared docker
host (10.100.0.35) with a project=lmxopcua label, driven by lmxopcua-fix up bacnet:
bacnet-stackdemo server (bacnet-stack/bacnet-stack) — the reference C stack'sbacservdemo. Configurable device-instance + object set via env; answers Who-Is/I-Am, RP/RPM, WP, and SubscribeCOV. The canonical target.bacstack-compliance-docker(fh1ch/bacstack-compliance-docker) — a ready-made BACnet server simulator as a Docker container (DockerHub image), built on bacnet-stack. Lowest-friction "just run a device."bacnet-docker(mnp/bacnet-docker / desolat/bacnet-docker) — docker-compose framework to stand up multiple BACnet/IP servers + clients on one host; good for a multi-device Who-Is discovery test and a BBMD/foreign-device topology test (put a BBMD + a device on one compose network, register the driver as a foreign device).- YABE (Yet Another BACnet Explorer, Windows GUI) — manual cross-check that our reads/COV match a known-good client (it uses the same ela-compil library, so it's an apples-to-apples oracle). Also a good source of protocol-behavior sanity.
- ela-compil
BACnet.Examples/BasicServer— a pure-.NET device sim we can embed directly in the integration test process (no docker), useful for deterministic COV-notification unit/ integration tests without UDP-on-CI flakiness. Pair withBasicAdviseCOVas the client oracle.
Gotcha — UDP + broadcast on docker/CI: BACnet relies on subnet broadcast for Who-Is;
bridged docker networks and macOS-hosted CI don't broadcast cleanly. Two mitigations: (a) run
BACnet fixtures on the Linux docker host with network_mode: host or a dedicated bridge and test
from a peer on that host (the 10.100.0.35 pattern), and (b) for hermetic unit tests use the
embedded BasicServer + directed (unicast) Who-Is to a known instance/address, sidestepping
broadcast entirely. Expect an env-gated live-integration suite (like the historian/S7 live
gates) rather than pure in-proc for the discovery/COV/BBMD legs.
6. Effort / risk / phasing
Overall effort: Medium-Large. Bigger than Modbus (Modbus has no discovery, no push), roughly on par with or slightly above the OpcUaClient driver, because of the async-wrapping of a callback-style library + the COV lifetime/renewal machinery + BBMD.
Suggested phasing (each phase independently shippable, mirroring how OpcUaClient landed across PRs):
- Phase 1 — Connect + Read + Discover + Browse (read-only).
BacnetDriverOptions,BacnetDriver : IDriver, ITagDiscovery, IReadable, IHostConnectivityProbe; the async adapter overBacnetClient; RP/RPM read path; Who-Is/object-list discovery; theBacnet.Browserproject + AdminUI registration; typed tag editor (TagConfigEditorMap+TagConfigValidator) or start on the raw-JSON fallback. Foreign-device/BBMD included here because without it the driver can't reach most real sites. This is the MVP and delivers real value. - Phase 2 — SubscribeCOV push.
ISubscribablevia SubscribeCOV + lifetime-renew timer + poll fallback for non-COV devices. The headline feature over poll-only drivers. - Phase 3 — Write.
IWritablevia WriteProperty with priority-array semantics + commandable validation. Deferred deliberately (see risk below). - Phase 4 — History / Alarms.
IHistoryProviderover Trend Log objects (ReadRange);IAlarmSourceover intrinsic/algorithmic event enrollment + COV ofstatus-flags. Nice-to-have; large surface.
Top risks
- BBMD / foreign-device registration + UDP broadcast reachability (HIGH). The single most
likely thing to make it "work on my bench, fail at the customer." The server is usually not on
the controllers' L2 segment, so correct foreign-device registration (and its TTL renewal) is
load-bearing, and it's exactly what's hardest to reproduce in docker/CI. Mitigation: build BBMD
in from Phase 1, test it explicitly with the
bacnet-dockermulti-network compose, and gate a live suite against a real site BBMD (the established env-gated live-gate pattern). - COV lifetime/renewal + non-COV device fallback (HIGH). Subscriptions silently expire if not renewed; some devices cap concurrent COV subscriptions or don't support COV at all (must degrade to polling without the operator noticing a gap). Getting the renewal timer, the confirmed-vs-unconfirmed choice, and the transparent poll fallback right is subtle and won't be caught by unit tests — needs the live/sim COV soak (mirror the continuous-historization live-gate discipline).
Secondary risks: the ela-compil library's callback/blocking API needs a careful async wrapper
(TCS + timeout + cancellation) to fit the driver's async seams; write priority-array
semantics (why Phase 3 is deferred — writing the wrong priority can fight the BMS's own control
logic, a safety concern); and segmentation for large object-list/RPM results (the library
handles it, but maxSegmentsAccepted must not be set too low).
Sources
- ela-compil/BACnet (System.IO.BACnet, MIT, net10.0): https://github.com/ela-compil/BACnet
- ela-compil/BACnet.Examples (WhoIs/OnIam, RPM, SubscribeCOV, BBMD, ObjectBrowse): https://github.com/ela-compil/BACnet.Examples
- bacnet-stack (reference C stack + demo server): https://github.com/bacnet-stack/bacnet-stack
- fh1ch/bacstack-compliance-docker (BACnet device sim container): https://github.com/fh1ch/bacstack-compliance-docker
- mnp/bacnet-docker (multi-device compose rig): https://github.com/mnp/bacnet-docker
- BACnet protocol / object model / COV overviews: Chipkin (https://docs.chipkin.com/protocols/bacnet/), Actility (https://www.actility.com/what-is-the-bacnet-object-model/), Software Toolbox (https://softwaretoolbox.com/resources/what-is-bacnet)