Files
lmxopcua/docs/research/drivers/bacnet-ip.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

24 KiB
Raw Blame History

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-list or ReadPropertyMultiple result 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 answers I-Am carrying 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-value at a priority.
  • SubscribeCOV (Change-Of-Value) — subscribe to an object; the device pushes COVNotification when present-value (or status-flags) changes beyond the COV increment. Subscriptions carry a lifetime (seconds) and must be renewed before expiry, or made confirmed vs unconfirmed. SubscribeCOVProperty targets 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 BACnetgithub.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() fires OnIam events; ReadPropertyRequest has 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 → DriverDataType mapping.
  • Transport is a single shared UDP socket (BacnetIpUdpProtocolTransport) per client — all devices on the network multiplex over it. One BacnetClient per 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 (116; 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 the ConnectBudget pattern 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-list on a big device can be large ⇒ segmentation must work in the browse client (the ela-compil client handles segmented RPM; just don't cap maxSegmentsAccepted too 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:

  1. bacnet-stack demo server (bacnet-stack/bacnet-stack) — the reference C stack's bacserv demo. Configurable device-instance + object set via env; answers Who-Is/I-Am, RP/RPM, WP, and SubscribeCOV. The canonical target.
  2. 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."
  3. 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).
  4. 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.
  5. 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 with BasicAdviseCOV as 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 over BacnetClient; RP/RPM read path; Who-Is/object-list discovery; the Bacnet.Browser project + 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. ISubscribable via SubscribeCOV + lifetime-renew timer + poll fallback for non-COV devices. The headline feature over poll-only drivers.
  • Phase 3 — Write. IWritable via WriteProperty with priority-array semantics + commandable validation. Deferred deliberately (see risk below).
  • Phase 4 — History / Alarms. IHistoryProvider over Trend Log objects (ReadRange); IAlarmSource over intrinsic/algorithmic event enrollment + COV of status-flags. Nice-to-have; large surface.

Top risks

  1. 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-docker multi-network compose, and gate a live suite against a real site BBMD (the established env-gated live-gate pattern).
  2. 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