Skip to content

Route between networks

Build and integrate / Route between networks

A BACnet router joins two or more networks, each with its own network number, and passes messages between them (Clause 6). BACnetRouter is that router in Rust: each port is one transport on one network, such as two BACnet/IP subnets, or a BACnet/IP network and an MS/TP trunk. It forwards requests and replies, keeps a routing table, and answers the network-layer messages other nodes use to find it. Clients and servers on the attached networks need nothing special to talk through it. Python has a B/IP router too.

The page describes v0.15.0.

BACnetRouter::start(ports, options) takes a list of RouterPort { transport, network_number } and starts every transport:

  • A network number is 1 to 65534, and each port needs a different one. Every router on a network must use the same number for it.
  • All ports share one transport type. RouterPort’s transport type defaults to AnyTransport, so a router that mixes link types wraps each transport in AnyTransport and writes RouterPort alone.
  • RouterOptions::new() is the plain router. The options below turn on more.

start returns a StartedRouter with the router and a receiver, apdus, for messages addressed to the router itself, which an application on the router may answer. router.table() borrows the routing table: read or change what you need, then drop it before the next .await. router.counters() reports what the router dropped or refused, and router.ports() lists each port’s network number and actual MAC.

The router’s own application can be a client or a server too. StartedRouter::into_application_port() makes it a transport for a BACnetClient, and into_application_ports() splits it between a BACnetServer and a client. BACnetRouter::originate sends a global broadcast from the router’s own application, such as a Who-Is for every network, on every port.

A node finds the router to a network with Who-Is-Router-To-Network, and each router answers with I-Am-Router-To-Network and the networks it reaches (Clauses 6.4.1 and 6.4.2):

  • Rust’s client.discover_routers(Some(network), wait) and Python’s await client.discover_routers(network, timeout_ms=...) broadcast the question and return each router that answered, with the networks it lists. Leave the network out to hear every router.
  • A client’s Who-Is goes out as a global broadcast, so routers carry it to every network, and each answer records the network the device is on. read_property_from_device(instance, ...) then sends the read through the router that answered.
  • Rust’s read_property_routed(router_mac, network, device_mac, ...) addresses a device on another network directly, when you already know the router’s address and the device’s address on its own network.

From the CLI, whois-router lists the routers and the networks they reach, and discover --dnet asks one network for its devices. Replace the network number and device instance with your site’s:

Terminal window
bacnet whois-router
bacnet whois-router --dnet 5 --wait 5
bacnet discover --dnet 5 --wait 3
bacnet read 5:1005 ai:1 pv

A routed target is NETWORK:INSTANCE, as 5:1005 above, or NETWORK:0xMAC for a station by its address on that network, such as 2:0x05 for MS/TP station 5 on network 2. The CLI asks that network for the device and sends the request through the router that answers. read, readm, read-range, write, writem, subscribe, alarms, alarm-summary, enrollment-summary, ack-alarm, create-object, delete-object, control, reinit, time-sync, file-read and file-write take routed targets, with the same write guards as a local target. The B/IP management commands bdt, fdt, register and unregister address local peers and refuse them.

Each option is a RouterOptions method:

  • control_policy(ControlPolicy::Hardened) with control_authorizer(..) decides which peers may change routes with network-layer messages. Without them, any peer’s routing messages are applied. See who may change routes.
  • congestion(Congestion::DEFAULT) tells the other networks, with Router-Busy-To-Network, when a port’s send queue stays full, and with Router-Available-To-Network when it drains.
  • duplicates(Duplicates::DEFAULT) drops a global broadcast the router saw within the last second. It is meant for a site whose routers form a loop, which the standard doesn’t allow but a misconfigured site can have. The standard defines no such suppression, and a sender’s own identical retry within that second is dropped too. It also covers three messages the router passes on without a hop count, so that nothing else stops them around a loop: I-Am-Router-To-Network, and Router-Busy-To-Network or Router-Available-To-Network from one sender.
  • next_hop_rejects() lets a Reject-Message-To-Network change a learned route only when it comes from that route’s next router.
  • track_admission() gives the local receiver queue counters and a per-source quota.
Limit Value
Ports 1 to 255
Routes, direct ones included 256
Sends queued per port 256

At the route limit, the router stops learning new routes. See the network layer for how routes are learned and aged.

router.stop().await returns Result<(), RouterStopError>. It waits for every transport to stop, and only Ok means every port’s socket is closed; failures() lists what failed. Calling it again returns the same result. Queued local messages stay readable from apdus.

