This document explains how the rusty-bacnet crates fit together, how data flows through the stack, and how the major subsystems work.
bacnet-types Enums, primitives, error types (no I/O)
|
bacnet-encoding ASN.1 tags, APDU/NPDU codec, property value encode/decode
|
bacnet-services Service request/response structs (RP, WP, RPM, COV, etc.)
|
+---> bacnet-transport Data-link transports (BIP, SC, MS/TP, Ethernet, Loopback)
| |
| bacnet-network Network layer, BACnetRouter, RouterTable
| |
+---> bacnet-objects BACnetObject trait, ObjectDatabase, 65 object types
| |
| bacnet-client Async BACnet client (TSM, segmentation, discovery)
| bacnet-server Async BACnet server (dispatch, COV, events, scheduling)
| |
+---> bacnet-gateway HTTP REST API + MCP server (optional, feature-gated)
| bacnet-cli Interactive shell and CLI tool
| bacnet-btl BTL compliance test harness (3,808 tests)
|
+---> rusty-bacnet Python bindings (PyO3)
bacnet-wasm WASM/JS BACnet/SC thin client
The bottom rows are "application" crates — they compose the library crates into user-facing tools. They are excluded from default-members in the workspace to avoid pulling in their heavy dependencies (clap, axum, rmcp, pyo3, wasm-bindgen) during normal development.
Physical network (UDP socket / WebSocket / serial port)
|
v
TransportPort::start() -> mpsc::Receiver<ReceivedNpdu>
| Decodes data-link framing (BVLL for BIP, BVLC-SC for SC, MS/TP frames)
| Extracts NPDU bytes + source MAC address
v
NetworkLayer::start() -> mpsc::Receiver<ReceivedApdu>
| Decodes NPDU header (version, control, DNET/DADR/SNET/SADR)
| Filters: drops messages not for this device (wrong DNET)
| Extracts APDU bytes + source network/address info
v
Client dispatch task / Server dispatch task
| Decodes APDU header (PDU type, service choice, invoke ID)
| Routes to appropriate handler
v
Service handler (e.g., handle_read_property)
| Decodes service request from APDU payload
| Reads/writes ObjectDatabase
| Encodes response
v
NetworkLayer::send_apdu() -> TransportPort::send_unicast()
| Encodes NPDU header + APDU payload
| Sends via transport
v
Physical network
When BACnetRouter is used (multi-transport gateway):
Transport A (BIP, network 1) ─┐
Transport B (SC, network 2) ─┤──> BACnetRouter
Transport C (MS/TP, network 3) ─┤ |
Loopback (local client/server) ─┘ |
v
RouterTable lookup
|
Forward NPDU to correct transport
The router receives NPDUs from all transports, checks the destination network number in the NPDU header, and forwards to the appropriate transport. Messages for the local device (DNET matches a loopback port) are delivered to the client/server.
All transports implement the TransportPort trait:
pub trait TransportPort: Send + Sync {
fn start(&mut self) -> impl Future<Output = Result<mpsc::Receiver<ReceivedNpdu>, Error>> + Send;
fn stop(&mut self) -> impl Future<Output = Result<(), Error>> + Send;
fn send_unicast(&self, npdu: &[u8], mac: &[u8]) -> impl Future<Output = Result<(), Error>> + Send;
fn send_broadcast(&self, npdu: &[u8]) -> impl Future<Output = Result<(), Error>> + Send;
fn local_mac(&self) -> &[u8];
fn max_apdu_length(&self) -> u16; // BIP/SC: 1476, MS/TP: 480
}MAC address format varies by transport:
- BIP: 6 bytes (4-byte IPv4 + 2-byte port, big-endian)
- BIP6: 18 bytes (16-byte IPv6 + 2-byte port)
- MS/TP: 1 byte (station address 0-254)
- BACnet/SC: 6 bytes (VMAC)
- Ethernet: 6 bytes (IEEE 802 MAC)
- Loopback: arbitrary (synthetic, e.g.,
[0x00, 0x01])
AnyTransport<S> is a type-erased enum wrapping all transport types, enabling mixed-transport routing (e.g., BIP + MS/TP + Loopback on the same router).
RS-485 is half-duplex — the transceiver's DE/RE pin must be toggled between transmit and receive. The stack supports three modes:
┌──────────────────────────┐
USB RS-485 Adapter ──────────>│ TokioSerialPort │ Auto-direction
(FTDI, CH340, etc.) │ (no config needed) │ (hardware handles DE/RE)
└──────────────────────────┘
UART + RTS → DE/RE ──────────>│ TokioSerialPort │ Kernel RS-485
(DE wired to UART RTS pin) │ .enable_kernel_rs485() │ (TIOCSRS485 ioctl)
└──────────────────────────┘
UART + GPIO → DE/RE ─────────>│ GpioDirectionPort<S> │ GPIO direction
(Pi hat, GPIO pin for DE) │ wraps any SerialPort │ (gpiocdev, serial-gpio feature)
└──────────────────────────┘
GpioDirectionPort is a composable wrapper — it wraps any SerialPort and toggles a GPIO pin via the Linux character device API (/dev/gpiochipN) before and after each write. This keeps TokioSerialPort simple and platform-independent.
Every BACnet object implements the BACnetObject trait:
pub trait BACnetObject: Send + Sync {
fn object_identifier(&self) -> ObjectIdentifier;
fn object_name(&self) -> &str;
fn read_property(&self, property: PropertyIdentifier, array_index: Option<u32>) -> Result<PropertyValue, Error>;
fn write_property(&mut self, property: PropertyIdentifier, array_index: Option<u32>, value: PropertyValue, priority: Option<u8>) -> Result<(), Error>;
fn property_list(&self) -> Cow<'static, [PropertyIdentifier]>;
// ... plus COV, intrinsic reporting, scheduling methods
}ObjectDatabase stores Box<dyn BACnetObject> keyed by ObjectIdentifier, with secondary indexes by name (for WhoHas) and by type (for efficient enumeration).
The stack runs on a Tokio multi-threaded runtime.
Lock ordering (server): always lock db (ObjectDatabase) before cov_table (COV subscriptions). Violating this order risks deadlock.
Resource exhaustion caps:
- COV subscriptions: 1,024 max
- BBMD FDT entries: 512
- Objects per database: 10,000
- Router table entries: 256
- Segment receivers: 128 (prevents DoS from abandoned segmented transfers)
- COV in-flight: 255 (matches u8 invoke ID range)
- MS/TP frame buffer: 1,507 bytes
- MS/TP queue: 256 pending NPDUs
Client APDU retry: 3 retries by default, invoke ID reused across retries, cleaned up on final timeout.
The BACnetServer spawns several background tasks:
| Task | Purpose | Interval |
|---|---|---|
| Dispatch | Receives APDUs, routes to service handlers | Event-driven |
| COV purge | Removes expired COV subscriptions | 60s |
| Fault detection | Evaluates analog objects for over/under-range | 10s |
| Event enrollment | Processes intrinsic reporting algorithms | 10s |
| Trend log | Records data samples for trend log objects | Per-object interval |
| Schedule tick | Evaluates weekly schedules and exception dates | 60s |
The server handles 20+ services including ReadProperty, WriteProperty, ReadPropertyMultiple, WritePropertyMultiple, SubscribeCOV, CreateObject, DeleteObject, DeviceCommunicationControl, ReinitializeDevice, GetEventInformation, GetAlarmSummary, LifeSafetyOperation, AtomicReadFile, AtomicWriteFile, TimeSynchronization, and more.
The bacnet-gateway crate adds HTTP and MCP interfaces on top of the core stack. It compiles zero web dependencies by default — the http and mcp features must be explicitly enabled.
┌────────────────────────────────┐
│ HTTP REST API │ MCP Server │ (feature-gated: http, mcp)
│ (Axum) │ (rmcp) │
└───────┬─────────┴──────┬───────┘
│ │
v v
GatewayState (shared)
│
v
BACnetClient + BACnetServer
│
v
BIP Transport (UDP)
GatewayState holds Arc references to the client and shared object database. Both REST handlers and MCP tools call the same methods on GatewayState — no duplicated BACnet logic.
See Gateway Reference for full configuration, endpoint, and tool documentation.