Skip to content

Latest commit

 

History

History
1142 lines (874 loc) · 35.7 KB

File metadata and controls

1142 lines (874 loc) · 35.7 KB

Rust API Reference

Rusty BACnet is a workspace of 8 published crates implementing the BACnet protocol stack (ASHRAE 135-2020).

Crate Dependency Order

bacnet-types → bacnet-encoding → bacnet-services → bacnet-transport → bacnet-network
                                                                          ↓
                                                    bacnet-objects → bacnet-client
                                                                          ↓
                                                                   bacnet-server

bacnet-types

Core BACnet types, enums, and error definitions.

Enums (bacnet_enum! macro)

All BACnet enums are generated with bacnet_enum!, which produces a newtype struct with:

  • from_raw(value) / to_raw() — convert to/from raw integer
  • ALL_NAMED: &[(&str, Self)] — named constant list for iteration
  • Display / Debug / PartialEq / Eq / Hash / Copy / Clone
use bacnet_types::enums::*;

let ot = ObjectType::ANALOG_INPUT;
assert_eq!(ot.to_raw(), 0);
assert_eq!(ObjectType::from_raw(0), ot);

Key enums: ObjectType (u32), PropertyIdentifier (u32), ErrorClass (u16), ErrorCode (u16), EnableDisable (u32), ReinitializedState (u32), Segmentation (u8), EventState (u32), EventType (u32), NotifyType (u32), Polarity (u32), Reliability (u32), LifeSafetyOperation (u32), MessagePriority (u32)

Primitives

use bacnet_types::primitives::*;

// Object Identifier (type + instance, max 4194303)
let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1)?;
assert_eq!(oid.object_type(), ObjectType::ANALOG_INPUT);
assert_eq!(oid.instance_number(), 1);

// Property Value — tagged union
let val = PropertyValue::Real(72.5);
let val = PropertyValue::Boolean(true);
let val = PropertyValue::CharacterString("hello".into());
let val = PropertyValue::Null;

Error

use bacnet_types::error::Error;

// Protocol error from a remote device
let e = Error::Protocol { class: 2, code: 31 }; // ErrorClass(2)=PROPERTY, ErrorCode(31)=UNKNOWN_PROPERTY

// Other variants: Timeout, Reject, Abort, RoutedPathTooLong,
// RoutedPathCapacityExceeded, Encoding, etc.

Error::RoutedPathTooLong { dnet } identifies the destination network from a matching network-layer rejection; it does not claim an exact supported length. Error::RoutedPathCapacityExceeded { capacity } reports that all bounded path state is protected by a held/waiting gate or configured/learned evidence, so a new path was rejected before transaction registration or frame emission. Error is a public enum, so these variants can require new arms in downstream exhaustive matches. Matchers with a wildcard arm are unaffected.


bacnet-encoding

ASN.1/BER tag encoding, APDU/NPDU codecs, property value serialization, and segmentation.

Property Value Encoding

use bacnet_encoding::primitives::{encode_property_value, decode_application_value};
use bacnet_types::primitives::PropertyValue;
use bytes::BytesMut;

// Encode
let mut buf = BytesMut::new();
encode_property_value(&mut buf, &PropertyValue::Real(72.5));
let bytes = buf.to_vec();

// Decode
let (value, bytes_consumed) = decode_application_value(&bytes, 0)?;
assert_eq!(value, PropertyValue::Real(72.5));

APDU Types

use bacnet_encoding::apdu::*;

// Confirmed request, Complex ACK, Simple ACK, Error, Reject, Abort
// Segmentation: SegmentAck, segmented confirmed requests

NPDU

use bacnet_encoding::npdu::{NpduHeader, encode_npdu, decode_npdu};

// Handles source/destination network addresses, hop count, priority

bacnet-services

23 BACnet service modules with request/response encoding and decoding.

ReadProperty / WriteProperty

use bacnet_services::rp::{ReadPropertyRequest, ReadPropertyACK};
use bacnet_services::wp::WritePropertyRequest;

ReadPropertyMultiple / WritePropertyMultiple

use bacnet_services::rpm::{ReadAccessSpecification, ReadPropertyMultipleACK, ReadAccessResult};
use bacnet_services::wpm::WriteAccessSpecification;
use bacnet_services::common::{PropertyReference, BACnetPropertyValue};

let spec = ReadAccessSpecification {
    object_identifier: oid,
    list_of_property_references: vec![
        PropertyReference {
            property_identifier: PropertyIdentifier::PRESENT_VALUE,
            property_array_index: None,
        },
    ],
};

