use std::collections::{BTreeMap, BTreeSet};
use std::sync::PoisonError;
use std::time::Duration;
use bacnet_transport::port::TransportPort;
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::time::Instant;
use super::{Spec, bbmd, schema};
use crate::context::{Call, Ctx, ROUTER_CAP};
use crate::errors::ToolError;
pub(super) const NAME: &str = "bacnet_network_map";
pub(super) const SPEC: Spec = Spec {
name: NAME,
title: "Map BACnet networks",
description: "Map the networks this server may reach: each network with the routers \
that reach it, how fast the policy lets requests go there, and the devices \
discovered on it; and the routers with the networks each reaches. send: \
true first broadcasts a Who-Is-Router-To-Network (for one network, or all) \
and waits wait_s for the routers' answers; that is a broadcast every router \
answers, so it needs broad discovery in the server's policy and goes at most \
once a minute. Without send, the routers are the last ones heard. bbmd: true \
also reads the BDT and FDT (BBMD tables) of the BBMDs \
in reach (the one this server registers with, any bbmds named by a known \
device's or router's ip:port, and the peers their tables list) and returns a \
verdict: entries one BBMD has and its peer lacks, mismatched masks, foreign \
devices near expiry, and BBMDs that didn't answer or don't support the read. \
It needs broad discovery and goes at most once a minute. Sends no table \
writes, registrations or network-layer writes. Read-only.",
input,
output,
idempotent: true,
open_world: true,
passive: false,
};
const ROUTER_INTERVAL: Duration = Duration::from_secs(60);
const LISTED: usize = 200;
fn input() -> Value {
schema::input(
json!({
"send": {
"type": "boolean",
"default": false,
"description": "Broadcast a Who-Is-Router-To-Network first (broad discovery only)",
},
"network": {
"type": "integer",
"minimum": 1,
"maximum": 65534,
"description": "Ask only for the routers to this network, and map only it",
},
"wait_s": { "type": "integer", "minimum": 1, "maximum": 30, "default": 3 },
"bbmd": {
"type": "boolean",
"default": false,
"description": "Read the BBMD tables of the local B/IP network and check them \
(broad discovery only, once a minute)",
},
"bbmds": {
"type": "array",
"items": { "type": "string", "description": "ip:port" },
"maxItems": bbmd::MAX_NAMED,
"description": "BBMDs to start from besides the registration's: each the B/IP \
address of a device discovered or a router heard (needs bbmd)",
},
}),
&[],
)
}
fn output() -> Value {
let numbers = json!({ "type": "array", "items": { "type": "integer" } });
let strings = json!({ "type": "array", "items": { "type": "string" } });
schema::output(json!({
"sent": { "type": "boolean" },
"bbmd": bbmd::schema(),
"routers_heard_s": {
"type": ["integer", "null"],
"description": "Seconds since the routers were last asked; null if never",
},
"routers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"address": { "type": "string" },
"networks": numbers,
},
"required": ["address", "networks"],
},
},
"networks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"network": { "type": "integer" },
"local": { "type": "boolean" },
"routers": strings,
"speed": { "enum": ["normal", "slow", "fast"] },
"rate_per_s": { "type": "integer" },
"devices": { "type": "integer" },
"instances": numbers,
},
"required": ["network", "local", "routers", "speed", "rate_per_s", "devices"],
},
},
}))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Args {
#[serde(default)]
send: bool,
network: Option<u32>,
wait_s: Option<u64>,
#[serde(default)]
bbmd: bool,
#[serde(default)]
bbmds: Vec<String>,
}
pub(super) async fn run<T: TransportPort + 'static>(
ctx: &Ctx<T>,
call: &Call,
args: Args,
) -> Result<Value, ToolError> {
let network = args
.network
.map(|n| {
u16::try_from(n)
.ok()
.filter(|n| (1..=65534).contains(n))
.ok_or_else(|| ToolError::invalid("network", "a network is 1 to 65534"))
})
.transpose()?;
if let Some(network) = network {
ctx.allow_network(network)?;
}
let engine = ctx.engine()?;
if !args.bbmd && !args.bbmds.is_empty() {
return Err(ToolError::invalid("bbmds", "bbmds needs bbmd: true"));
}
let plan = match args.bbmd {
true => Some(bbmd::plan(ctx, engine, &args.bbmds).await?),
false => None,
};
if args.send
&& let Err(error) = ask_routers(ctx, call, network, args.wait_s).await
{
if let Some(plan) = &plan {
bbmd::release(ctx, plan);
}
return Err(error);
}
let style = engine.address_style();
let address = |mac: &[u8]| style.locate(mac, None).0;
let (heard, known) = {
let routers = ctx.routers.lock().unwrap_or_else(PoisonError::into_inner);
match &routers.heard {
Some((at, found)) => (Some(at.elapsed().as_secs()), found.clone()),
None => (None, Vec::new()),
}
};
let wanted = |n: u16| ctx.policy.allows_network(n) && network.is_none_or(|w| w == n);
let mut networks: BTreeMap<u16, (BTreeSet<String>, Vec<u32>)> = BTreeMap::new();
if network.is_none() && ctx.policy.allows_network(0) {
networks.entry(0).or_default();
}
let mut routers = Vec::new();
for router in &known {
let reach: Vec<u16> = router
.networks
.iter()
.copied()
.filter(|&n| wanted(n))
.collect();
if reach.is_empty() {
continue;
}
let at = address(&router.mac);
for &n in &reach {
networks.entry(n).or_default().0.insert(at.clone());
}
routers.push(json!({ "address": at, "networks": reach }));
}
for found in engine.devices().await {
let on = found.source_network.unwrap_or(0);
let instance = found.object_identifier.instance_number();
if !wanted(on) || !ctx.policy.allows_device(instance) {
continue;
}
let entry = networks.entry(on).or_default();
if on != 0 {
entry.0.insert(address(&found.mac_address));
}
entry.1.push(instance);
}
let networks: Vec<Value> = networks
.into_iter()
.map(|(n, (via, mut instances))| {
instances.sort_unstable();
let speed = if ctx.policy.slow_networks.contains(&n) {
"slow"
} else if ctx.policy.fast_networks.contains(&n) {
"fast"
} else {
"normal"
};
let count = instances.len();
instances.truncate(LISTED);
json!({
"network": n,
"local": n == 0,
"routers": via,
"speed": speed,
"rate_per_s": ctx.policy.network_rate(n),
"devices": count,
"instances": instances,
})
})
.collect();
let mut body = json!({
"sent": args.send,
"routers_heard_s": heard,
"routers": routers,
"networks": networks,
"limits": ctx.limits(None),
});
if let Some(plan) = &plan {
body["bbmd"] = bbmd::run(ctx, engine, call, plan).await;
}
Ok(body)
}
async fn ask_routers<T: TransportPort + 'static>(
ctx: &Ctx<T>,
call: &Call,
network: Option<u16>,
wait_s: Option<u64>,
) -> Result<(), ToolError> {
let engine = ctx.engine()?;
ctx.allow_network(0)?;
if !ctx.policy.broad_discovery {
return Err(ToolError::refused(
"broad-discovery",
"a Who-Is-Router-To-Network is a broadcast every router answers: it needs \
broad_discovery = true in the profile's [mcp] table; call without send to see \
the routers and networks already known",
));
}
let asked = Instant::now();
let wanted = Duration::from_secs(wait_s.unwrap_or(3).clamp(1, 30));
let previous_claim = {
let mut routers = ctx.routers.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(at) = routers.asked
&& at.elapsed() < ROUTER_INTERVAL
{
let wait = ROUTER_INTERVAL - at.elapsed();
return Err(ToolError::Refused {
reason: "rate-limited",
message: "one Who-Is-Router-To-Network a minute".into(),
retry_after_s: Some(wait.as_secs().max(1)),
});
}
routers.asked.replace(asked)
};
match engine
.discover_routers(network, wanted, Some(call.deadline))
.await
{
Ok(found) => {
let mut routers = ctx.routers.lock().unwrap_or_else(PoisonError::into_inner);
let before = match (network, routers.heard.take()) {
(Some(_), Some((_, before))) => before,
_ => Vec::new(),
};
let mut merged = found;
for router in before {
match merged.iter_mut().find(|known| known.mac == router.mac) {
Some(known) => {
known.networks.extend(router.networks);
known.networks.sort_unstable();
known.networks.dedup();
}
None => merged.push(router),
}
}
merged.truncate(ROUTER_CAP);
routers.heard = Some((asked, merged));
Ok(())
}
Err(error) => {
ctx.routers
.lock()
.unwrap_or_else(PoisonError::into_inner)
.asked = previous_claim;
Err(ToolError::Failed(error.to_string()))
}
}
}