Rusty BACnet is a workspace of 8 published crates implementing the BACnet protocol stack (ASHRAE 135-2020).
bacnet-types → bacnet-encoding → bacnet-services → bacnet-transport → bacnet-network
↓
bacnet-objects → bacnet-client
↓
bacnet-server
Core BACnet types, enums, and error definitions.
All BACnet enums are generated with bacnet_enum!, which produces a newtype struct with:
from_raw(value)/to_raw()— convert to/from raw integerALL_NAMED: &[(&str, Self)]— named constant list for iterationDisplay/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)
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;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.
ASN.1/BER tag encoding, APDU/NPDU codecs, property value serialization, and segmentation.
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));use bacnet_encoding::apdu::*;
// Confirmed request, Complex ACK, Simple ACK, Error, Reject, Abort
// Segmentation: SegmentAck, segmented confirmed requestsuse bacnet_encoding::npdu::{NpduHeader, encode_npdu, decode_npdu};
// Handles source/destination network addresses, hop count, priority23 BACnet service modules with request/response encoding and decoding.
use bacnet_services::rp::{ReadPropertyRequest, ReadPropertyACK};
use bacnet_services::wp::WritePropertyRequest;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,
},
],
};use bacnet_services::cov::{
SubscribeCOVRequest, COVNotificationRequest, UnsubscribeCOVRequest,
};
use bacnet_services::cov_multiple::{
COVReference, COVSubscriptionSpecification, SubscribeCOVPropertyMultipleRequest,
};use bacnet_services::who_is::{WhoIsRequest, IAmRequest};
use bacnet_services::who_has::{WhoHasRequest, WhoHasObject, IHaveRequest};use bacnet_services::device_mgmt::{
DeviceCommunicationControlRequest, ReinitializeDeviceRequest,
};use bacnet_services::object_mgmt::{
CreateObjectRequest, ObjectSpecifier, DeleteObjectRequest,
};use bacnet_services::file::{FileAccessMethod, FileWriteAccessMethod};use bacnet_services::read_range::{RangeSpec, ReadRangeAck};use bacnet_services::alarm_event::{
AcknowledgeAlarmRequest, GetEventInformationRequest,
GetAlarmSummaryRequest, GetEnrollmentSummaryRequest,
};use bacnet_services::list_manipulation::{AddListElementRequest, RemoveListElementRequest};use bacnet_services::private_transfer::{
ConfirmedPrivateTransferRequest, UnconfirmedPrivateTransferRequest,
};use bacnet_services::text_message::{
ConfirmedTextMessageRequest, UnconfirmedTextMessageRequest,
};use bacnet_services::life_safety::LifeSafetyOperationRequest;use bacnet_services::write_group::WriteGroupRequest;use bacnet_services::vt::{VtOpenRequest, VtCloseRequest, VtDataRequest};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.
Transport-layer implementations. All implement the TransportPort trait.
| 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) |
use bacnet_transport::bip::BipTransport;
let transport = BipTransport::new(
Ipv4Addr::new(0, 0, 0, 0), // bind interface
0xBAC0, // port (47808)
Ipv4Addr::BROADCAST, // broadcast address
);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 detectionuse 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.
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 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.
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?;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
)?;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) viagpiocdev— not deprecated sysfs
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;
}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.
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.
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]]);Network layer routing, router tables, and the multi-port router.
use bacnet_network::network_layer::NetworkLayer;
use bacnet_network::router::BACnetRouter;BACnet object model: trait, database, and object implementations.
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>;
}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>>| 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) |
| 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) |
| 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) |
| 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) |
| Type | Constructor |
|---|---|
LightingOutputObject |
::new(instance, name) |
BinaryLightingOutputObject |
::new(instance, name) |
ColorObject |
::new(instance, name) |
ColorTemperatureObject |
::new(instance, name) |
| Type | Constructor |
|---|---|
LifeSafetyPointObject |
::new(instance, name) |
LifeSafetyZoneObject |
::new(instance, name) |
| 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) |
| Type | Constructor |
|---|---|
ElevatorGroupObject |
::new(instance, name) |
EscalatorObject |
::new(instance, name) |
LiftObject |
::new(instance, name, num_floors) |
| Type | Constructor |
|---|---|
GroupObject |
::new(instance, name) |
GlobalGroupObject |
::new(instance, name) |
StructuredViewObject |
::new(instance, name) |
| Type | Constructor |
|---|---|
AccumulatorObject |
::new(instance, name, units) |
PulseConverterObject |
::new(instance, name, units) |
| Type | Constructor |
|---|---|
DeviceObject |
::new(DeviceConfig { .. }) |
FileObject |
::new(instance, name, file_type) |
NetworkPortObject |
::new(instance, name, network_type) |
| 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) |
Async BACnet client with transaction state machine, segmentation, and discovery.
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 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.
// 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?;// 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?;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 tableclient.device_communication_control(&mac, EnableDisable::DISABLE, Some(60), Some("password".into())).await?;
client.reinitialize_device(&mac, ReinitializedState::WARMSTART, None).await?;client.create_object(&mac, ObjectSpecifier::Type(ObjectType::ANALOG_INPUT), initial_values).await?;
client.delete_object(&mac, oid).await?;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?;client.life_safety_operation(&mac, process_id, "operator", LifeSafetyOperation::SILENCE, Some(oid)).await?;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?;let ack = client.read_range(&mac, oid, PropertyIdentifier::LOG_BUFFER, None, Some(RangeSpec::ByPosition { reference_index: 1, count: 10 })).await?;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?;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?;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?;client.write_group(&mac, group_number, write_priority, change_list, Some(false)).await?;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?;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.
Async BACnet server that hosts objects and dispatches incoming requests.
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.
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)
- Lock ordering: always
dbbeforecov_table seg_receiverscapped at 128 (DoS prevention)cov_in_flightsemaphore: max 255 concurrent confirmed COV notificationscomm_state:Arc<AtomicU8>— lock-free read
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 |
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?;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?;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?;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?;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?;