COV

use bacnet_services::cov::{
    SubscribeCOVRequest, COVNotificationRequest, UnsubscribeCOVRequest,
};
use bacnet_services::cov_multiple::{
    COVReference, COVSubscriptionSpecification, SubscribeCOVPropertyMultipleRequest,
};

Discovery

use bacnet_services::who_is::{WhoIsRequest, IAmRequest};
use bacnet_services::who_has::{WhoHasRequest, WhoHasObject, IHaveRequest};

Device Management

use bacnet_services::device_mgmt::{
    DeviceCommunicationControlRequest, ReinitializeDeviceRequest,
};

Object Management

use bacnet_services::object_mgmt::{
    CreateObjectRequest, ObjectSpecifier, DeleteObjectRequest,
};

File Services

use bacnet_services::file::{FileAccessMethod, FileWriteAccessMethod};

ReadRange

use bacnet_services::read_range::{RangeSpec, ReadRangeAck};

Alarm/Event

use bacnet_services::alarm_event::{
    AcknowledgeAlarmRequest, GetEventInformationRequest,
    GetAlarmSummaryRequest, GetEnrollmentSummaryRequest,
};

List Manipulation

use bacnet_services::list_manipulation::{AddListElementRequest, RemoveListElementRequest};

Private Transfer

use bacnet_services::private_transfer::{
    ConfirmedPrivateTransferRequest, UnconfirmedPrivateTransferRequest,
};

Text Message

use bacnet_services::text_message::{
    ConfirmedTextMessageRequest, UnconfirmedTextMessageRequest,
};

Life Safety

use bacnet_services::life_safety::LifeSafetyOperationRequest;

Write Group

use bacnet_services::write_group::WriteGroupRequest;

Virtual Terminal

use bacnet_services::vt::{VtOpenRequest, VtCloseRequest, VtDataRequest};

Audit

use bacnet_services::audit::{
    AuditLogQueryAck, AuditLogQueryRequest, AuditNotificationRequest,
    AuditPropertyReference, BACnetAuditLogQueryParameters, BACnetAuditNotification,
};

These models encode the Clause 21 field and tag productions within the library's u64 Unsigned implementation limit. In particular, AuditLogQueryRequest::start_at_sequence_number is Option<u32>, and each query alternative contains successful_actions_only: bool. Clause 13.19 instead describes Unsigned64 and BACnetSuccessFilter; that internal Standard conflict remains unresolved pending authoritative addendum or errata research, so these codecs are not an unqualified Clause 13.19 support claim.


bacnet-transport

Transport-layer implementations. All implement the TransportPort trait.

Feature Flags

Feature Platforms Transport
(default) all BIP (UDP/IPv4)
ipv6 all BIP6 (UDP/IPv6 multicast)
sc-tls all BACnet/SC (WebSocket + TLS) + SC Hub
serial all MS/TP (serial token-passing via tokio-serial)
serial-gpio Linux MS/TP + GPIO direction control (adds gpiocdev)
ethernet Linux BACnet Ethernet (BPF)

BIP (IPv4)

use bacnet_transport::bip::BipTransport;

let transport = BipTransport::new(
    Ipv4Addr::new(0, 0, 0, 0),  // bind interface
    0xBAC0,                       // port (47808)
    Ipv4Addr::BROADCAST,          // broadcast address
);

BIP6 (IPv6)

use bacnet_transport::bip6::Bip6Transport;

let transport = Bip6Transport::new(
    Ipv6Addr::UNSPECIFIED,  // bind interface
    0xBAC0,                 // port
    None,                   // device_instance (auto VMAC)
);
// 3-byte VMAC, 3 multicast scopes, collision detection

BACnet/SC (Client Transport)

use bacnet_transport::sc::ScTransport;
use bacnet_transport::sc_tls::TlsWebSocket;

let ws = TlsWebSocket::connect("wss://hub:1234", tls_config).await?;
let transport = ScTransport::new(ws, vmac)
    .with_heartbeat_interval_ms(30_000)
    .with_heartbeat_timeout_ms(60_000);

Production BACnet/SC transports validate heartbeat settings at start(): the interval must be 3_000..=300_000 ms, and the disconnect timeout must be greater than the interval.

BACnet/SC Hub

use bacnet_transport::sc_hub::ScHub;

let hub = ScHub::new(listen_addr, tls_acceptor, hub_vmac);
let addr = hub.start().await?;  // Returns SocketAddr
// hub.stop().await;

