Skip to content

Serve objects from your application

Build and integrate / Serve objects

A BACnetServer makes your application a BACnet device: it holds a Device object and the objects you add, and answers reads, writes, subscriptions and discovery for them. Your application supplies the values the server can’t know, such as a measured temperature, and acts on the commands peers write. This page builds one on BACnet/IP in Rust and in Python and describes v0.15.0.

The server has the full service set. If one device must also send requests through the same socket, use a shared endpoint instead, whose responder is narrower.

In Rust, build an ObjectDatabase that holds a DeviceObject and your objects, then start the server with a builder:

  • BACnetServer::bip_builder() takes device(..), interface(..), port(..), broadcast_address(..) and database(..), and build().await binds the socket and starts serving.
  • BACnetServer::generic_builder() takes any transport you built, such as MS/TP or BACnet/IPv6. BACnetServer::sc_builder(), with the sc-tls feature, joins a BACnet/SC hub.

In Python, BACnetServer(device_instance, device_name, interface=..., port=..., broadcast_address=...) builds the Device for you, and add_analog_input, add_binary_output and the other add_* methods add objects. Add every object before await server.start(); Python can’t add objects to a running server.

On BACnet/IP the server binds a socket on every address (0.0.0.0) and the port you give, but takes unicast requests only when they are sent to its interface address. Port 0 lets the operating system pick a free port, which suits tests; a device on a site network normally uses 47808 and the interface’s subnet broadcast address.

These programs serve Device 1001 with two objects on 127.0.0.1, on a port the operating system picks:

  • Analog Input 1, Supply air temperature, whose value the program supplies: 21.5 °C at start.
  • Binary Output 1, Supply fan, which peers command. When the fan’s value changes, the program prints it and changes the temperature: 19.0 °C while the fan runs, 21.5 °C when it stops.

Each runs until you press Enter or send end of input, then stops the server and prints Stopped.

These files target v0.15.0, whose minimum Rust version is 1.95. Put Cargo.toml in a new directory and main.rs in its src subdirectory. The BACnet crates are pinned to the release, and the empty workspace table makes the directory a standalone Cargo project.

Cargo.toml
[package]
name = "server-device"
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
publish = false
[workspace]
[dependencies]
bacnet-objects = "=0.15.0"
bacnet-server = "=0.15.0"
bacnet-transport = "=0.15.0"
bacnet-types = "=0.15.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "io-util", "time"] }
src/main.rs
//! Serve a small device over BACnet/IP: an Analog Input whose value the
//! application supplies, and a Binary Output that peers command.
#![forbid(unsafe_code)]
use bacnet_objects::DeviceIdentity;
use bacnet_objects::analog::AnalogInputObject;
use bacnet_objects::binary::BinaryOutputObject;
use bacnet_objects::database::ObjectDatabase;
use bacnet_objects::device::{DeviceConfig, DeviceObject};
use bacnet_server::server::BACnetServer;
use bacnet_transport::bvll::decode_bip_mac;
use bacnet_types::enums::{EngineeringUnits, ObjectType, PropertyIdentifier};
use bacnet_types::primitives::{ObjectIdentifier, PropertyValue};
use std::io::{self, Write};
use std::net::{Ipv4Addr, SocketAddrV4};
use std::time::Duration;
use tokio::io::AsyncBufReadExt;
const DEVICE_INSTANCE: u32 = 1001;
// Vendor 0 is a placeholder: use the vendor identifier ASHRAE assigned you.
const VENDOR_ID: u16 = 0;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::args_os().len() != 1 {
return Err("usage: server-device (press Enter or send EOF to stop)".into());
}
let identity =
DeviceIdentity::new(DEVICE_INSTANCE, VENDOR_ID)?.with_name("Example air handler");
let mut database = ObjectDatabase::new();
database.add(Box::new(DeviceObject::new(DeviceConfig {
identity: identity.clone(),
..DeviceConfig::default()
})?))?;
database.add(Box::new(AnalogInputObject::new(
1,
"Supply air temperature",
EngineeringUnits::DEGREES_CELSIUS,
)?))?;
database.add(Box::new(BinaryOutputObject::new(1, "Supply fan")?))?;
let temperature = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1)?;
let fan = ObjectIdentifier::new(ObjectType::BINARY_OUTPUT, 1)?;
// The server answers only requests sent to 127.0.0.1. For a real
// network, give the interface's address and its subnet broadcast.
let mut server = BACnetServer::bip_builder()
.device(identity)
.interface(Ipv4Addr::LOCALHOST)
.port(0)
.broadcast_address(Ipv4Addr::LOCALHOST)
.database(database)
.build()
.await?;
// Keep every error on the path that stops the server.
let served = async {
// The application owns the input's value.
server
.set_present_value_local(&temperature, PropertyValue::Real(21.5))
.await?;
let (ip, port) = decode_bip_mac(server.local_mac())?;
let address = SocketAddrV4::new(Ipv4Addr::from(ip), port);
writeln!(
io::stdout(),
"Serving device {DEVICE_INSTANCE} at {address}"
)?;
writeln!(io::stdout(), "Press Enter to stop (or send EOF).")?;
io::stdout().flush()?;
let mut input = String::new();
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
let stop = stdin.read_line(&mut input);
tokio::pin!(stop);
let mut ticks = tokio::time::interval(Duration::from_millis(100));
let mut fan_on = false;
loop {
tokio::select! {
read = &mut stop => {
read?;
break;
}
_ = ticks.tick() => {
// Peers command the fan; the application acts on the
// value the priority array settles on.
let command = server
.read_local(&fan, PropertyIdentifier::PRESENT_VALUE, None)
.await?;
let on = command == PropertyValue::Enumerated(1);
if on != fan_on {
fan_on = on;
let reading = if on { 19.0 } else { 21.5 };
server
.set_present_value_local(&temperature, PropertyValue::Real(reading))
.await?;
writeln!(io::stdout(), "Supply fan: {}", if on { "on" } else { "off" })?;
io::stdout().flush()?;
}
}
}
}
Ok::<_, Box<dyn std::error::Error>>(())
}
.await;
server.stop().await?;
writeln!(io::stdout(), "Stopped")?;
served
}

