8fc147d8d4
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
357 lines
24 KiB
Markdown
357 lines
24 KiB
Markdown
# 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 **`BACnet`** — [github.com/ela-compil/BACnet](https://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](https://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 `FullReference`s 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`)
|
||
|
||
```jsonc
|
||
{
|
||
"localEndpoint": "0.0.0.0", // interface to bind; "0.0.0.0" = all
|
||
"port": 47808, // 0xBAC0 default
|
||
"localDeviceInstance": 4194302, // our own Device instance id (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.
|
||
|
||
```jsonc
|
||
{
|
||
"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](https://github.com/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](https://github.com/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](https://github.com/mnp/bacnet-docker) /
|
||
[desolat/bacnet-docker](https://github.com/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
|
||
|
||
- 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>)
|