The SC hub is a TLS WebSocket relay. Both clients and servers connect to it as spoke nodes. Messages are routed by VMAC address.

MS/TP (Serial RS-485)

MS/TP is a token-passing protocol over RS-485 serial, commonly used for field-level BACnet devices. The serial I/O is abstracted behind the SerialPort trait, with three RS-485 direction control modes.

Auto-Direction (USB RS-485 Adapters)

Most USB RS-485 adapters (FTDI, CH340, CP2102) handle direction switching in hardware — no configuration needed.

use bacnet_transport::mstp_serial::{TokioSerialPort, SerialConfig};

let serial = TokioSerialPort::open(&SerialConfig {
    port_name: "/dev/ttyUSB0".into(),   // Linux
    // port_name: "/dev/cu.usbserial-xxx".into(),  // macOS
    baud_rate: 76800,
})?;

// Use with BACnetClient or BACnetServer via generic_builder
let client = BACnetClient::generic_builder()
    .transport(MstpTransport::new(serial, 1, 127))  // station 1, max_master 127
    .build()
    .await?;

Kernel RS-485 Mode (Linux, RTS-based)

When DE/RE is wired to the UART's RTS pin, the Linux kernel can toggle it automatically via the TIOCSRS485 ioctl. Zero userspace overhead.

let serial = TokioSerialPort::open(&config)?;
serial.enable_kernel_rs485(
    false,  // invert_rts: false = RTS HIGH during TX
    0,      // delay_before_send_us
    0,      // delay_after_send_us
)?;

GPIO Direction Control (RS-485 Hats)

For RS-485 hats where DE/RE is wired to a GPIO pin (e.g., Seeed Studio RS-485 Shield on Raspberry Pi with GPIO18), use GpioDirectionPort to wrap any SerialPort. Requires the serial-gpio feature.

use bacnet_transport::mstp_serial::{GpioDirectionPort, TokioSerialPort, SerialConfig};

let serial = TokioSerialPort::open(&SerialConfig {
    port_name: "/dev/ttyS0".into(),
    baud_rate: 76800,
})?;

// Wrap with GPIO direction control: gpiochip0, line 18, active-high
let port = GpioDirectionPort::new(serial, "/dev/gpiochip0", 18, true)?;

// Or with explicit post-TX delay (microseconds before switching to RX):
let port = GpioDirectionPort::with_post_tx_delay(
    serial, "/dev/gpiochip0", 18, true, 200,
)?;

The GpioDirectionPort wrapper:

  • Sets GPIO to receive mode (DE deasserted) on creation
  • Switches to TX mode before each write()
  • Waits for optional post-TX delay after write completes
  • Switches back to RX mode after each write()
  • Uses the Linux GPIO character device (/dev/gpiochipN) via gpiocdev — not deprecated sysfs

SerialPort Trait

The MS/TP state machine is hardware-agnostic. Custom serial implementations (e.g., for testing) can implement:

pub trait SerialPort: Send + Sync + 'static {
    fn write(&self, data: &[u8]) -> impl Future<Output = Result<(), Error>> + Send;
    fn read(&self, buf: &mut [u8]) -> impl Future<Output = Result<usize, Error>> + Send;
}

Loopback Transport

use bacnet_transport::loopback::LoopbackTransport;

let (side_a, side_b) = LoopbackTransport::pair(
    vec![0x00, 0x01],  // MAC for side A
    vec![0x00, 0x02],  // MAC for side B
);

In-process channel-based transport for composing a client and server without real network sockets (e.g. inside an HTTP gateway). LoopbackTransport::pair() creates two connected transports backed by tokio::sync::mpsc channels — sending on one delivers to the other. Available as AnyTransport::Loopback for use with the enum dispatch wrapper.

AnyTransport (enum dispatch)

use bacnet_transport::any::AnyTransport;
use bacnet_transport::mstp::NoSerial; // placeholder when serial feature is off

let transport: AnyTransport<NoSerial> = AnyTransport::Bip(bip_transport);

Variants: Bip, Bip6, Mstp, Sc (boxed), Loopback.

BBMD

use std::net::Ipv4Addr;
use std::path::PathBuf;

use bacnet_transport::bbmd::BdtEntry;
use bacnet_transport::bip::{BipTransport, DEFAULT_BACNET_PORT};

let mut transport = BipTransport::new(
    Ipv4Addr::UNSPECIFIED,
    DEFAULT_BACNET_PORT,
    Ipv4Addr::BROADCAST,
);

transport.enable_bbmd(vec![BdtEntry {
    ip: [192, 168, 1, 10],
    port: DEFAULT_BACNET_PORT,
    broadcast_mask: [255, 255, 255, 255],
}]);