From the directory containing Cargo.toml, run:

Terminal window
cargo run

Both programs print the address the operating system gave them:

Serving device 1001 at 127.0.0.1:<assigned-port>
Press Enter to stop (or send EOF).

From another terminal, read the temperature, start the fan at priority 8, and read again. Writes need --allow-write; without it the CLI exits with status 4 and sends nothing.

Terminal window
bacnet --interface 127.0.0.1 --port 0 read 127.0.0.1:<assigned-port> ai:1 pv
bacnet --interface 127.0.0.1 --port 0 --allow-write write 127.0.0.1:<assigned-port> bo:1 pv active --priority 8
bacnet --interface 127.0.0.1 --port 0 read 127.0.0.1:<assigned-port> ai:1 pv
bacnet --interface 127.0.0.1 --port 0 --allow-write write 127.0.0.1:<assigned-port> bo:1 pv null --priority 8

The first read returns 21.5 and the second 19, and the program prints Supply fan: on. Writing NULL at priority 8 relinquishes the command, and the program prints Supply fan: off. Press Enter in the program’s terminal to stop it.

CI compiles the Rust file and runs it in the server_device integration tests, and runs the Python file in test_website_server.py. Each test reads the temperature and the object names with a client, commands the fan, relinquishes it, and checks every line the program prints and that the port is free after Stopped.

An input’s value comes from your application, not from the network. Set it through the server, so that subscribers are notified:

  • Rust’s set_present_value_local(&oid, value) takes Analog, Binary and Multi-state Inputs, Values whose Present_Value isn’t commandable, a Loop’s output, and Life Safety Points and Zones. For the Inputs, Values and Loop it refuses an object whose Out_Of_Service is TRUE, since a client is then simulating its value; a Life Safety object takes the value either way. write_local(..) makes the same write a network WriteProperty would, at a priority, for commandable objects.
  • Python’s set_present_value_local(object_id, value) takes Analog, Binary and Multi-state Inputs, a Loop’s output and Life Safety objects. write_property_local(..., source_object=None) makes a network-equivalent write.

Local writes are trusted: no server policy is asked. Read a served value back with Rust’s read_local(..) or Python’s read_property(..), which evaluate it as a network ReadProperty does. In Rust, server.database() is the raw object database, behind a Tokio lock; don’t hold its guard across an .await.

Outputs, and Values unless you make them read-only, are commandable: a peer writes Present_Value at a priority from 1 to 16, and the value with the highest priority wins, or Relinquish_Default when every priority is NULL. The server keeps the priority array; your application acts on the result.

v0.15.0 has no callback for a write. Read the commanded value when your application needs it, as the example does every 100 ms, and drive your hardware or logic from it.

By default any peer may write. To decide who may change the device, install a policy or deny every network mutation; see restrict who can change a device.

The server answers SubscribeCOV, SubscribeCOVProperty and SubscribeCOVPropertyMultiple for the objects it serves, and sends the notifications. A value your application sets through the server notifies subscribers as a network write does; an analog object’s COV_Increment still filters small changes. To watch a device as a client instead, see observe changes with COV.

Rust’s builder takes a CovPolicy with cov_policy(..), and Python’s constructor a cov_policy dict. cov_counters() reports the subscriptions held and the notifications sent and throttled.

Limit Default What happens at the limit
COV subscriptions, all peers 1,024 The next subscription is refused
COV subscriptions, one peer 64 That peer’s next subscription is refused
Objects created by CreateObject Until the database holds 10,000 objects CreateObject fails with RESOURCES / NO_SPACE_FOR_OBJECT

The COV limits are CovPolicy fields you can change. The CreateObject limit counts every object in the database but doesn’t stop your application from adding more. Requests in flight, in total and per peer, have their own limits: see request admission.

Rust’s server.generate_pics(&PicsConfig) builds a Protocol Implementation Conformance Statement from the served objects and the server’s services. PicsConfig supplies what the server can’t know, such as the vendor and model names, and Pics::generate_text() or generate_markdown() renders it. Python has no PICS generator. Generate a PICS for your device walks through a program and the fields you fill in. See bacnet_server::pics on docs.rs.

Await stop() before your program exits or builds another server on the same port or storage. In Rust it refuses new local writes and broadcasts, waits for the work it already admitted, then closes the transport; read_local, the counters and the PICS still work afterwards. Python’s await server.stop() does the same, after which local_address() raises RuntimeError. A server dropped without stop() aborts its tasks, and nothing waits for them.

Restrict who can change a device · Route between networks · Compose a shared endpoint

Release v0.15.0 ·Build and integrate. Pre-1.0 APIs; partial conformance.Support & limitations