use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::PathBuf;
use bacnet_client::client::BACnetClient;
use bacnet_transport::bip::{BipMode, BipTransport, ForeignDeviceConfig};
use bacnet_transport::bip6::Bip6Transport;
use bacnet_types::error::Error;
#[allow(dead_code)]
#[derive(Clone)]
pub struct TransportArgs {
pub interface: Ipv4Addr,
pub port: u16,
pub broadcast: Ipv4Addr,
pub timeout_ms: u64,
pub min_interval_ms: u64,
pub sc: bool,
pub sc_url: Option<String>,
pub sc_ca: Option<PathBuf>,
pub sc_cert: Option<PathBuf>,
pub sc_key: Option<PathBuf>,
pub sc_vmac: Option<[u8; 6]>,
pub sc_device_uuid: Option<[u8; 16]>,
pub ipv6: bool,
pub ipv6_interface: Option<Ipv6Addr>,
pub device_instance: Option<u32>,
pub foreign_device: Option<ForeignDeviceConfig>,
}
impl TransportArgs {
pub(crate) fn from_cli(
cli: &crate::args::Cli,
interface: Ipv4Addr,
broadcast: Ipv4Addr,
) -> Result<Self, String> {
let ipv6_interface = cli
.ipv6_interface
.as_deref()
.map(|s| {
s.parse::<Ipv6Addr>()
.map_err(|e| format!("invalid --ipv6-interface address '{s}': {e}"))
})
.transpose()?;
if cli.foreign_bbmd.is_some() && (cli.sc || cli.ipv6) {
return Err(
"--foreign-bbmd requires BACnet/IP transport (do not use --sc or --ipv6)".into(),
);
}
Ok(Self {
interface,
port: cli.port,
broadcast,
timeout_ms: cli.timeout,
min_interval_ms: cli.min_interval_ms,
sc: cli.sc,
sc_url: cli.sc_url.clone(),
sc_ca: cli.sc_ca.clone(),
sc_cert: cli.sc_cert.clone(),
sc_key: cli.sc_key.clone(),
sc_vmac: cli.sc_vmac,
sc_device_uuid: cli.sc_device_uuid,
ipv6: cli.ipv6,
ipv6_interface,
device_instance: cli.device_instance,
foreign_device: crate::foreign::config(cli.foreign_bbmd, cli.ttl, cli.renewal),
})
}
}
fn parse_fixed_hex_array<const N: usize>(
value: &str,
label: &str,
separators: &[char],
) -> Result<[u8; N], String> {
let compact: String = value
.trim()
.chars()
.filter(|ch| !separators.contains(ch))
.collect();
let expected_len = N * 2;
if compact.len() != expected_len {
return Err(format!("{label} must contain exactly {N} hex bytes"));
}
if !compact.as_bytes().iter().all(u8::is_ascii_hexdigit) {
return Err(format!("{label} contains non-hex characters"));
}
let mut bytes = [0u8; N];
for (index, byte) in bytes.iter_mut().enumerate() {
let start = index * 2;
*byte = u8::from_str_radix(&compact[start..start + 2], 16)
.map_err(|e| format!("failed to parse {label}: {e}"))?;
}
Ok(bytes)
}
pub fn parse_sc_vmac_arg(value: &str) -> Result<[u8; 6], String> {
let vmac = parse_fixed_hex_array::<6>(value, "SC VMAC", &[':', '-'])?;
match vmac {
bacnet_transport::sc_frame::UNKNOWN_VMAC => {
Err("--sc-vmac must not be the reserved unknown VMAC".into())
}
bacnet_transport::sc_frame::BROADCAST_VMAC => {
Err("--sc-vmac must not be the reserved broadcast VMAC".into())
}
_ => Ok(vmac),
}
}
pub fn parse_sc_device_uuid_arg(value: &str) -> Result<[u8; 16], String> {
let uuid = parse_fixed_hex_array::<16>(value, "SC device UUID", &['-'])?;
if uuid == [0; 16] {
return Err("--sc-device-uuid must not be all zero".into());
}
Ok(uuid)
}
pub async fn build_bip_client(args: &TransportArgs) -> Result<BACnetClient<BipTransport>, Error> {
BACnetClient::bip_builder()
.interface(args.interface)
.port(args.port)
.broadcast_address(args.broadcast)
.apdu_timeout_ms(args.timeout_ms)
.min_request_interval_ms(args.min_interval_ms)
.mode(
args.foreign_device
.clone()
.map_or(BipMode::Normal, BipMode::Foreign),
)
.build()
.await
}
#[cfg(feature = "sc-tls")]
pub async fn build_sc_client(
args: &TransportArgs,
) -> Result<
BACnetClient<bacnet_transport::sc::ScTransport<bacnet_transport::sc_tls::TlsWebSocket>>,
Error,
> {
use bacnet_transport::sc_tls::ScNodeTlsConfig;
use rustls::RootCertStore;
use rustls_pki_types::pem::PemObject;
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
let cert_path = args
.sc_cert
.as_ref()
.ok_or_else(|| Error::Encoding("--sc-cert is required for BACnet/SC".into()))?;
let key_path = args
.sc_key
.as_ref()
.ok_or_else(|| Error::Encoding("--sc-key is required for BACnet/SC".into()))?;
let hub_url = args
.sc_url
.as_deref()
.ok_or_else(|| Error::Encoding("--sc-url is required for BACnet/SC".into()))?;
let sc_vmac = args
.sc_vmac
.ok_or_else(|| Error::Encoding("--sc-vmac is required for BACnet/SC".into()))?;
let sc_device_uuid = args
.sc_device_uuid
.ok_or_else(|| Error::Encoding("--sc-device-uuid is required for BACnet/SC".into()))?;
let ca_path = args
.sc_ca
.as_ref()
.filter(|path| !path.as_os_str().is_empty())
.ok_or_else(|| {
Error::Encoding("--sc-ca <FILE> is required for BACnet/SC; supply a nonempty site CA PEM path (no system-root fallback)".into())
})?;
let mut root_store = RootCertStore::empty();
let ca_iter = CertificateDer::pem_file_iter(ca_path)
.map_err(|e| Error::Encoding(format!("failed to read --sc-ca PEM: {e}")))?;
let mut ca_certs = Vec::new();
for cert in ca_iter {
let cert =
cert.map_err(|e| Error::Encoding(format!("failed to parse --sc-ca PEM: {e}")))?;
root_store
.add(cert.clone())
.map_err(|e| Error::Encoding(format!("unusable certificate in --sc-ca PEM: {e}")))?;
ca_certs.push(cert);
}
if root_store.is_empty() {
return Err(Error::Encoding(
"--sc-ca PEM contains no certificates; supply the trusted site CA certificate(s)"
.into(),
));
}
let certs = CertificateDer::pem_file_iter(cert_path)
.map_err(|e| Error::Encoding(format!("failed to read cert PEM: {e}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| Error::Encoding(format!("failed to parse cert PEM: {e}")))?;
let key = PrivateKeyDer::from_pem_file(key_path)
.map_err(|e| Error::Encoding(format!("failed to read key PEM: {e}")))?;
let tls_config = ScNodeTlsConfig::from_der(ca_certs, certs, key)
.map_err(|e| Error::Encoding(format!("TLS config error: {e}")))?;
BACnetClient::sc_builder()
.hub_url(hub_url)
.tls_config(tls_config)
.vmac(sc_vmac)
.device_uuid(sc_device_uuid)
.apdu_timeout_ms(args.timeout_ms)
.min_request_interval_ms(args.min_interval_ms)
.build()
.await
}
pub async fn build_bip6_client(args: &TransportArgs) -> Result<BACnetClient<Bip6Transport>, Error> {
let ipv6_addr = args.ipv6_interface.unwrap_or(Ipv6Addr::UNSPECIFIED);
let mut builder = BACnetClient::bip6_builder()
.interface(ipv6_addr)
.port(args.port)
.apdu_timeout_ms(args.timeout_ms)
.min_request_interval_ms(args.min_interval_ms);
if let Some(instance) = args.device_instance {
builder = builder.device_instance(instance);
}
builder.build().await
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "sc-tls")]
fn sc_args() -> TransportArgs {
TransportArgs {
interface: Ipv4Addr::UNSPECIFIED,
port: 0xBAC0,
broadcast: Ipv4Addr::BROADCAST,
timeout_ms: 6000,
min_interval_ms: 0,
sc: true,
sc_url: Some("wss://hub.example.com/bacnet".into()),
sc_ca: Some(PathBuf::from("site-ca.pem")),
sc_cert: Some(PathBuf::from("cert.pem")),
sc_key: Some(PathBuf::from("key.pem")),
sc_vmac: Some([0x22, 0x01, 0x02, 0x03, 0x04, 0x05]),
sc_device_uuid: Some([0xAB; 16]),
ipv6: false,
ipv6_interface: None,
device_instance: None,
foreign_device: None,
}
}
#[test]
fn parse_sc_vmac_arg_accepts_compact_and_separated_hex() {
assert_eq!(
parse_sc_vmac_arg("220102030405").unwrap(),
[0x22, 0x01, 0x02, 0x03, 0x04, 0x05]
);
assert_eq!(
parse_sc_vmac_arg("22:01:02:03:04:05").unwrap(),
[0x22, 0x01, 0x02, 0x03, 0x04, 0x05]
);
}
#[test]
fn parse_sc_vmac_arg_rejects_reserved_values() {
assert!(parse_sc_vmac_arg("00:00:00:00:00:00").is_err());
assert!(parse_sc_vmac_arg("ff:ff:ff:ff:ff:ff").is_err());
}
#[test]
fn parse_sc_device_uuid_arg_accepts_hyphenated_uuid() {
assert_eq!(
parse_sc_device_uuid_arg("00112233-4455-6677-8899-aabbccddeeff").unwrap(),
[
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF,
]
);
}
#[test]
fn parse_sc_device_uuid_arg_rejects_zero_uuid() {
assert!(parse_sc_device_uuid_arg("00000000-0000-0000-0000-000000000000").is_err());
}
#[cfg(feature = "sc-tls")]
#[tokio::test]
async fn build_sc_client_requires_vmac_before_loading_tls_files() {
let mut args = sc_args();
args.sc_vmac = None;
let result = build_sc_client(&args).await;
assert!(matches!(
result,
Err(Error::Encoding(message)) if message.contains("--sc-vmac is required")
));
}
#[cfg(feature = "sc-tls")]
#[tokio::test]
async fn build_sc_client_requires_device_uuid_before_loading_tls_files() {
let mut args = sc_args();
args.sc_device_uuid = None;
let result = build_sc_client(&args).await;
assert!(matches!(
result,
Err(Error::Encoding(message)) if message.contains("--sc-device-uuid is required")
));
}
}