// Optional: persist successful legacy Write-BDT updates and reload them on restart.
transport.set_bdt_persist_path(PathBuf::from("/var/lib/rusty-bacnet/bdt.bin"));

// Optional: restrict Write-BDT and Delete-FDT management operations.
// An empty ACL allows all sources.
transport.set_bbmd_management_acl(vec![[192, 168, 1, 100]]);

bacnet-network

Network layer routing, router tables, and the multi-port router.

use bacnet_network::network_layer::NetworkLayer;
use bacnet_network::router::BACnetRouter;

bacnet-objects

BACnet object model: trait, database, and object implementations.

BACnetObject Trait

use bacnet_objects::traits::BACnetObject;

// Every object type implements:
trait BACnetObject {
    fn object_identifier(&self) -> ObjectIdentifier;
    fn object_name(&self) -> &str;
    fn object_type(&self) -> ObjectType;
    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) -> Vec<PropertyIdentifier>;
}

ObjectDatabase

use bacnet_objects::database::ObjectDatabase;

let mut db = ObjectDatabase::new();
db.add(Box::new(analog_input));

let obj = db.get(&oid);                // Option<&dyn BACnetObject>
let obj = db.get_mut(&oid);            // Option<&mut Box<dyn BACnetObject>>

Object Types (62)

Core I/O (9)

Type Constructor
AnalogInputObject ::new(instance, name, units)
AnalogOutputObject ::new(instance, name, units)
AnalogValueObject ::new(instance, name, units)
BinaryInputObject ::new(instance, name)
BinaryOutputObject ::new(instance, name)
BinaryValueObject ::new(instance, name)
MultiStateInputObject ::new(instance, name, number_of_states)
MultiStateOutputObject ::new(instance, name, number_of_states)
MultiStateValueObject ::new(instance, name, number_of_states)

Schedule & Notification (5)

Type Constructor
CalendarObject ::new(instance, name)
ScheduleObject ::new(instance, name, default_value)
NotificationClass ::new(instance, name)
AlertEnrollmentObject ::new(instance, name)
EventEnrollmentObject ::new(instance, name, event_type)

Logging & Trending (5)

Type Constructor
TrendLogObject ::new(instance, name, buffer_size)
TrendLogMultipleObject ::new(instance, name, buffer_size)
EventLogObject ::new(instance, name, buffer_size)
AuditLogObject ::new(instance, name, buffer_size, persistence)
AuditReporterObject ::new(instance, name)

Building Control (7)

Type Constructor
LoopObject ::new(instance, name, output_units)
CommandObject ::new(instance, name)
TimerObject ::new(instance, name)
LoadControlObject ::new(instance, name)
ProgramObject ::new(instance, name)
AveragingObject ::new(instance, name)
StagingObject ::new(instance, name, num_stages)

Lighting & Color (4)

Type Constructor
LightingOutputObject ::new(instance, name)
BinaryLightingOutputObject ::new(instance, name)
ColorObject ::new(instance, name)
ColorTemperatureObject ::new(instance, name)

Life Safety (2)

Type Constructor
LifeSafetyPointObject ::new(instance, name)
LifeSafetyZoneObject ::new(instance, name)

Access Control (7)

Type Constructor
AccessDoorObject ::new(instance, name)
AccessPointObject ::new(instance, name)
AccessCredentialObject ::new(instance, name)
AccessUserObject ::new(instance, name)
AccessRightsObject ::new(instance, name)
AccessZoneObject ::new(instance, name)
CredentialDataInputObject ::new(instance, name)

Transportation (3)

Type Constructor
ElevatorGroupObject ::new(instance, name)
EscalatorObject ::new(instance, name)
LiftObject ::new(instance, name, num_floors)

Groups & Views (3)

Type Constructor
GroupObject ::new(instance, name)
GlobalGroupObject ::new(instance, name)
StructuredViewObject ::new(instance, name)

Measurement (2)

Type Constructor
AccumulatorObject ::new(instance, name, units)
PulseConverterObject ::new(instance, name, units)

System (3)

Type Constructor
DeviceObject ::new(DeviceConfig { .. })
FileObject ::new(instance, name, file_type)
NetworkPortObject ::new(instance, name, network_type)

Extended Value Types (12)

