diff --git a/benchmarks/Cargo.toml b/benchmarks/Cargo.toml index 6365f0e..0ab8599 100644 --- a/benchmarks/Cargo.toml +++ b/benchmarks/Cargo.toml @@ -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 diff --git a/benchmarks/src/bin/bvll_test.rs b/benchmarks/src/bin/bvll_test.rs new file mode 100644 index 0000000..d509413 --- /dev/null +++ b/benchmarks/src/bin/bvll_test.rs @@ -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 { + 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 +} diff --git a/crates/bacnet-client/src/client.rs b/crates/bacnet-client/src/client.rs index 87b245a..2907439 100644 --- a/crates/bacnet-client/src/client.rs +++ b/crates/bacnet-client/src/client.rs @@ -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; @@ -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, +} + /// BACnet client with low-level and high-level request APIs. pub struct BACnetClient { config: ClientConfig, @@ -242,6 +253,26 @@ impl BACnetClient { } } +impl + BACnetClient> +{ + /// Read the Broadcast Distribution Table from a BBMD (BIP transport only). + pub async fn read_bdt( + &self, + target: &[u8], + ) -> Result, 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, Error> { + self.network.transport().read_fdt(target).await + } +} + impl BACnetClient { /// Create a BIP6-specific builder for BACnet/IPv6 transport. pub fn bip6_builder() -> Bip6ClientBuilder { @@ -439,6 +470,9 @@ impl BACnetClient { 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()); @@ -1489,6 +1523,75 @@ impl BACnetClient { .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, + wait_ms: u64, + ) -> Result, 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> = 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, diff --git a/crates/bacnet-network/src/layer.rs b/crates/bacnet-network/src/layer.rs index 7583c15..25645a1 100644 --- a/crates/bacnet-network/src/layer.rs +++ b/crates/bacnet-network/src/layer.rs @@ -11,10 +11,21 @@ use bacnet_types::enums::NetworkPriority; use bacnet_types::error::Error; use bacnet_types::MacAddr; use bytes::{Bytes, BytesMut}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{broadcast, mpsc, oneshot}; use tokio::task::JoinHandle; use tracing::{debug, warn}; +/// A received network-layer message (e.g., I-Am-Router-To-Network). +#[derive(Debug, Clone)] +pub struct ReceivedNetworkMessage { + /// The network message type byte. + pub message_type: u8, + /// Raw payload bytes (after the message type). + pub payload: Bytes, + /// Source MAC address in transport-native format. + pub source_mac: MacAddr, +} + /// A received APDU with source addressing information. pub struct ReceivedApdu { /// Raw APDU bytes. @@ -59,6 +70,9 @@ impl std::fmt::Debug for ReceivedApdu { pub struct NetworkLayer { transport: T, dispatch_task: Option>, + /// Broadcast sender for network-layer messages (e.g., I-Am-Router-To-Network). + /// Lazily initialized via `network_messages()`. + network_msg_tx: Option>, } impl NetworkLayer { @@ -67,9 +81,33 @@ impl NetworkLayer { Self { transport, dispatch_task: None, + network_msg_tx: None, } } + /// Enable network message forwarding and return a receiver. + /// + /// Must be called **before** `start()`. Network-layer messages + /// (Who-Is-Router, I-Am-Router, etc.) will be published on this channel + /// instead of being silently dropped. + pub fn network_messages(&mut self) -> broadcast::Receiver { + if let Some(ref tx) = self.network_msg_tx { + tx.subscribe() + } else { + let (tx, rx) = broadcast::channel(64); + self.network_msg_tx = Some(tx); + rx + } + } + + /// Subscribe to network-layer messages. Returns `None` if + /// `network_messages()` was never called before `start()`. + pub fn subscribe_network_messages( + &self, + ) -> Option> { + self.network_msg_tx.as_ref().map(|tx| tx.subscribe()) + } + /// Start the network layer. Returns a receiver for incoming APDUs. /// /// This starts the underlying transport and spawns a dispatch task that @@ -78,16 +116,27 @@ impl NetworkLayer { let mut npdu_rx = self.transport.start().await?; let (apdu_tx, apdu_rx) = mpsc::channel(256); + let network_msg_tx = self.network_msg_tx.clone(); let dispatch_task = tokio::spawn(async move { while let Some(received) = npdu_rx.recv().await { match decode_npdu(received.npdu.clone()) { Ok(npdu) => { if npdu.is_network_message { - debug!( - message_type = npdu.message_type, - "Ignoring network layer message (non-router mode)" - ); + if let Some(ref tx) = network_msg_tx { + if let Some(msg_type) = npdu.message_type { + let _ = tx.send(ReceivedNetworkMessage { + message_type: msg_type, + payload: npdu.payload.clone(), + source_mac: received.source_mac.clone(), + }); + } + } else { + debug!( + message_type = npdu.message_type, + "Ignoring network layer message (non-router mode)" + ); + } continue; } @@ -272,6 +321,27 @@ impl NetworkLayer { self.transport.send_unicast(&buf, router_mac).await } + /// Broadcast a network-layer message (e.g., Who-Is-Router-To-Network). + /// + /// Encodes an NPDU with `is_network_message: true` and the given + /// message type and payload, then broadcasts on the local network. + pub async fn broadcast_network_message( + &self, + message_type: u8, + payload: &[u8], + ) -> Result<(), Error> { + let npdu = Npdu { + is_network_message: true, + message_type: Some(message_type), + payload: Bytes::copy_from_slice(payload), + ..Npdu::default() + }; + + let mut buf = BytesMut::with_capacity(4 + payload.len()); + encode_npdu(&mut buf, &npdu)?; + self.transport.send_broadcast(&buf).await + } + /// Access the underlying transport. /// /// Useful for transport-specific operations like BBMD registration diff --git a/crates/bacnet-transport/src/any.rs b/crates/bacnet-transport/src/any.rs index 90e370e..0a49985 100644 --- a/crates/bacnet-transport/src/any.rs +++ b/crates/bacnet-transport/src/any.rs @@ -6,6 +6,7 @@ use bacnet_types::error::Error; use tokio::sync::mpsc; +use crate::bbmd::{BdtEntry, FdtEntryWire}; use crate::bip::BipTransport; use crate::bip6::Bip6Transport; use crate::mstp::{MstpTransport, SerialPort}; @@ -111,6 +112,32 @@ impl TransportPort for AnyTransport { } } +// --------------------------------------------------------------------------- +// BVLL operations — BIP-only, errors on other transports. +// --------------------------------------------------------------------------- + +impl AnyTransport { + /// Read the Broadcast Distribution Table from a BBMD (BIP only). + pub async fn read_bdt(&self, target: &[u8]) -> Result, Error> { + match self { + Self::Bip(t) => t.read_bdt(target).await, + _ => Err(Error::Encoding( + "read_bdt is only supported on BIP transport".into(), + )), + } + } + + /// Read the Foreign Device Table from a BBMD (BIP only). + pub async fn read_fdt(&self, target: &[u8]) -> Result, Error> { + match self { + Self::Bip(t) => t.read_fdt(target).await, + _ => Err(Error::Encoding( + "read_fdt is only supported on BIP transport".into(), + )), + } + } +} + impl From for AnyTransport { fn from(t: BipTransport) -> Self { Self::Bip(t) diff --git a/crates/rusty-bacnet/rusty_bacnet.pyi b/crates/rusty-bacnet/rusty_bacnet.pyi index 4155908..06805db 100644 --- a/crates/rusty-bacnet/rusty_bacnet.pyi +++ b/crates/rusty-bacnet/rusty_bacnet.pyi @@ -312,6 +312,63 @@ class CovNotificationIterator: async def __anext__(self) -> dict[str, Any]: ... +class BdtEntry: + """A Broadcast Distribution Table entry from a BBMD.""" + + @property + def ip(self) -> str: + """IP address as dotted quad string.""" + ... + + @property + def port(self) -> int: ... + + @property + def mask(self) -> str: + """Broadcast distribution mask as dotted quad string.""" + ... + + def __repr__(self) -> str: ... + + +class FdtEntry: + """A Foreign Device Table entry from a BBMD.""" + + @property + def ip(self) -> str: ... + + @property + def port(self) -> int: ... + + @property + def ttl(self) -> int: + """Time-to-live in seconds.""" + ... + + @property + def seconds_remaining(self) -> int: + """Seconds remaining before expiry.""" + ... + + def __repr__(self) -> str: ... + + +class RouterInfo: + """A discovered BACnet router and the networks it serves.""" + + @property + def address(self) -> str: + """Router address as 'ip:port'.""" + ... + + @property + def networks(self) -> list[int]: + """Network numbers reachable through this router.""" + ... + + def __repr__(self) -> str: ... + + # --------------------------------------------------------------------------- # Exceptions # --------------------------------------------------------------------------- @@ -654,6 +711,22 @@ class BACnetClient: """Send a WriteGroup request.""" ... + async def read_bdt(self, address: str) -> list[BdtEntry]: + """Read the Broadcast Distribution Table from a BBMD.""" + ... + + async def read_fdt(self, address: str) -> list[FdtEntry]: + """Read the Foreign Device Table from a BBMD.""" + ... + + async def who_is_router_to_network( + self, + network: Optional[int] = None, + timeout_ms: int = 3000, + ) -> list[RouterInfo]: + """Broadcast Who-Is-Router-To-Network and collect responses.""" + ... + async def stop(self) -> None: """Stop the client and release resources.""" ... diff --git a/crates/rusty-bacnet/src/client.rs b/crates/rusty-bacnet/src/client.rs index fa1a877..e3ed1ce 100644 --- a/crates/rusty-bacnet/src/client.rs +++ b/crates/rusty-bacnet/src/client.rs @@ -1812,6 +1812,129 @@ impl BACnetClient { }) } + // ----------------------------------------------------------------------- + // BVLL operations + // ----------------------------------------------------------------------- + + /// Read the Broadcast Distribution Table from a BBMD. + /// + /// Args: + /// address: BBMD address as "ip:port" + /// + /// Returns: list of BdtEntry + #[pyo3(signature = (address))] + fn read_bdt<'py>( + &self, + py: Python<'py>, + address: String, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mac = parse_address(&address)?; + let c = { + let guard = inner.lock().await; + Arc::clone(guard.as_ref().ok_or_else(|| { + PyRuntimeError::new_err("client not started — use 'async with'") + })?) + }; + let entries = c.read_bdt(&mac).await.map_err(to_py_err)?; + let py_entries: Vec = entries + .iter() + .map(crate::types::PyBdtEntry::from_rust) + .collect(); + Ok(py_entries) + }) + } + + /// Read the Foreign Device Table from a BBMD. + /// + /// Args: + /// address: BBMD address as "ip:port" + /// + /// Returns: list of FdtEntry + #[pyo3(signature = (address))] + fn read_fdt<'py>( + &self, + py: Python<'py>, + address: String, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mac = parse_address(&address)?; + let c = { + let guard = inner.lock().await; + Arc::clone(guard.as_ref().ok_or_else(|| { + PyRuntimeError::new_err("client not started — use 'async with'") + })?) + }; + let entries = c.read_fdt(&mac).await.map_err(to_py_err)?; + let py_entries: Vec = entries + .iter() + .map(crate::types::PyFdtEntry::from_rust) + .collect(); + Ok(py_entries) + }) + } + + /// Broadcast Who-Is-Router-To-Network and collect responses. + /// + /// Args: + /// network: Optional network number to query. None queries all. + /// timeout_ms: How long to wait for responses (default 3000ms). + /// + /// Returns: list of RouterInfo + #[pyo3(signature = (network=None, timeout_ms=3000))] + fn who_is_router_to_network<'py>( + &self, + py: Python<'py>, + network: Option, + timeout_ms: u64, + ) -> PyResult> { + let inner = self.inner.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let c = { + let guard = inner.lock().await; + Arc::clone(guard.as_ref().ok_or_else(|| { + PyRuntimeError::new_err("client not started — use 'async with'") + })?) + }; + let routers = c + .who_is_router_to_network(network, timeout_ms) + .await + .map_err(to_py_err)?; + + // Convert MAC addresses to human-readable format for Python. + let py_routers: Vec = routers + .into_iter() + .map(|r| { + let mac = r.mac.as_ref(); + let address = if mac.len() == 6 { + // BIP: 4-byte IP + 2-byte port + format!( + "{}.{}.{}.{}:{}", + mac[0], + mac[1], + mac[2], + mac[3], + u16::from_be_bytes([mac[4], mac[5]]) + ) + } else { + // Fallback: hex + mac.iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") + }; + crate::types::PyRouterInfo { + address, + networks: r.networks, + } + }) + .collect(); + Ok(py_routers) + }) + } + /// Explicitly stop the client. fn stop<'py>(&self, py: Python<'py>) -> PyResult> { let inner = self.inner.clone(); diff --git a/crates/rusty-bacnet/src/types.rs b/crates/rusty-bacnet/src/types.rs index af3ef7e..2d89501 100644 --- a/crates/rusty-bacnet/src/types.rs +++ b/crates/rusty-bacnet/src/types.rs @@ -771,6 +771,94 @@ pub(crate) fn py_to_wpm_specs( .collect() } +// --------------------------------------------------------------------------- +// BDT/FDT/Router types for BVLL operations +// --------------------------------------------------------------------------- + +/// A Broadcast Distribution Table entry from a BBMD. +#[pyclass(name = "BdtEntry", frozen)] +pub struct PyBdtEntry { + #[pyo3(get)] + pub ip: String, + #[pyo3(get)] + pub port: u16, + #[pyo3(get)] + pub mask: String, +} + +#[pymethods] +impl PyBdtEntry { + fn __repr__(&self) -> String { + format!("BdtEntry({}:{}, mask={})", self.ip, self.port, self.mask) + } +} + +impl PyBdtEntry { + pub fn from_rust(entry: &bacnet_transport::bbmd::BdtEntry) -> Self { + Self { + ip: format!("{}.{}.{}.{}", entry.ip[0], entry.ip[1], entry.ip[2], entry.ip[3]), + port: entry.port, + mask: format!( + "{}.{}.{}.{}", + entry.broadcast_mask[0], + entry.broadcast_mask[1], + entry.broadcast_mask[2], + entry.broadcast_mask[3] + ), + } + } +} + +/// A Foreign Device Table entry from a BBMD. +#[pyclass(name = "FdtEntry", frozen)] +pub struct PyFdtEntry { + #[pyo3(get)] + pub ip: String, + #[pyo3(get)] + pub port: u16, + #[pyo3(get)] + pub ttl: u16, + #[pyo3(get)] + pub seconds_remaining: u16, +} + +#[pymethods] +impl PyFdtEntry { + fn __repr__(&self) -> String { + format!( + "FdtEntry({}:{}, ttl={}, remaining={})", + self.ip, self.port, self.ttl, self.seconds_remaining + ) + } +} + +impl PyFdtEntry { + pub fn from_rust(entry: &bacnet_transport::bbmd::FdtEntryWire) -> Self { + Self { + ip: format!("{}.{}.{}.{}", entry.ip[0], entry.ip[1], entry.ip[2], entry.ip[3]), + port: entry.port, + ttl: entry.ttl, + seconds_remaining: entry.seconds_remaining, + } + } +} + +/// A discovered BACnet router and the networks it serves. +#[pyclass(name = "RouterInfo", frozen)] +pub struct PyRouterInfo { + #[pyo3(get)] + pub address: String, + #[pyo3(get)] + pub networks: Vec, +} + +#[pymethods] +impl PyRouterInfo { + fn __repr__(&self) -> String { + format!("RouterInfo({}, networks={:?})", self.address, self.networks) + } +} + // --------------------------------------------------------------------------- // Module registration // --------------------------------------------------------------------------- @@ -818,6 +906,11 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + // BVLL / router types + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) } diff --git a/examples/docker/Dockerfile.bvll-test b/examples/docker/Dockerfile.bvll-test new file mode 100644 index 0000000..6b7f77d --- /dev/null +++ b/examples/docker/Dockerfile.bvll-test @@ -0,0 +1,13 @@ +FROM rust:1.93-alpine AS builder + +RUN apk add --no-cache musl-dev cmake make perl gcc g++ + +WORKDIR /src +COPY . . + +RUN cargo build --release -p bacnet-benchmarks --bin bvll-test + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates +COPY --from=builder /src/target/release/bvll-test /usr/local/bin/ +CMD ["bvll-test"] diff --git a/examples/python/bbmd_scanner.py b/examples/python/bbmd_scanner.py new file mode 100644 index 0000000..6d959cc --- /dev/null +++ b/examples/python/bbmd_scanner.py @@ -0,0 +1,49 @@ +"""Scan BACnet network for BBMDs, read BDTs and FDTs, discover routers.""" +import asyncio +from rusty_bacnet import BACnetClient + + +async def main(): + async with BACnetClient(port=47808, broadcast_address="255.255.255.255") as client: + # Discover devices + await client.who_is() + await asyncio.sleep(3) + devices = await client.discovered_devices() + print(f"Found {len(devices)} devices") + + # Check each device for BBMD status by trying to read BDT + for dev in devices: + mac = dev.mac_address + if len(mac) == 6: + addr = f"{mac[0]}.{mac[1]}.{mac[2]}.{mac[3]}:{int.from_bytes(mac[4:6], 'big')}" + else: + continue + + try: + bdt = await client.read_bdt(addr) + print(f"\nBBMD found at {addr} — BDT has {len(bdt)} entries:") + for entry in bdt: + print(f" {entry.ip}:{entry.port} mask={entry.mask}") + + # Read FDT from confirmed BBMD + fdt = await client.read_fdt(addr) + print(f" FDT has {len(fdt)} entries:") + for entry in fdt: + print( + f" {entry.ip}:{entry.port} " + f"ttl={entry.ttl} remaining={entry.seconds_remaining}" + ) + except Exception: + pass # Not a BBMD + + # Discover routers + routers = await client.who_is_router_to_network() + print(f"\nFound {len(routers)} routers:") + for r in routers: + print(f" {r.address} serves networks: {r.networks}") + + await client.stop() + + +if __name__ == "__main__": + asyncio.run(main())