|
| 1 | +# libbacnet |
| 2 | + |
| 3 | +A sans-IO BACnet/IP client library written in Rust with a Python asyncio interface. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +`libbacnet` implements the BACnet/IP client protocol as a pure state machine (the |
| 8 | +[sans-IO](https://sans-io.readthedocs.io/) pattern). All protocol logic lives in |
| 9 | +a Rust `Stack` type that: |
| 10 | + |
| 11 | +- receives inputs (`Received`, `Tick`, `Send`), |
| 12 | +- returns outputs (`Transmit`, `Event`, `Deadline`), |
| 13 | +- never touches a socket, spawns a thread, or reads a clock. |
| 14 | + |
| 15 | +A thin Python asyncio layer (`BacnetProtocol` / `BacnetClient`) wires the stack |
| 16 | +to UDP I/O, drives the retry scheduler, and exposes `async def` methods for each |
| 17 | +supported service. |
| 18 | + |
| 19 | +### Supported services |
| 20 | + |
| 21 | +| Service | Direction | |
| 22 | +|---|---| |
| 23 | +| ReadProperty | confirmed request | |
| 24 | +| ReadPropertyMultiple | confirmed request | |
| 25 | +| WriteProperty | confirmed request | |
| 26 | +| Who-Is / I-Am | unconfirmed | |
| 27 | +| WhoIsRouterToNetwork / IAmRouterToNetwork | unconfirmed (NPDU) | |
| 28 | + |
| 29 | +Segmented responses (ComplexACK with `more-follows`) are transparently |
| 30 | +reassembled; large segmented requests are automatically fragmented. |
| 31 | + |
| 32 | +### Non-goals (v0.1) |
| 33 | + |
| 34 | +- MS/TP or other physical layers (BACnet/IP only) |
| 35 | +- Server / device role |
| 36 | +- COV subscriptions, alarms, trend logs |
| 37 | +- BBMD / foreign device registration |
| 38 | +- IPv6 |
| 39 | + |
| 40 | +--- |
| 41 | + |
| 42 | +## Quick start |
| 43 | + |
| 44 | +```python |
| 45 | +import asyncio |
| 46 | +from libbacnet import BacnetClient, BacnetAddr, ObjectIdentifier, PropertyIdentifier |
| 47 | + |
| 48 | +async def main(): |
| 49 | + async with BacnetClient(local_addr=("0.0.0.0", 47808)) as client: |
| 50 | + # Discover devices (3-second collection window) |
| 51 | + devices = await client.who_is(wait=3.0) |
| 52 | + print(f"Found {len(devices)} device(s)") |
| 53 | + |
| 54 | + for ev in devices: |
| 55 | + addr = BacnetAddr(ev.src.addr, ev.src.port) |
| 56 | + obj = ObjectIdentifier(object_type=8, instance=ev.message.device_id.instance) |
| 57 | + |
| 58 | + # Read the device object's description — returns a ReadPropertyResult |
| 59 | + result = await client.read_property( |
| 60 | + addr=addr, |
| 61 | + obj_id=obj, |
| 62 | + prop_id=PropertyIdentifier.DESCRIPTION, |
| 63 | + ) |
| 64 | + print(f" {addr} description: {result.value}") |
| 65 | + |
| 66 | +asyncio.run(main()) |
| 67 | +``` |
| 68 | + |
| 69 | +See [`examples/discover_read_write.py`](examples/discover_read_write.py) for a |
| 70 | +longer example that performs Who-Is discovery, reads the object list, reads and |
| 71 | +writes `present-value` on each object, and then reads all values with |
| 72 | +`read_property_multiple`. |
| 73 | + |
| 74 | +--- |
| 75 | + |
| 76 | +## API reference |
| 77 | + |
| 78 | +### `BacnetClient` |
| 79 | + |
| 80 | +High-level asyncio client. Most users should start here. |
| 81 | + |
| 82 | +```python |
| 83 | +async with BacnetClient(config=None, local_addr=("0.0.0.0", 47808)) as client: |
| 84 | + # Confirmed services — return typed result objects |
| 85 | + result = await client.read_property(addr, obj_id, prop_id, array_index=None) |
| 86 | + result = await client.read_property_multiple(addr, request_list) |
| 87 | + await client.write_property(addr, obj_id, prop_id, value, array_index=None, priority=None) |
| 88 | + |
| 89 | + # Discovery — return lists of EventUnconfirmedReceived |
| 90 | + devices = await client.who_is(addr=None, low=None, high=None, wait=3.0) |
| 91 | + routers = await client.who_is_router_to_network(network=None, wait=3.0) |
| 92 | +``` |
| 93 | + |
| 94 | +#### Return types |
| 95 | + |
| 96 | +| Method | Return type | |
| 97 | +|---|---| |
| 98 | +| `read_property` | `ReadPropertyResult` | |
| 99 | +| `read_property_multiple` | `ReadPropertyMultipleResult` | |
| 100 | +| `write_property` | `None` (raises on error) | |
| 101 | +| `who_is` | `list[EventUnconfirmedReceived]` | |
| 102 | +| `who_is_router_to_network` | `list[EventUnconfirmedReceived]` | |
| 103 | + |
| 104 | +**`ReadPropertyResult`** — fields: `object_id`, `property_id`, `array_index`, `value` (`PropertyValue`). |
| 105 | + |
| 106 | +**`ReadPropertyMultipleResult`** — field: `objects` (`list[ObjectResult]`). Each |
| 107 | +`ObjectResult` has `object_id` and `properties` (`list[PropertyResult]`). Each |
| 108 | +`PropertyResult` has either `value` (`PropertyValue`) or `error` |
| 109 | +(`BacnetPropertyError`). |
| 110 | + |
| 111 | +**`PropertyValue`** — one of: `PropertyValueNull`, `PropertyValueBoolean`, |
| 112 | +`PropertyValueUnsigned`, `PropertyValueSigned`, `PropertyValueReal`, |
| 113 | +`PropertyValueDouble`, `PropertyValueOctetString`, `PropertyValueCharacterString`, |
| 114 | +`PropertyValueBitString`, `PropertyValueEnumerated`, `PropertyValueDate`, |
| 115 | +`PropertyValueTime`, `PropertyValueObjectIdentifier`, `PropertyValueArray`, |
| 116 | +`PropertyValueAny`. |
| 117 | + |
| 118 | +### `BacnetProtocol` |
| 119 | + |
| 120 | +Low-level `asyncio.DatagramProtocol`. Use this when you need direct access to |
| 121 | +the event stream or want to integrate the stack into an existing event loop. |
| 122 | + |
| 123 | +```python |
| 124 | +from libbacnet import BacnetProtocol |
| 125 | + |
| 126 | +protocol = BacnetProtocol(config=None, on_event=None) |
| 127 | + |
| 128 | +# Inject inputs manually (e.g. in tests) |
| 129 | +outputs = protocol.send_input(InputSend(service=svc, dest=addr)) |
| 130 | + |
| 131 | +# Consume events from the async queue |
| 132 | +event = await protocol.events.get() |
| 133 | +``` |
| 134 | + |
| 135 | +### `StackConfig` |
| 136 | + |
| 137 | +```python |
| 138 | +from libbacnet import StackConfig |
| 139 | + |
| 140 | +cfg = StackConfig( |
| 141 | + apdu_timeout_secs=3.0, # seconds before retransmit |
| 142 | + apdu_retries=3, # retransmit attempts before Timeout |
| 143 | + max_apdu_length=1476, # outgoing segmentation threshold (bytes) |
| 144 | + max_segment_buffer=2_097_152, # reassembly buffer limit (bytes) |
| 145 | +) |
| 146 | +``` |
| 147 | + |
| 148 | +### Enumerations |
| 149 | + |
| 150 | +`PropertyIdentifier`, `ObjectType`, `ErrorClass`, and `ErrorCode` are Python |
| 151 | +`IntEnum` classes exported directly from `libbacnet`. They support unknown |
| 152 | +values via a `_missing_` fallback that returns a dynamic `UNKNOWN_<value>` |
| 153 | +member, so code never raises on an unrecognised wire value. |
| 154 | + |
| 155 | +```python |
| 156 | +from libbacnet import PropertyIdentifier, ObjectType |
| 157 | + |
| 158 | +prop = PropertyIdentifier.PRESENT_VALUE # 85 |
| 159 | +obj = ObjectType.ANALOG_INPUT # 0 |
| 160 | + |
| 161 | +# Pass enum members directly to client methods |
| 162 | +result = await client.read_property(addr, obj_id, prop_id=PropertyIdentifier.OBJECT_LIST) |
| 163 | +``` |
| 164 | + |
| 165 | +### `ReadAccessSpec` |
| 166 | + |
| 167 | +Used to build `read_property_multiple` requests. |
| 168 | + |
| 169 | +```python |
| 170 | +from libbacnet import ReadAccessSpec, PropertyIdentifier |
| 171 | + |
| 172 | +spec = ReadAccessSpec( |
| 173 | + object_id=obj_id, |
| 174 | + properties=[ |
| 175 | + (PropertyIdentifier.PRESENT_VALUE, None), # (prop_id, array_index) |
| 176 | + (PropertyIdentifier.DESCRIPTION, None), |
| 177 | + ], |
| 178 | +) |
| 179 | +result = await client.read_property_multiple(addr=dest, request_list=[spec]) |
| 180 | +``` |
| 181 | + |
| 182 | +### Exceptions |
| 183 | + |
| 184 | +| Exception | Raised when | |
| 185 | +|---|---| |
| 186 | +| `BacnetTimeoutError` | No response within the retry budget | |
| 187 | +| `BacnetError` | Server sent Error, Abort, or Reject PDU | |
| 188 | +| `InvokeIdExhaustedError` | All 256 invoke IDs for the destination are in use | |
| 189 | + |
| 190 | +--- |
| 191 | + |
| 192 | +## Limitations and known constraints |
| 193 | + |
| 194 | +- **BACnet/IP only** — MS/TP and other data links are not supported. |
| 195 | +- **Client role only** — incoming confirmed requests from other devices are |
| 196 | + silently ignored. |
| 197 | +- **IPv4 only** — `BacnetAddr` stores an IPv4 address; IPv6 BVLC is not |
| 198 | + implemented. |
| 199 | +- **No BBMD** — the library sends `Original-Unicast-NPDU` (0x0A) and |
| 200 | + `Original-Broadcast-NPDU` (0x0B) only. Foreign device registration and |
| 201 | + broadcast distribution are out of scope. |
| 202 | +- **Single-threaded** — `Stack` is not `Send + Sync`; use it from a single |
| 203 | + asyncio event loop thread. |
0 commit comments