Type Constructor
IntegerValueObject ::new(instance, name)
PositiveIntegerValueObject ::new(instance, name)
LargeAnalogValueObject ::new(instance, name)
CharacterStringValueObject ::new(instance, name)
OctetStringValueObject ::new(instance, name)
BitStringValueObject ::new(instance, name)
DateValueObject ::new(instance, name)
TimeValueObject ::new(instance, name)
DateTimeValueObject ::new(instance, name)
DatePatternValueObject ::new(instance, name)
TimePatternValueObject ::new(instance, name)
DateTimePatternValueObject ::new(instance, name)

bacnet-client

Async BACnet client with transaction state machine, segmentation, and discovery.

Building a Client

use bacnet_client::client::BACnetClient;

// Generic builder — accepts any pre-built TransportPort
let client = BACnetClient::generic_builder()
    .transport(transport)
    .apdu_timeout_ms(6000)
    .build()
    .await?;

// BIP-specific builder — constructs BipTransport from interface/port/broadcast
let client = BACnetClient::bip_builder()
    .interface(Ipv4Addr::UNSPECIFIED)
    .port(0)
    .broadcast_address(Ipv4Addr::BROADCAST)
    .build()
    .await?;

// SC-specific builder (requires `sc-tls` feature)
let client = BACnetClient::sc_builder()
    .hub_url("wss://hub:1234")
    .tls_config(tls_config)
    .vmac([0, 1, 2, 3, 4, 5])
    .build()
    .await?;

BACnetClient::builder() is an alias for bip_builder().

Routed Confirmed-Request Limits

Routed confirmed requests size each outgoing APDU to the smallest applicable peer, local-transport, and routed-path allowance before registering a transaction or emitting a frame. The local allowance retains the transport's live maximum and the current routed destination-header cost. The routed-path allowance is an NPDU limit: an unknown path starts from a conservative 228-octet NPDU envelope, then subtracts the forwarded header containing both the destination address and the client's actual local source MAC. For example, six-octet destination and source addresses leave 207 APDU octets.

Applications with path-specific evidence can configure the NPDU envelope without changing ClientConfig:

client
    .configure_routed_path_max_npdu(&router_mac, dnet, 1497)
    .await?;

// Restore the conservative unknown-path policy.
client.clear_routed_path_limit(&router_mac, dnet).await?;

State is keyed by the immediate router MAC together with DNET. One confirmed request at a time owns that path; requests through a different router or to a different DNET remain independent, and direct requests bypass this state. A matching Reject-Message-To-Network reason 4 completes only the active owner as Error::RoutedPathTooLong { dnet } and records the attempted NPDU length as an exclusive upper bound. Learned negative evidence lasts for the client lifetime and has no widening TTL. Both configuration methods wait for an active owner; configuring replaces the prior value and deliberately resets learned evidence, while clearing removes configured and learned evidence. Active Clause 19.4 path probing and cache persistence across process restarts are not provided.

The client retains at most 256 routed-path entries. At capacity it deterministically reclaims the least-recently-used entry only when it has no configured or learned evidence and its gate has neither an owner nor waiters. Configured and learned safety evidence is never silently evicted. If no entry is safely reclaimable, the operation returns Error::RoutedPathCapacityExceeded { capacity: 256 } before TSM registration or frame emission.

An ambiguously terminated send (including cancellation, timeout, or send failure) quarantines its path for the configured APDU timeout multiplied by the configured attempt count. The same-path gate remains exclusive during that interval, and network controls already observed at ingress before the next generation activates are discarded using a monotonic ingress sequence. A source-correlated terminal response after one attempted frame can end the generation without quarantine; multi-frame or retried generations remain conservative.

Property Access

// ReadProperty
let ack = client.read_property(&mac, oid, PropertyIdentifier::PRESENT_VALUE, None).await?;
let (value, _) = decode_application_value(&ack.property_value, 0)?;

// WriteProperty
let mut buf = BytesMut::new();
encode_property_value(&mut buf, &PropertyValue::Real(72.5));
client.write_property(&mac, oid, PropertyIdentifier::PRESENT_VALUE, None, buf.to_vec(), Some(8)).await?;

// ReadPropertyMultiple
let specs = vec![ReadAccessSpecification { object_identifier: oid, list_of_property_references: refs }];
let ack = client.read_property_multiple(&mac, specs).await?;

// WritePropertyMultiple
let specs = vec![WriteAccessSpecification { object_identifier: oid, list_of_properties: props }];
client.write_property_multiple(&mac, specs).await?;

COV Subscriptions

// Subscribe
client.subscribe_cov(&mac, process_id, oid, true, Some(300)).await?;

