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