use std::net::Ipv4Addr;
use bacnet_client::discovery::DiscoveredDevice;
use bacnet_types::primitives::PropertyValue;
use owo_colors::OwoColorize;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Table,
Json,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeviceInfo {
pub instance: u32,
pub address: String,
pub vendor_id: u16,
pub max_apdu: u32,
pub segmentation: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub network: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_mac: Option<String>,
}
pub fn format_mac(mac: &[u8]) -> String {
if mac.len() == 6 {
let ip = Ipv4Addr::new(mac[0], mac[1], mac[2], mac[3]);
let port = u16::from_be_bytes([mac[4], mac[5]]);
format!("{ip}:{port}")
} else if let Ok(vmac) = <[u8; 3]>::try_from(mac) {
bacnet_transport::bip6::format_vmac_text(bacnet_transport::bip6::Bip6Vmac::new(vmac))
} else {
mac.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
}
}
pub fn device_info(d: &DiscoveredDevice) -> DeviceInfo {
DeviceInfo {
instance: d.object_identifier.instance_number(),
address: format_mac(d.mac_address.as_slice()),
vendor_id: d.vendor_id,
max_apdu: d.max_apdu_length,
segmentation: format!("{}", d.segmentation_supported),
network: d.source_network,
remote_mac: d.source_address.as_ref().map(|m| format_mac(m.as_slice())),
}
}
pub fn print_devices(devices: &[DeviceInfo], format: OutputFormat) {
match format {
OutputFormat::Table => {
if devices.is_empty() {
println!("{}", "No devices found.".yellow());
return;
}
let has_routing = devices.iter().any(|d| d.network.is_some());
let mut table = comfy_table::Table::new();
if has_routing {
table.set_header(vec![
"Instance",
"Router",
"DNET",
"Remote MAC",
"Vendor",
"Max APDU",
"Segmentation",
]);
for d in devices {
table.add_row(vec![
d.instance.to_string(),
d.address.clone(),
d.network
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into()),
d.remote_mac.clone().unwrap_or_else(|| "-".into()),
d.vendor_id.to_string(),
d.max_apdu.to_string(),
d.segmentation.clone(),
]);
}
} else {
table.set_header(vec![
"Instance",
"Address",
"Vendor",
"Max APDU",
"Segmentation",
]);
for d in devices {
table.add_row(vec![
d.instance.to_string(),
d.address.clone(),
d.vendor_id.to_string(),
d.max_apdu.to_string(),
d.segmentation.clone(),
]);
}
}
println!("{table}");
println!("{}", format!("Found {} device(s)", devices.len()).green());
}
OutputFormat::Json => {
let json = serde_json::to_string_pretty(devices).expect("serialize devices");
println!("{json}");
}
}
}
pub fn format_property_value(value: &PropertyValue) -> String {
match value {
PropertyValue::Null => "null".to_string(),
PropertyValue::Boolean(b) => b.to_string(),
PropertyValue::Unsigned(v) => v.to_string(),
PropertyValue::Signed(v) => v.to_string(),
PropertyValue::Real(v) => format!("{v}"),
PropertyValue::Double(v) => format!("{v}"),
PropertyValue::OctetString(bytes) => {
let hex: Vec<String> = bytes.iter().map(|b| format!("{b:02x}")).collect();
format!("[{}]", hex.join(" "))
}
PropertyValue::CharacterString(s) => format!("\"{s}\""),
PropertyValue::BitString { unused_bits, data } => {
let total_bits = (data.len() * 8).saturating_sub(*unused_bits as usize);
let mut bits = String::with_capacity(total_bits);
for i in 0..total_bits {
let byte_idx = i / 8;
let bit_idx = 7 - (i % 8);
if data[byte_idx] & (1 << bit_idx) != 0 {
bits.push('1');
} else {
bits.push('0');
}
}
format!("bits({bits})")
}
PropertyValue::Enumerated(v) => format!("enumerated({v})"),
PropertyValue::Date(d) => {
let year_str = if d.year == 0xFF {
"*".to_string()
} else {
format!("{}", d.year as u16 + 1900)
};
let month_str = if d.month == 0xFF {
"*".to_string()
} else {
format!("{:02}", d.month)
};
let day_str = if d.day == 0xFF {
"*".to_string()
} else {
format!("{:02}", d.day)
};
format!("{year_str}-{month_str}-{day_str}")
}
PropertyValue::Time(t) => {
let hour_str = if t.hour == 0xFF {
"*".to_string()
} else {
format!("{:02}", t.hour)
};
let minute_str = if t.minute == 0xFF {
"*".to_string()
} else {
format!("{:02}", t.minute)
};
let second_str = if t.second == 0xFF {
"*".to_string()
} else {
format!("{:02}", t.second)
};
let hundredths_str = if t.hundredths == 0xFF {
"*".to_string()
} else {
format!("{:02}", t.hundredths)
};
format!("{hour_str}:{minute_str}:{second_str}.{hundredths_str}")
}
PropertyValue::ObjectIdentifier(oid) => {
format!("{}:{}", oid.object_type(), oid.instance_number())
}
PropertyValue::List(items) => {
let formatted: Vec<String> = items.iter().map(format_property_value).collect();
format!("[{}]", formatted.join(", "))
}
PropertyValue::ApplicationData(bytes) => {
let hex: Vec<String> = bytes.iter().map(|b| format!("{b:02x}")).collect();
format!("application-data([{}])", hex.join(" "))
}
}
}
pub fn print_read_result(
object: &str,
property: &str,
array_index: Option<u32>,
value: &str,
format: OutputFormat,
) {
let prop_display = match array_index {
Some(idx) => format!("{property}[{idx}]"),
None => property.to_string(),
};
match format {
OutputFormat::Table => {
let mut table = comfy_table::Table::new();
table.set_header(vec!["Object", "Property", "Value"]);
table.add_row(vec![object, &prop_display, &format!("{}", value.green())]);
println!("{table}");
}
OutputFormat::Json => {
let result = serde_json::json!({
"object": object,
"property": prop_display,
"value": value,
});
println!(
"{}",
serde_json::to_string_pretty(&result).expect("serialize read result")
);
}
}
}
pub fn print_rpm_table(entries: &[(String, String, Option<u32>, String)], format: OutputFormat) {
match format {
OutputFormat::Table => {
let mut table = comfy_table::Table::new();
table.set_header(vec!["Object", "Property", "Value"]);
for (obj, prop, idx, val) in entries {
let prop_display = match idx {
Some(i) => format!("{prop}[{i}]"),
None => prop.clone(),
};
let colored_val = if val.starts_with("ERROR") {
format!("{}", val.red())
} else {
format!("{}", val.green())
};
table.add_row(vec![obj.as_str(), &prop_display, &colored_val]);
}
println!("{table}");
}
OutputFormat::Json => {
let json_results: Vec<serde_json::Value> = entries
.iter()
.map(|(obj, prop, idx, val)| {
let prop_display = match idx {
Some(i) => format!("{prop}[{i}]"),
None => prop.clone(),
};
serde_json::json!({
"object": obj,
"property": prop_display,
"value": val,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&json_results).expect("serialize rpm results")
);
}
}
}
pub fn print_success(msg: &str, format: OutputFormat) {
match format {
OutputFormat::Table => println!("{}", msg.green()),
OutputFormat::Json => {
let result = serde_json::json!({ "status": "ok", "message": msg });
println!(
"{}",
serde_json::to_string_pretty(&result).expect("serialize success")
);
}
}
}
pub fn print_waiting(secs: u64) {
eprintln!("{}", format!("Waiting {secs}s for responses...").dimmed());
}
pub fn print_error(msg: &str) {
eprintln!("{} {}", "ERROR:".red().bold(), msg.red());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_null() {
assert_eq!(format_property_value(&PropertyValue::Null), "null");
}
#[test]
fn format_boolean() {
assert_eq!(format_property_value(&PropertyValue::Boolean(true)), "true");
}
#[test]
fn format_unsigned() {
assert_eq!(format_property_value(&PropertyValue::Unsigned(42)), "42");
}
#[test]
fn format_string() {
assert_eq!(
format_property_value(&PropertyValue::CharacterString("hello".to_string())),
"\"hello\""
);
}
#[test]
fn format_enumerated() {
assert_eq!(
format_property_value(&PropertyValue::Enumerated(3)),
"enumerated(3)"
);
}
#[test]
fn output_format_equality() {
assert_eq!(OutputFormat::Table, OutputFormat::Table);
assert_ne!(OutputFormat::Table, OutputFormat::Json);
}
#[test]
fn format_bitstring_empty_data() {
let val = PropertyValue::BitString {
unused_bits: 3,
data: vec![],
};
assert_eq!(format_property_value(&val), "bits()");
}
#[test]
fn format_date_human_readable() {
use bacnet_types::primitives::Date;
let d = Date {
year: 124,
month: 3,
day: 15,
day_of_week: 5,
};
assert_eq!(format_property_value(&PropertyValue::Date(d)), "2024-03-15");
}
#[test]
fn format_date_unspecified() {
use bacnet_types::primitives::Date;
let d = Date {
year: 0xFF,
month: 12,
day: 0xFF,
day_of_week: 0xFF,
};
assert_eq!(format_property_value(&PropertyValue::Date(d)), "*-12-*");
}
#[test]
fn format_time_human_readable() {
use bacnet_types::primitives::Time;
let t = Time {
hour: 14,
minute: 30,
second: 0,
hundredths: 0,
};
assert_eq!(
format_property_value(&PropertyValue::Time(t)),
"14:30:00.00"
);
}
#[test]
fn macs_print_as_targets() {
assert_eq!(format_mac(&[10, 0, 0, 1, 0xBA, 0xC0]), "10.0.0.1:47808");
assert_eq!(format_mac(&[0x00, 0x04, 0xD2]), "bip6:0x0004D2");
assert_eq!(format_mac(&[7]), "07");
for mac in [vec![10, 0, 0, 1, 0xBA, 0xC0], vec![0x40, 0xAB, 0xCD]] {
match crate::resolve::parse_target(&format_mac(&mac)) {
Ok(crate::resolve::Target::Mac(parsed)) => assert_eq!(parsed, mac),
_ => panic!("{} does not read back", format_mac(&mac)),
}
}
}
#[test]
fn device_info_serializes() {
let info = DeviceInfo {
instance: 1234,
address: "192.168.1.10:47808".to_string(),
vendor_id: 42,
max_apdu: 1476,
segmentation: "both".to_string(),
network: None,
remote_mac: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("1234"));
assert!(json.contains("192.168.1.10"));
}
}