// Subscribe to multiple properties at once
let cov_specs = vec![COVSubscriptionSpecification {
    monitored_object_identifier: oid,
    list_of_cov_references: vec![COVReference {
        monitored_property: PropertyReference {
            property_identifier: PropertyIdentifier::PRESENT_VALUE,
            property_array_index: None,
        },
        cov_increment: Some(0.5),
        timestamped: true,
    }],
}];
let request = SubscribeCOVPropertyMultipleRequest {
    subscriber_process_identifier: process_id,
    issue_confirmed_notifications: true,
    lifetime: Some(300),
    max_notification_delay: Some(10),
    list_of_cov_subscription_specifications: cov_specs,
};
let mut service_data = bytes::BytesMut::new();
request.try_encode(&mut service_data)?;
client
    .confirmed_request(
        &mac,
        ConfirmedServiceChoice::SUBSCRIBE_COV_PROPERTY_MULTIPLE,
        &service_data,
    )
    .await?;

// Receive notifications (broadcast channel — multiple consumers OK)
let mut rx = client.cov_notifications();
let notification: COVNotificationRequest = rx.recv().await?;

// Unsubscribe
client.unsubscribe_cov(&mac, process_id, oid).await?;

Discovery

client.who_is(None, None).await?;                       // broadcast
client.who_has(WhoHasObject::Name("Zone Temp".into()), None, None).await?;
client.who_am_i().await?;                               // network path verification

let devices = client.discovered_devices().await;         // Vec<DiscoveredDevice>
let device = client.get_device(1234).await;              // Option<DiscoveredDevice>
client.clear_devices().await;                            // reset table

Device Management

client.device_communication_control(&mac, EnableDisable::DISABLE, Some(60), Some("password".into())).await?;
client.reinitialize_device(&mac, ReinitializedState::WARMSTART, None).await?;

Object Management

client.create_object(&mac, ObjectSpecifier::Type(ObjectType::ANALOG_INPUT), initial_values).await?;
client.delete_object(&mac, oid).await?;

Alarms & Events

client.acknowledge_alarm(&mac, process_id, oid, event_state, "operator").await?;
let raw = client.get_event_information(&mac, None).await?;
let raw = client.get_alarm_summary(&mac).await?;
let raw = client.get_enrollment_summary(&mac, ack_filter, event_state, event_type, min_pri, max_pri, notif_class).await?;

Life Safety

client.life_safety_operation(&mac, process_id, "operator", LifeSafetyOperation::SILENCE, Some(oid)).await?;

File Services

let raw = client.atomic_read_file(&mac, file_oid, FileAccessMethod::Stream { file_start_position: 0, requested_octet_count: 1024 }).await?;
client.atomic_write_file(&mac, file_oid, FileWriteAccessMethod::Stream { file_start_position: 0, file_data: data }).await?;

ReadRange

let ack = client.read_range(&mac, oid, PropertyIdentifier::LOG_BUFFER, None, Some(RangeSpec::ByPosition { reference_index: 1, count: 10 })).await?;

List Manipulation

client.add_list_element(&mac, oid, PropertyIdentifier::OBJECT_LIST, None, element_bytes).await?;
client.remove_list_element(&mac, oid, PropertyIdentifier::OBJECT_LIST, None, element_bytes).await?;

Private Transfer

let raw = client.confirmed_private_transfer(&mac, vendor_id, service_number, Some(params)).await?;
client.unconfirmed_private_transfer(&mac, vendor_id, service_number, Some(params)).await?;

Text Messages

let raw = client.confirmed_text_message(&mac, device_oid, priority, "Fire alarm", class_type, class_value).await?;
client.unconfirmed_text_message(&mac, device_oid, priority, "Status update", None, None).await?;

Write Group

client.write_group(&mac, group_number, write_priority, change_list, Some(false)).await?;

Virtual Terminal

let raw = client.vt_open(&mac, vt_class).await?;
client.vt_close(&mac, &session_ids).await?;
let raw = client.vt_data(&mac, session_id, &data, data_flag).await?;

Audit Services

use bacnet_services::audit::{
    AuditLogQueryAck, AuditLogQueryRequest, AuditNotificationRequest,
};
use bacnet_types::enums::{ConfirmedServiceChoice, UnconfirmedServiceChoice};
use bytes::BytesMut;

let notification_request: AuditNotificationRequest = /* build typed request */;
let mut service_data = BytesMut::new();
notification_request.try_encode(&mut service_data)?;
client.confirmed_request(
    &mac,
    ConfirmedServiceChoice::CONFIRMED_AUDIT_NOTIFICATION,
    &service_data,
).await?;

