Skip to content

Commit 0f602d2

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

15 files changed

Lines changed: 3187 additions & 0 deletions

CONTRIBUTING.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Contributing
2+
3+
## Build
4+
5+
### Prerequisites
6+
7+
- Rust 1.75+ (`rustup toolchain install stable`)
8+
- Python 3.12+
9+
- [`maturin`](https://maturin.rs) (`uv tool install maturin`)
10+
11+
### Development build
12+
13+
```bash
14+
# clone and enter the repo
15+
git clone https://github.com/example/libbacnet
16+
cd libbacnet
17+
18+
# create a virtual environment and install dev dependencies
19+
uv sync
20+
21+
# compile the Rust extension and install it into the venv (editable)
22+
maturin develop
23+
```
24+
25+
### Release build (wheel)
26+
27+
```bash
28+
maturin build --release
29+
uv pip install target/wheels/libbacnet-*.whl
30+
```
31+
32+
---
33+
34+
## Running tests
35+
36+
```bash
37+
# Unit and integration tests (no real device required)
38+
uv run pytest -v
39+
40+
# Rust unit tests (activate the venv first so PyO3 links against the correct Python)
41+
source .venv/bin/activate
42+
cargo test
43+
44+
# Linting
45+
cargo clippy
46+
cargo fmt --check
47+
uv run ruff format python/ tests/
48+
uv run ruff check python/ tests/
49+
```

README.md

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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+
## 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

Comments
 (0)