Skip to content

Commit a9c286a

Browse files
committed
feat(python): add asyncio client interface
1 parent fe91e4e commit a9c286a

13 files changed

Lines changed: 3188 additions & 0 deletions

README.md

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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 (v1)
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+
## Build
43+
44+
### Prerequisites
45+
46+
- Rust 1.75+ (`rustup toolchain install stable`)
47+
- Python 3.12+
48+
- [`maturin`](https://maturin.rs) (`uv tool install maturin`)
49+
50+
### Development build
51+
52+
```bash
53+
# clone and enter the repo
54+
git clone https://github.com/example/libbacnet
55+
cd libbacnet
56+
57+
# create a virtual environment and install dev dependencies
58+
uv sync
59+
60+
# compile the Rust extension and install it into the venv (editable)
61+
maturin develop
62+
```
63+
64+
### Release build (wheel)
65+
66+
```bash
67+
maturin build --release
68+
uv pip install target/wheels/libbacnet-*.whl
69+
```
70+
71+
---
72+
73+
## Quick start
74+
75+
```python
76+
import asyncio
77+
from libbacnet import BacnetClient, BacnetAddr, ObjectIdentifier, PropertyIdentifier
78+
79+
async def main():
80+
async with BacnetClient(local_addr=("0.0.0.0", 47808)) as client:
81+
# Discover devices (3-second collection window)
82+
devices = await client.who_is(wait=3.0)
83+
print(f"Found {len(devices)} device(s)")
84+
85+
for ev in devices:
86+
addr = BacnetAddr(ev.src.addr, ev.src.port)
87+
obj = ObjectIdentifier(object_type=8, instance=ev.message.device_id.instance)
88+
89+
# Read the device object's description — returns a ReadPropertyResult
90+
result = await client.read_property(
91+
addr=addr,
92+
obj_id=obj,
93+
prop_id=PropertyIdentifier.DESCRIPTION,
94+
)
95+
print(f" {addr} description: {result.value}")
96+
97+
asyncio.run(main())
98+
```
99+
100+
See [`examples/discover_read_write.py`](examples/discover_read_write.py) for a
101+
longer example that performs Who-Is discovery, reads the object list, reads and
102+
writes `present-value` on each object, and then reads all values with
103+
`read_property_multiple`.
104+
105+
---
106+
107+
## API reference
108+
109+
### `BacnetClient`
110+
111+
High-level asyncio client. Most users should start here.
112+
113+
```python
114+
async with BacnetClient(config=None, local_addr=("0.0.0.0", 47808)) as client:
115+
# Confirmed services — return typed result objects
116+
result = await client.read_property(addr, obj_id, prop_id, array_index=None)
117+
result = await client.read_property_multiple(addr, request_list)
118+
await client.write_property(addr, obj_id, prop_id, value, array_index=None, priority=None)
119+
120+
# Discovery — return lists of EventUnconfirmedReceived
121+
devices = await client.who_is(addr=None, low=None, high=None, wait=3.0)
122+
routers = await client.who_is_router_to_network(network=None, wait=3.0)
123+
```
124+
125+
#### Return types
126+
127+
| Method | Return type |
128+
|---|---|
129+
| `read_property` | `ReadPropertyResult` |
130+
| `read_property_multiple` | `ReadPropertyMultipleResult` |
131+
| `write_property` | `None` (raises on error) |
132+
| `who_is` | `list[EventUnconfirmedReceived]` |
133+
| `who_is_router_to_network` | `list[EventUnconfirmedReceived]` |
134+
135+
**`ReadPropertyResult`** — fields: `object_id`, `property_id`, `array_index`, `value` (`PropertyValue`).
136+
137+
**`ReadPropertyMultipleResult`** — field: `objects` (`list[ObjectResult]`). Each
138+
`ObjectResult` has `object_id` and `properties` (`list[PropertyResult]`). Each
139+
`PropertyResult` has either `value` (`PropertyValue`) or `error`
140+
(`BacnetPropertyError`).
141+
142+
**`PropertyValue`** — one of: `PropertyValueNull`, `PropertyValueBoolean`,
143+
`PropertyValueUnsigned`, `PropertyValueSigned`, `PropertyValueReal`,
144+
`PropertyValueDouble`, `PropertyValueOctetString`, `PropertyValueCharacterString`,
145+
`PropertyValueBitString`, `PropertyValueEnumerated`, `PropertyValueDate`,
146+
`PropertyValueTime`, `PropertyValueObjectIdentifier`, `PropertyValueArray`,
147+
`PropertyValueAny`.
148+
149+
### `BacnetProtocol`
150+
151+
Low-level `asyncio.DatagramProtocol`. Use this when you need direct access to
152+
the event stream or want to integrate the stack into an existing event loop.
153+
154+
```python
155+
from libbacnet import BacnetProtocol
156+
157+
protocol = BacnetProtocol(config=None, on_event=None)
158+
159+
# Inject inputs manually (e.g. in tests)
160+
outputs = protocol.send_input(InputSend(service=svc, dest=addr))
161+
162+
# Consume events from the async queue
163+
event = await protocol.events.get()
164+
```
165+
166+
### `StackConfig`
167+
168+
```python
169+
from libbacnet import StackConfig
170+
171+
cfg = StackConfig(
172+
apdu_timeout_secs=3.0, # seconds before retransmit
173+
apdu_retries=3, # retransmit attempts before Timeout
174+
max_apdu_length=1476, # outgoing segmentation threshold (bytes)
175+
max_segment_buffer=2_097_152, # reassembly buffer limit (bytes)
176+
)
177+
```
178+
179+
### Enumerations
180+
181+
`PropertyIdentifier`, `ObjectType`, `ErrorClass`, and `ErrorCode` are Python
182+
`IntEnum` classes exported directly from `libbacnet`. They support unknown
183+
values via a `_missing_` fallback that returns a dynamic `UNKNOWN_<value>`
184+
member, so code never raises on an unrecognised wire value.
185+
186+
```python
187+
from libbacnet import PropertyIdentifier, ObjectType
188+
189+
prop = PropertyIdentifier.PRESENT_VALUE # 85
190+
obj = ObjectType.ANALOG_INPUT # 0
191+
192+
# Pass enum members directly to client methods
193+
result = await client.read_property(addr, obj_id, prop_id=PropertyIdentifier.OBJECT_LIST)
194+
```
195+
196+
### `ReadAccessSpec`
197+
198+
Used to build `read_property_multiple` requests.
199+
200+
```python
201+
from libbacnet import ReadAccessSpec, PropertyIdentifier
202+
203+
spec = ReadAccessSpec(
204+
object_id=obj_id,
205+
properties=[
206+
(PropertyIdentifier.PRESENT_VALUE, None), # (prop_id, array_index)
207+
(PropertyIdentifier.DESCRIPTION, None),
208+
],
209+
)
210+
result = await client.read_property_multiple(addr=dest, request_list=[spec])
211+
```
212+
213+
### Exceptions
214+
215+
| Exception | Raised when |
216+
|---|---|
217+
| `BacnetTimeoutError` | No response within the retry budget |
218+
| `BacnetError` | Server sent Error, Abort, or Reject PDU |
219+
| `InvokeIdExhaustedError` | All 256 invoke IDs for the destination are in use |
220+
221+
---
222+
223+
## Running tests
224+
225+
```bash
226+
# Unit and integration tests (no real device required)
227+
uv run pytest -v
228+
229+
# Rust unit tests (activate the venv first so PyO3 links against the correct Python)
230+
source .venv/bin/activate
231+
cargo test
232+
233+
# Linting
234+
cargo clippy
235+
cargo fmt --check
236+
uv run ruff format python/ tests/
237+
uv run ruff check python/ tests/
238+
```
239+
240+
---
241+
242+
## Limitations and known constraints
243+
244+
- **BACnet/IP only** — MS/TP and other data links are not supported.
245+
- **Client role only** — incoming confirmed requests from other devices are
246+
silently ignored.
247+
- **IPv4 only**`BacnetAddr` stores an IPv4 address; IPv6 BVLC is not
248+
implemented.
249+
- **No BBMD** — the library sends `Original-Unicast-NPDU` (0x0A) and
250+
`Original-Broadcast-NPDU` (0x0B) only. Foreign device registration and
251+
broadcast distribution are out of scope.
252+
- **Single-threaded**`Stack` is not `Send + Sync`; use it from a single
253+
asyncio event loop thread.

0 commit comments

Comments
 (0)