client.unconfirmed_request(
    &mac,
    UnconfirmedServiceChoice::UNCONFIRMED_AUDIT_NOTIFICATION,
    &service_data,
).await?;

let query_request: AuditLogQueryRequest = /* build typed request */;
let mut query_data = BytesMut::new();
query_request.try_encode(&mut query_data)?;
let raw_ack = client.confirmed_request(
    &mac,
    ConfirmedServiceChoice::AUDIT_LOG_QUERY,
    &query_data,
).await?;
let query_ack = AuditLogQueryAck::decode(&raw_ack)?;

These remain generic-client examples. The bundled server executes AuditLogQuery against the retained in-memory snapshot of an explicitly backed AuditLogObject, returning newest-first typed records through the existing ComplexACK segmentation path. ConfirmedAuditNotification and UnconfirmedAuditNotification receipt are available only when the server is configured with exactly one audit_notification_sink and the corresponding fast audit_notification_authorizer or unconfirmed_audit_notification_authorizer; missing, false, or panicking policy fails closed. Each policy receives the immediate MAC, optional routed NPDU source, configured sink, and decoded request separately from the peer-reported payload; only the confirmed context has an invoke ID. Accepted lists merge or create records atomically through the sink's durable backend. Confirmed duplicate detection is bounded and process-local (60 seconds / 256 exact requests) and silently discards detected retransmissions rather than replaying responses. Unconfirmed receipt never emits a response and does not use duplicate tracking. Synchronous persistence under the database writer is an intentional availability limitation. Query authorization, sustained rate limiting, durable idempotency, producer/report generation, forwarding, multi-log routing policy, failures-only filtering, and a wrap-safe 64-bit continuation are not provided. Executed-service bit 46 represents receipt only; no Audit Reporting BIBB, including AR-L-A, is claimed.


bacnet-server

Async BACnet server that hosts objects and dispatches incoming requests.

Building a Server

use bacnet_server::server::BACnetServer;

// Generic builder — accepts any pre-built TransportPort
let server = BACnetServer::generic_builder()
    .database(db)
    .transport(transport)
    .build()
    .await?;

// BIP-specific builder — constructs BipTransport from interface/port/broadcast
let server = BACnetServer::bip_builder()
    .database(db)
    .interface(Ipv4Addr::UNSPECIFIED)
    .port(0xBAC0)
    .broadcast_address(Ipv4Addr::BROADCAST)
    .life_safety_operation_authorizer(|context| {
        // Use authenticated deployment identity where available; the
        // Requesting Source string is peer-controlled descriptive text.
        allowed_life_safety_peer(&context.source_mac, context.source_network.as_ref())
    })
    .build()
    .await?;

// SC-specific builder (requires `sc-tls` feature)
let server = BACnetServer::sc_builder()
    .database(db)
    .hub_url("wss://hub:1234")
    .tls_config(tls_config)
    .vmac([0, 1, 2, 3, 4, 5])
    .build()
    .await?;

// Access the database at runtime
let db = server.database().lock().await;
let value = db.get(&oid).unwrap().read_property(pid, None)?;

// Check communication state
let state = server.comm_state(); // 0=Enable, 1=Disable, 2=DisableInitiation

// Stop
server.stop().await?;

BACnetServer::builder() is an alias for bip_builder().

Inbound LifeSafetyOperation is fail-closed unless an authorizer is configured. The built-in Life Safety Point and Zone objects execute the six silence and unsilence operations. Targeted reset variants return OBJECT / VALUE_OUT_OF_RANGE until application-executor and duplicate-response semantics are available; targetless reset requests complete the required all-applicable attempt without mutating built-in objects.

Trusted runtime logic can arm or rearm a Life Safety object through BACnetServer::set_life_safety_operation_expected_local. The lower-level BACnetObject::set_life_safety_operation_expected_internal channel also remains available to custom database owners. Protocol WriteProperty and WritePropertyMultiple cannot forge Operation_Expected or Silenced.

Handled Services

The server automatically dispatches:

Confirmed:

  • ReadProperty, WriteProperty
  • ReadPropertyMultiple, WritePropertyMultiple
  • SubscribeCOV, SubscribeCOVProperty, SubscribeCOVPropertyMultiple
  • CreateObject, DeleteObject
  • DeviceCommunicationControl, ReinitializeDevice
  • GetEventInformation, AcknowledgeAlarm
  • GetAlarmSummary, GetEnrollmentSummary
  • ConfirmedTextMessage
  • LifeSafetyOperation (authorized silence/unsilence; built-in reset is unsupported)
  • ConfirmedAuditNotification (explicit sink and fail-closed authorizer; process-local duplicate detection)
  • AuditLogQuery (retained records; no query authorization or failures-only mode)
  • ReadRange
  • AtomicReadFile, AtomicWriteFile
  • AddListElement, RemoveListElement

