Skip to content

Latest commit

 

History

History
194 lines (152 loc) · 10.9 KB

File metadata and controls

194 lines (152 loc) · 10.9 KB

Architecture Guide

This document explains how the rusty-bacnet crates fit together, how data flows through the stack, and how the major subsystems work.

Crate Dependency Graph

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-endpoint-core  Private endpoint lifecycle, ingress, egress, coordination
    |         |
    +---> bacnet-objects      BACnetObject trait, ObjectDatabase, object implementations
    |         |
    |     bacnet-client       Async BACnet client (TSM, segmentation, discovery)
    |     bacnet-server       Async BACnet server (dispatch, COV, events, scheduling)
    |         |
    +---> bacnet-cli          Interactive shell and CLI tool
    |
    +---> rusty-bacnet        Python bindings (PyO3)

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, pyo3) during normal development.

The HTTP/MCP gateway and BTL compliance test harness now live in dedicated repositories:

Both consume the published bacnet-* crates from this workspace.

Packet Flow

Inbound (receiving a BACnet request)

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 + raw link-layer group provenance + attributes
    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 addressing + raw/effective group facts + attributes
    v
Optional private EndpointIngress classifier / 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

Multi-network routing

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.

Data attributes are carried on ReceivedNpdu and ReceivedApdu, and attribute-aware send helpers are available on TransportPort and NetworkLayer. ReceivedApdu.link_layer_group preserves whether the incoming data-link destination was a group address, while ReceivedApdu.is_group describes the effective BACnet network destination; a routed unicast can therefore have link_layer_group == true and is_group == false. BACnet/SC maps every inbound Annex AB Data Option to these attributes and maps outbound attributes back to SC Data Options. Unknown Data Options do not block NPDU delivery. For received Encapsulated-NPDUs, an unsupported Must Understand Destination Option returns a BVLC-Result NAK with the original option marker to the unicast source through the hub and drops broadcast without a result. This message-level NAK does not close the source connection. A Destination Option without Must Understand does not block delivery. The current DataAttribute.must_understand field stores the Data Option bit-6 value, but Addendum 135-2020cf Every Segment behavior is not implemented. The router preserves inbound data attributes when forwarding unicast or broadcast NPDUs across attribute-capable transports, while data links that do not support attributes expose an empty list on receive and ignore attributes on send.

Transport Abstraction

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
}

TransportPort owns data-link framing and link-specific controls. NetworkLayer owns NPDU addressing and APDU delivery forms. The private bacnet-endpoint-core runtime can own one network lifecycle and expose bounded ingress and network-service egress to application-role adapters; those role handles cannot start or stop the network or transport. This foundation does not add a public combined endpoint API, and it does not claim that B/IP and BACnet/SC operate together as one device.

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 Direction Control (MS/TP)

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.

Object Model

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

Concurrency Model

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.

Server Engine

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
Intrinsic reporting Advances Time_Delay countdowns and fires confirmed transitions via tick_intrinsic_reporting 1s
Event enrollment Evaluates Event Enrollment objects against their monitored properties 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.

Companion projects

The HTTP/MCP gateway and BTL compliance test harness live in separate repositories that consume this workspace's published crates:

  • rusty-bacnet-mcp — HTTP REST API (Axum) and MCP server (rmcp) on top of BACnetClient + BACnetServer. Single shared GatewayState handles both surfaces — no duplicated BACnet logic.
  • rusty-bacnet-btl-harness — external BTL Test Plan 26.1 harness project. Formal support status is tracked separately in the conformance ledger.