BACnetRouter([BipRouterPort(network, interface=..., port=..., broadcast_address=...), ...]) owns a B/IP router with 1 to 255 ports. The bbmd=Bbmd(...) and foreign_device=ForeignDevice(...) options the client and server take work on a port too. Start it with async with or await router.start(); await router.ports() gives each port’s actual address, and await router.routes() copies the routing table. A successful await router.close(), or leaving the context, waits until every port’s socket is closed.

It routes between B/IP networks only. It uses the native defaults, including the permissive policy for routing messages, and exposes none of the options above.

The router’s own application entity can host Python stacks too. Build the router with application="client" for one BACnetClient, or application="client_and_server" for a BACnetClient and a BACnetServer, and build each stack with transport="router", router=router. Each takes its share when it starts, once per router start. The client reaches every device through the router’s table, so it names each one by its network: find devices with a Who-Is, then use the _from_device methods. The server answers at the router’s address on each network. Closing the router ends both shares, and their requests then fail at once.

These programs start a router with ports on networks 1 and 5, both on 127.0.0.1, a device (instance 1005) on network 5 and a client on network 1. The client finds the router, finds the device with a global Who-Is, and reads its Analog Input through the router. Then everything stops.

All three share 127.0.0.1, so a B/IP broadcast from one would reach none of the others. Each router port is therefore a BBMD, and the device and the client register with it as foreign devices, which sends their broadcasts through it. On a real site each port would sit on its own subnet and plain broadcasts would do.

The Rust manifest pins v0.15.0, whose minimum Rust version is 1.95. Put Cargo.toml in a new directory and main.rs in its src subdirectory, then run cargo run there.