Unconfirmed:

  • WhoIs / IAm
  • WhoHas / IHave
  • TimeSynchronization, UTCTimeSynchronization
  • UnconfirmedTextMessage
  • UnconfirmedAuditNotification (explicit sink and distinct fail-closed authorizer; no response or duplicate tracking)

Outgoing (server-initiated):

  • COV notifications (confirmed and unconfirmed, with ServerTsm retry for confirmed)
  • Event notifications (confirmed and unconfirmed, routed via NotificationClass recipients)

Concurrency

  • Lock ordering: always db before cov_table
  • seg_receivers capped at 128 (DoS prevention)
  • cov_in_flight semaphore: max 255 concurrent confirmed COV notifications
  • comm_state: Arc<AtomicU8> — lock-free read

Error Handling

All async operations return Result<T, bacnet_types::error::Error>. Key variants:

Variant Meaning
Error::Protocol { class, code } Remote BACnet error response
Error::Timeout(msg) APDU retry exhausted
Error::Reject { reason } Remote device rejected request
Error::Abort { reason } Remote device aborted request
Error::RoutedPathTooLong { dnet } Router rejected the active message as too long for DNET
Error::RoutedPathCapacityExceeded { capacity } No routed-path entry can be allocated without discarding protected safety state
Error::Encoding(msg) Malformed packet
Error::Io(io_error) Transport I/O failure

Transport Configuration Examples

BIP Client + Server

use bacnet_client::client::BACnetClient;
use bacnet_server::server::BACnetServer;
use bacnet_transport::bip::BipTransport;
use std::net::Ipv4Addr;

// Client
let client = BACnetClient::bip_builder()
    .interface(Ipv4Addr::UNSPECIFIED)
    .port(0)
    .broadcast_address(Ipv4Addr::BROADCAST)
    .build()
    .await?;

// Server
let server = BACnetServer::bip_builder()
    .database(db)
    .interface(Ipv4Addr::UNSPECIFIED)
    .port(0xBAC0)
    .broadcast_address(Ipv4Addr::BROADCAST)
    .build()
    .await?;

BIP6 (IPv6)

use bacnet_transport::bip6::Bip6Transport;
use std::net::Ipv6Addr;

let transport = Bip6Transport::new(Ipv6Addr::UNSPECIFIED, 0xBAC0, None);
let client = BACnetClient::generic_builder().transport(transport).build().await?;

BACnet/SC with Hub

use bacnet_transport::sc::ScTransport;
use bacnet_transport::sc_hub::ScHub;
use bacnet_transport::sc_tls::{TlsWebSocket, build_tls_config};

// Start hub
let hub = ScHub::new(listen_addr, tls_acceptor, [0xFF, 0, 0, 0, 0, 1]);
let hub_addr = hub.start().await?;

// Connect client to hub
let client = BACnetClient::sc_builder()
    .hub_url(&format!("wss://127.0.0.1:{}", hub_addr.port()))
    .tls_config(tls_config)
    .vmac([0, 1, 2, 3, 4, 5])
    .build()
    .await?;

MS/TP with USB Adapter

use bacnet_transport::mstp::MstpTransport;
use bacnet_transport::mstp_serial::{TokioSerialPort, SerialConfig};

let serial = TokioSerialPort::open(&SerialConfig {
    port_name: "/dev/ttyUSB0".into(),
    baud_rate: 76800,
})?;

let client = BACnetClient::generic_builder()
    .transport(MstpTransport::new(serial, 1, 127))
    .build()
    .await?;

MS/TP with Raspberry Pi RS-485 Hat (GPIO)

use bacnet_transport::mstp::MstpTransport;
use bacnet_transport::mstp_serial::{GpioDirectionPort, TokioSerialPort, SerialConfig};

let serial = TokioSerialPort::open(&SerialConfig {
    port_name: "/dev/ttyS0".into(),
    baud_rate: 76800,
})?;

// Seeed Studio RS-485 Shield: GPIO18 for DE/RE, active-high
let port = GpioDirectionPort::new(serial, "/dev/gpiochip0", 18, true)?;

let client = BACnetClient::generic_builder()
    .transport(MstpTransport::new(port, 1, 127))
    .build()
    .await?;