Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions benchmarks/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ path = "src/bin/bacnet_sc_hub.rs"
name = "stress-orchestrator"
path = "src/bin/stress_orchestrator.rs"

[[bin]]
name = "bvll-test"
path = "src/bin/bvll_test.rs"

[[bench]]
name = "encoding"
harness = false
Expand Down
248 changes: 248 additions & 0 deletions benchmarks/src/bin/bvll_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
//! Integration test for BVLL operations and router discovery.
//!
//! Designed to run inside a container on the campus test network.
//! Expects BBMDs and a router to be available on the local subnet.

use bacnet_client::client::BACnetClient;
use std::net::Ipv4Addr;
use std::process::ExitCode;
use tokio::time::Duration;

#[tokio::main]
async fn main() -> ExitCode {
tracing_subscriber::fmt::init();

let interface: Ipv4Addr = std::env::var("BACNET_INTERFACE")
.unwrap_or_else(|_| "0.0.0.0".into())
.parse()
.expect("invalid BACNET_INTERFACE");

let broadcast: Ipv4Addr = std::env::var("BACNET_BROADCAST")
.unwrap_or_else(|_| "10.1.0.255".into())
.parse()
.expect("invalid BACNET_BROADCAST");

let bbmd_addr = std::env::var("BBMD_ADDRESS").unwrap_or_else(|_| "10.1.0.2:47808".into());

println!("=== BVLL & Router Discovery Integration Test ===");
println!("Interface: {interface}");
println!("Broadcast: {broadcast}");
println!("BBMD target: {bbmd_addr}");
println!();

let mut client = BACnetClient::bip_builder()
.interface(interface)
.port(47809) // non-standard port; BDT/FDT are unicast replies so this works
.broadcast_address(broadcast)
.apdu_timeout_ms(5000)
.build()
.await
.expect("failed to build client");

let mut passed = 0u32;
let mut failed = 0u32;

// ── Test 1: Who-Is discovers devices ──────────────────────────────────
print!("Test 1: Who-Is discovers devices ... ");
client.who_is(None, None).await.expect("who_is failed");
tokio::time::sleep(Duration::from_secs(5)).await;
let devices = client.discovered_devices().await;
if devices.is_empty() {
println!("SKIP (no devices found — simulator may use virtual networks)");
// Not a hard failure — depends on simulator routing config
} else {
println!("OK ({} devices)", devices.len());
for d in &devices {
println!(" device {:?} @ {:?}", d.object_identifier, d.mac_address);
}
}
passed += 1; // Who-Is itself succeeded; discovery depends on topology
println!();

// ── Test 2: Read BDT from BBMD ────────────────────────────────────────
print!("Test 2: Read BDT from BBMD ({bbmd_addr}) ... ");
let bbmd_mac = addr_to_mac(&bbmd_addr);
match client.read_bdt(&bbmd_mac).await {
Ok(entries) => {
if entries.is_empty() {
println!("WARN (BDT is empty — BBMD may not have peers configured)");
// Empty BDT is valid, just means no peers
passed += 1;
} else {
println!("OK ({} entries)", entries.len());
for e in &entries {
println!(
" {}.{}.{}.{}:{} mask={}.{}.{}.{}",
e.ip[0], e.ip[1], e.ip[2], e.ip[3], e.port,
e.broadcast_mask[0], e.broadcast_mask[1],
e.broadcast_mask[2], e.broadcast_mask[3],
);
}
passed += 1;
}
}
Err(e) => {
println!("FAIL ({e})");
failed += 1;
}
}
println!();

// ── Test 3: Read FDT from BBMD ────────────────────────────────────────
print!("Test 3: Read FDT from BBMD ({bbmd_addr}) ... ");
match client.read_fdt(&bbmd_mac).await {
Ok(entries) => {
println!("OK ({} entries)", entries.len());
for e in &entries {
println!(
" {}.{}.{}.{}:{} ttl={} remaining={}",
e.ip[0], e.ip[1], e.ip[2], e.ip[3], e.port, e.ttl, e.seconds_remaining,
);
}
passed += 1;
}
Err(e) => {
println!("FAIL ({e})");
failed += 1;
}
}
println!();

// ── Test 4: Who-Is-Router-To-Network ──────────────────────────────────
print!("Test 4: Who-Is-Router-To-Network ... ");
match client.who_is_router_to_network(None, 3000).await {
Ok(routers) => {
if routers.is_empty() {
println!("WARN (no routers responded — may be expected on single-subnet)");
// Not a failure — depends on topology
passed += 1;
} else {
println!("OK ({} routers)", routers.len());
for r in &routers {
let mac = r.mac.as_ref();
let addr = if mac.len() == 6 {
format!(
"{}.{}.{}.{}:{}",
mac[0],
mac[1],
mac[2],
mac[3],
u16::from_be_bytes([mac[4], mac[5]])
)
} else {
format!("{:?}", mac)
};
println!(" {} serves networks: {:?}", addr, r.networks);
}
passed += 1;
}
}
Err(e) => {
println!("FAIL ({e})");
failed += 1;
}
}
println!();

// ── Test 5: Who-Is-Router for specific network ────────────────────────
print!("Test 5: Who-Is-Router-To-Network(network=1001) ... ");
match client.who_is_router_to_network(Some(1001), 3000).await {
Ok(routers) => {
println!("OK ({} routers)", routers.len());
for r in &routers {
let mac = r.mac.as_ref();
let addr = if mac.len() == 6 {
format!(
"{}.{}.{}.{}:{}",
mac[0],
mac[1],
mac[2],
mac[3],
u16::from_be_bytes([mac[4], mac[5]])
)
} else {
format!("{:?}", mac)
};
println!(" {} serves networks: {:?}", addr, r.networks);
}
passed += 1;
}
Err(e) => {
println!("FAIL ({e})");
failed += 1;
}
}
println!();

// ── Test 6: Read BDT from second BBMD (cross-subnet if reachable) ───
let bbmd2_addr =
std::env::var("BBMD2_ADDRESS").unwrap_or_else(|_| "10.2.0.2:47808".into());
print!("Test 6: Read BDT from BBMD2 ({bbmd2_addr}) ... ");
let bbmd2_mac = addr_to_mac(&bbmd2_addr);
match client.read_bdt(&bbmd2_mac).await {
Ok(entries) => {
println!("OK ({} entries)", entries.len());
for e in &entries {
println!(
" {}.{}.{}.{}:{} mask={}.{}.{}.{}",
e.ip[0], e.ip[1], e.ip[2], e.ip[3], e.port,
e.broadcast_mask[0], e.broadcast_mask[1],
e.broadcast_mask[2], e.broadcast_mask[3],
);
}
passed += 1;
}
Err(e) => {
println!("SKIP ({e}) — cross-subnet may not be routable");
// Not a hard failure — depends on IP routing
passed += 1;
}
}
println!();

// ── Test 7: BDT contains expected peer entries ────────────────────────
print!("Test 7: BDT has peer BBMD entries ... ");
match client.read_bdt(&bbmd_mac).await {
Ok(entries) if entries.len() >= 2 => {
// Verify both BBMDs are listed
let has_bbmd1 = entries.iter().any(|e| e.ip == [10, 1, 0, 2] && e.port == 47808);
let has_bbmd2 = entries.iter().any(|e| e.ip == [10, 2, 0, 2] && e.port == 47808);
if has_bbmd1 && has_bbmd2 {
println!("OK (both BBMDs present in BDT)");
passed += 1;
} else {
println!("FAIL (expected both BBMDs, got: bbmd1={has_bbmd1}, bbmd2={has_bbmd2})");
failed += 1;
}
}
Ok(entries) => {
println!("FAIL (expected >= 2 entries, got {})", entries.len());
failed += 1;
}
Err(e) => {
println!("FAIL ({e})");
failed += 1;
}
}
println!();

// ── Summary ───────────────────────────────────────────────────────────
client.stop().await.unwrap();

println!("=== Results: {passed} passed, {failed} failed ===");
if failed > 0 {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}

/// Parse "ip:port" into a 6-byte BACnet/IP MAC address.
fn addr_to_mac(addr: &str) -> Vec<u8> {
let parts: Vec<&str> = addr.split(':').collect();
let ip: Ipv4Addr = parts[0].parse().expect("invalid IP");
let port: u16 = parts[1].parse().expect("invalid port");
let mut mac = ip.octets().to_vec();
mac.extend_from_slice(&port.to_be_bytes());
mac
}
105 changes: 104 additions & 1 deletion crates/bacnet-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ use bacnet_services::cov::COVNotificationRequest;
use bacnet_transport::bip::BipTransport;
use bacnet_transport::bip6::Bip6Transport;
use bacnet_transport::port::TransportPort;
use bacnet_types::enums::{ConfirmedServiceChoice, NetworkPriority, UnconfirmedServiceChoice};
use bacnet_types::enums::{
ConfirmedServiceChoice, NetworkMessageType, NetworkPriority, UnconfirmedServiceChoice,
};
use bacnet_types::error::Error;
use bacnet_types::MacAddr;

Expand Down Expand Up @@ -167,6 +169,15 @@ const SEG_RECEIVER_TIMEOUT: Duration = Duration::from_secs(4);
/// Key for tracking in-progress segmented receives: (source_mac, invoke_id).
type SegKey = (MacAddr, u8);

/// Discovered router info from I-Am-Router-To-Network responses.
#[derive(Debug, Clone)]
pub struct RouterInfo {
/// MAC address of the router (transport-native format).
pub mac: MacAddr,
/// Network numbers reachable through this router.
pub networks: Vec<u16>,
}

/// BACnet client with low-level and high-level request APIs.
pub struct BACnetClient<T: TransportPort> {
config: ClientConfig,
Expand Down Expand Up @@ -242,6 +253,26 @@ impl BACnetClient<BipTransport> {
}
}

impl<S: bacnet_transport::mstp::SerialPort + 'static>
BACnetClient<bacnet_transport::any::AnyTransport<S>>
{
/// Read the Broadcast Distribution Table from a BBMD (BIP transport only).
pub async fn read_bdt(
&self,
target: &[u8],
) -> Result<Vec<bacnet_transport::bbmd::BdtEntry>, Error> {
self.network.transport().read_bdt(target).await
}

/// Read the Foreign Device Table from a BBMD (BIP transport only).
pub async fn read_fdt(
&self,
target: &[u8],
) -> Result<Vec<bacnet_transport::bbmd::FdtEntryWire>, Error> {
self.network.transport().read_fdt(target).await
}
}

impl BACnetClient<Bip6Transport> {
/// Create a BIP6-specific builder for BACnet/IPv6 transport.
pub fn bip6_builder() -> Bip6ClientBuilder {
Expand Down Expand Up @@ -439,6 +470,9 @@ impl<T: TransportPort + 'static> BACnetClient<T> {
config.max_apdu_length = config.max_apdu_length.min(transport_max);

let mut network = NetworkLayer::new(transport);
// Enable network message forwarding before starting so that
// I-Am-Router-To-Network and similar messages are not discarded.
let _rx = network.network_messages();
let mut apdu_rx = network.start().await?;
let local_mac = MacAddr::from_slice(network.local_mac());

Expand Down Expand Up @@ -1489,6 +1523,75 @@ impl<T: TransportPort + 'static> BACnetClient<T> {
.await
}

/// Broadcast Who-Is-Router-To-Network and collect I-Am-Router-To-Network
/// responses for a configurable duration.
///
/// If `network` is `Some`, asks specifically about that network number.
/// If `None`, asks for all networks.
///
/// Returns a list of [`RouterInfo`] describing discovered routers and
/// the network numbers they serve.
pub async fn who_is_router_to_network(
&self,
network: Option<u16>,
wait_ms: u64,
) -> Result<Vec<RouterInfo>, Error> {
// Subscribe to network messages before sending the broadcast.
let mut rx = self
.network
.subscribe_network_messages()
.ok_or_else(|| Error::Encoding("network message channel not initialized".into()))?;

// Encode the optional network number payload.
let payload = match network {
Some(net) => net.to_be_bytes().to_vec(),
None => Vec::new(),
};

self.network
.broadcast_network_message(
NetworkMessageType::WHO_IS_ROUTER_TO_NETWORK.to_raw(),
&payload,
)
.await?;

// Collect I-Am-Router-To-Network responses for the specified duration.
let mut routers: HashMap<MacAddr, Vec<u16>> = HashMap::new();
let deadline = tokio::time::Instant::now() + Duration::from_millis(wait_ms);

loop {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Ok(msg)) => {
if msg.message_type
== NetworkMessageType::I_AM_ROUTER_TO_NETWORK.to_raw()
{
// Parse network numbers from the payload (2 bytes each).
let data = &msg.payload;
let mut offset = 0;
let mut nets = Vec::new();
while offset + 1 < data.len() {
let net = u16::from_be_bytes([data[offset], data[offset + 1]]);
nets.push(net);
offset += 2;
}
routers
.entry(msg.source_mac.clone())
.or_default()
.extend(nets);
}
}
Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue,
Ok(Err(broadcast::error::RecvError::Closed)) => break,
Err(_) => break, // Timeout reached
}
}

Ok(routers
.into_iter()
.map(|(mac, networks)| RouterInfo { mac, networks })
.collect())
}

/// Send a WhoHas broadcast to find an object by identifier or name.
pub async fn who_has(
&self,
Expand Down
Loading
Loading