Cargo.toml
[package]
name = "bip-router"
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
publish = false
[workspace]
[dependencies]
bacnet-client = "=0.15.0"
bacnet-encoding = "=0.15.0"
bacnet-network = "=0.15.0"
bacnet-objects = "=0.15.0"
bacnet-server = "=0.15.0"
bacnet-services = "=0.15.0"
bacnet-transport = "=0.15.0"
bacnet-types = "=0.15.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
src/main.rs
//! Route between two BACnet/IP networks on one host: a router joins
//! networks 1 and 5, and a client on network 1 finds the router, then
//! finds and reads a device on network 5 through it.
#![forbid(unsafe_code)]
use bacnet_client::client::BACnetClient;
use bacnet_encoding::primitives::decode_application_value;
use bacnet_network::router::{BACnetRouter, RouterOptions, RouterPort, StartedRouter};
use bacnet_objects::DeviceIdentity;
use bacnet_objects::analog::AnalogInputObject;
use bacnet_objects::database::ObjectDatabase;
use bacnet_objects::device::{DeviceConfig, DeviceObject};
use bacnet_server::server::BACnetServer;
use bacnet_services::who_is::DeviceInstanceRange;
use bacnet_transport::bbmd::ForeignDevicePolicy;
use bacnet_transport::bip::{BbmdOptions, BipMode, BipTransport, ForeignDeviceConfig};
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;
use std::time::Duration;
type Error = Box<dyn std::error::Error>;
const NEAR: u16 = 1;
const FAR: u16 = 5;
const DEVICE_INSTANCE: u32 = 1005;
const WAIT: Duration = Duration::from_secs(2);
/// One router port on 127.0.0.1, on a port the OS picks. Every node here
/// shares that address, so a B/IP broadcast would reach nobody else: each
/// port is a BBMD, and the other nodes register with it as foreign devices
/// to send and receive broadcasts.
fn router_port(network_number: u16) -> RouterPort<BipTransport> {
let mut transport = BipTransport::new(Ipv4Addr::LOCALHOST, 0, Ipv4Addr::LOCALHOST);
transport.set_mode(BipMode::Bbmd(BbmdOptions {
foreign_device_policy: Some(ForeignDevicePolicy::default()),
alone_on_subnet: true,
..BbmdOptions::default()
}));
RouterPort {
transport,
network_number,
}
}
/// Register as a foreign device with the router port whose MAC is `bbmd`.
fn foreign_device_of(bbmd: &[u8]) -> Result<BipMode, Error> {
let (ip, port) = decode_bip_mac(bbmd)?;
Ok(BipMode::Foreign(ForeignDeviceConfig {
bbmd_ip: Ipv4Addr::from(ip),
bbmd_port: port,
ttl: 60,
renewal_interval: None,
}))
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let StartedRouter { mut router, .. } = BACnetRouter::start(
vec![router_port(NEAR), router_port(FAR)],
RouterOptions::new(),
)
.await?;
let macs: Vec<Vec<u8>> = router
.ports()
.iter()
.map(|port| port.local_mac().to_vec())
.collect();
writeln!(io::stdout(), "Router started: networks {NEAR} and {FAR}")?;
let outcome = serve_and_read(&macs[0], &macs[1]).await;
// Only a successful stop means the router's sockets are closed.
let stopped = router.stop().await;
outcome?;
stopped?;
writeln!(io::stdout(), "Stopped")?;
Ok(())
}
/// A device on network 5 and a client on network 1, each stopped
/// whatever happens in between.
async fn serve_and_read(near: &[u8], far: &[u8]) -> Result<(), Error> {
let identity = DeviceIdentity::new(DEVICE_INSTANCE, 0)?.with_name("Rooftop unit");
let mut database = ObjectDatabase::new();
database.add(Box::new(DeviceObject::new(DeviceConfig {
identity: identity.clone(),
..DeviceConfig::default()
})?))?;
let mut temperature = AnalogInputObject::new(
1,
"Outdoor air temperature",
EngineeringUnits::DEGREES_CELSIUS,
)?;
temperature.set_present_value(8.5);
database.add(Box::new(temperature))?;
let mut server = BACnetServer::bip_builder()
.device(identity)
.interface(Ipv4Addr::LOCALHOST)
.port(0)
.broadcast_address(Ipv4Addr::LOCALHOST)
.mode(foreign_device_of(far)?)
.database(database)
.build()
.await?;
let outcome = async {
let mut client = BACnetClient::bip_builder()
.interface(Ipv4Addr::LOCALHOST)
.port(0)
.broadcast_address(Ipv4Addr::LOCALHOST)
.mode(foreign_device_of(near)?)
.build()
.await?;
let outcome = async {
// Broadcasts flow once both registrations are accepted.
let deadline = tokio::time::Instant::now() + WAIT;
while !(server.foreign_registration()?.registered
&& client.foreign_registration()?.registered)
{
if tokio::time::Instant::now() > deadline {
return Err("no foreign-device registration".into());
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
read_through_the_router(&client, near).await
}
.await;
client.stop().await?;
outcome
}
.await;
server.stop().await?;
outcome
}
async fn read_through_the_router(
client: &BACnetClient<BipTransport>,
near: &[u8],
) -> Result<(), Error> {
// Who-Is-Router-To-Network: which router reaches network 5?
let routers = client.discover_routers(Some(FAR), WAIT).await?;
let [router] = routers.as_slice() else {
return Err(format!("expected one router, heard {}", routers.len()).into());
};
if router.mac.as_slice() != near {
return Err("the answer came from another router".into());
}
writeln!(
io::stdout(),
"Router to network {FAR} found, reaching networks {:?}",
router.networks
)?;
// A global Who-Is crosses the router; the I-Am names the device's network.
client
.who_is(Some(DeviceInstanceRange::new(
DEVICE_INSTANCE,
DEVICE_INSTANCE,
)?))
.await?;
let deadline = tokio::time::Instant::now() + WAIT;
let device = loop {
if let Some(device) = client.get_device(DEVICE_INSTANCE).await {
break device;
}
if tokio::time::Instant::now() > deadline {
return Err(format!("device {DEVICE_INSTANCE} did not answer").into());
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
writeln!(
io::stdout(),
"Device {DEVICE_INSTANCE} found on network {}",
device
.source_network
.ok_or("the device answered from this network")?
)?;
// Reads by instance go through the router that answered for it.
let temperature = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1)?;
let ack = client
.read_property_from_device(
DEVICE_INSTANCE,
temperature,
PropertyIdentifier::PRESENT_VALUE,
None,
)
.await?;
let (value, _) = decode_application_value(&ack.property_value, 0)?;
let PropertyValue::Real(value) = value else {
return Err(format!("unexpected value {value:?}").into());
};
writeln!(io::stdout(), "Read through the router: {value}")?;
Ok(())
}

Both print, after a few seconds of waiting for answers:

Router started: networks 1 and 5
Router to network 5 found, reaching networks [5]
Device 1005 found on network 5
Read through the router: 8.5
Stopped

CI runs the Rust file in the bip_router_guide integration test and the Python file in test_website_router.py, and checks every line.

Serve objects from your application · Discovery and addressing · Across subnets with a BBMD

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