use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use bacnet_client::client::{DeviceEventKind, DiscoveredRouter};
use bacnet_client::discovery::DiscoveredDevice;
use bacnet_ops::engine::Engine;
use bacnet_ops::limits::{Account, TokenBucket};
use bacnet_ops::target::{self, Target};
use bacnet_ops::watch::Hub;
use bacnet_transport::port::TransportPort;
use bacnet_types::primitives::ObjectIdentifier;
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::watch;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::cursor::Cursors;
use crate::errors::ToolError;
use crate::listen::{Subs, TOOL_IDLE};
use crate::policy::Policy;
use crate::resources::Cache;
pub(crate) struct Ctx<T: TransportPort + 'static> {
pub(crate) engine: Option<Arc<Engine<T>>>,
pub(crate) policy: Policy,
pub(crate) principal: String,
pub(crate) cursors: Cursors,
pub(crate) bucket: Arc<TokenBucket>,
pub(crate) broad: Mutex<Option<Instant>>,
pub(crate) lists: Mutex<HashMap<u32, CachedList>>,
pub(crate) list_locks: Mutex<HashMap<u32, Arc<tokio::sync::Mutex<()>>>>,
pub(crate) reads: Mutex<BTreeMap<u32, (u64, u64)>>,
pub(crate) names: Mutex<HashMap<u32, CachedNames>>,
pub(crate) routers: Mutex<Routers>,
pub(crate) bbmd_asked: Mutex<Option<Instant>>,
pub(crate) hub: Option<Hub<T>>,
pub(crate) subs: Arc<Subs>,
pub(crate) closing: CancellationToken,
pub(crate) listening: watch::Sender<usize>,
pub(crate) known: Mutex<HashMap<u32, u16>>,
pub(crate) described: Cache,
pub(crate) alarms: Cache,
pub(crate) writes: crate::writes::Writes,
pub(crate) handoffs: tokio_util::task::TaskTracker,
}
pub(crate) struct Listening<'a>(&'a watch::Sender<usize>);
impl Drop for Listening<'_> {
fn drop(&mut self) {
self.0.send_modify(|n| *n = n.saturating_sub(1));
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Routers {
pub(crate) asked: Option<Instant>,
pub(crate) heard: Option<(Instant, Vec<DiscoveredRouter>)>,
}
pub(crate) const ROUTER_CAP: usize = 256;
#[derive(Debug, Clone)]
pub(crate) struct CachedNames {
pub(crate) at: Instant,
pub(crate) objects: HashMap<ObjectIdentifier, Names>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Names {
pub(crate) name: Option<Cleaned>,
pub(crate) description: Option<Option<Cleaned>>,
}
#[derive(Debug, Clone)]
pub(crate) struct Cleaned {
pub(crate) text: String,
pub(crate) truncated: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct CachedList {
pub(crate) at: Instant,
pub(crate) objects: Arc<Vec<ObjectIdentifier>>,
pub(crate) indexed: Option<(u32, u32)>,
pub(crate) gaps: Vec<u32>,
}
impl CachedList {
pub(crate) fn complete(&self) -> bool {
self.indexed.is_none_or(|(total, next)| next > total)
}
pub(crate) fn fresh(&self) -> bool {
let ttl = if self.complete() {
LIST_TTL
} else {
PARTIAL_TTL
};
self.at.elapsed() < ttl
}
}
pub(crate) const LIST_TTL: Duration = Duration::from_secs(300);
pub(crate) const PARTIAL_TTL: Duration = Duration::from_secs(600);
pub(crate) const LIST_CAP: usize = 64;
pub(crate) struct Call {
pub(crate) started: Instant,
pub(crate) deadline: Instant,
pub(crate) account: Arc<Account>,
timeouts: AtomicU32,
device: Mutex<Option<u32>>,
}
impl Call {
pub(crate) fn new(budget: Duration, account: Arc<Account>) -> Self {
let started = Instant::now();
Self {
started,
deadline: started + budget,
account,
timeouts: AtomicU32::new(0),
device: Mutex::new(None),
}
}
pub(crate) fn out_of_time(&self) -> bool {
Instant::now() >= self.deadline
}
pub(crate) fn timed_out(&self) {
self.timeouts.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn reached(&self, device: u32) {
*self.device.lock().unwrap_or_else(PoisonError::into_inner) = Some(device);
}
pub(crate) fn device(&self) -> Option<u32> {
*self.device.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn cost(&self) -> Value {
let ms = |d: Duration| u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
json!({
"requests": self.account.requests(),
"elapsed_ms": ms(self.started.elapsed()),
"throttled_ms": ms(self.account.throttled()),
"timeouts": self.timeouts.load(Ordering::Relaxed),
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub(crate) enum DeviceArg {
Number(u32),
Text(String),
}
impl DeviceArg {
pub(crate) fn text(&self) -> String {
match self {
Self::Number(n) => n.to_string(),
Self::Text(text) => text.clone(),
}
}
}
pub(crate) struct Device {
pub(crate) instance: u32,
pub(crate) network: u16,
pub(crate) entry: DiscoveredDevice,
}
const DISCOVER_HINT: &str = "call bacnet_discover first (with a range or network that includes it)";
impl<T: TransportPort + 'static> Ctx<T> {
pub(crate) fn engine(&self) -> Result<&Engine<T>, ToolError> {
self.engine.as_deref().ok_or_else(|| {
ToolError::refused(
"passive",
"the server is passive: it builds no client and sends nothing",
)
})
}
pub(crate) async fn device(&self, call: &Call, arg: &DeviceArg) -> Result<Device, ToolError> {
let engine = self.engine()?;
let text = arg.text();
let unknown = || ToolError::UnknownDevice {
device: text.clone(),
hint: DISCOVER_HINT.into(),
};
let parsed = target::parse_target(&text).map_err(|e| ToolError::invalid("device", e))?;
let entry = match parsed {
Target::Instance(instance) => engine.device(instance).await.ok_or_else(unknown)?,
Target::Routed { dnet, instance } => engine
.device(instance)
.await
.filter(|found| found.source_network == Some(dnet))
.ok_or_else(unknown)?,
Target::RoutedMac { dnet, mac } => engine
.devices()
.await
.into_iter()
.find(|found| {
found.source_network == Some(dnet)
&& found.source_address.as_deref() == Some(&mac[..])
})
.ok_or_else(unknown)?,
Target::Mac(_) | Target::Bip6(_) => {
return Err(ToolError::invalid(
"device",
"a device instance, dnet:instance or dnet:0xMAC, not an address",
));
}
};
let device = Device {
instance: entry.object_identifier.instance_number(),
network: entry.source_network.unwrap_or(0),
entry,
};
self.allow(device.instance, device.network)?;
self.remember(&device.entry);
call.reached(device.instance);
Ok(device)
}
pub(crate) fn remember(&self, found: &DiscoveredDevice) {
let instance = found.object_identifier.instance_number();
let network = found.source_network.unwrap_or(0);
self.known
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(instance, network);
}
pub(crate) fn reach_known(&self, instance: u32) -> bool {
self.reach_check(instance).is_ok()
}
pub(crate) fn reach_check(&self, instance: u32) -> Result<(), ToolError> {
let network = self
.known
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(&instance)
.copied();
let network = network.ok_or_else(|| ToolError::UnknownDevice {
device: instance.to_string(),
hint: DISCOVER_HINT.into(),
})?;
self.allow(instance, network)
}
pub(crate) fn known_in_reach(&self) -> Vec<u32> {
let known = self.known.lock().unwrap_or_else(PoisonError::into_inner);
let mut devices: Vec<u32> = known
.iter()
.filter(|(instance, network)| self.allow(**instance, **network).is_ok())
.map(|(instance, _)| *instance)
.collect();
devices.sort_unstable();
devices
}
pub(crate) async fn follow_devices(&self) {
let Some(engine) = &self.engine else {
return;
};
let mut events = engine.device_events();
let mut resync = true;
let mut sweep = tokio::time::interval(Duration::from_secs(60));
loop {
if resync {
let devices = engine.devices().await;
let mut known = self.known.lock().unwrap_or_else(PoisonError::into_inner);
known.clear();
for found in &devices {
known.insert(
found.object_identifier.instance_number(),
found.source_network.unwrap_or(0),
);
}
resync = false;
}
let event = tokio::select! {
() = self.closing.cancelled() => return,
event = events.recv() => event,
_ = sweep.tick() => {
self.subs.expire_tool(TOOL_IDLE);
self.subs.prune();
continue;
}
};
match event {
Ok(event) if event.kind == DeviceEventKind::Lost => {
let instance = event.device.object_identifier.instance_number();
self.known
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(&instance);
}
Ok(event) => self.remember(&event.device),
Err(RecvError::Lagged(_)) => resync = true,
Err(RecvError::Closed) => return,
}
}
}
pub(crate) fn listening(&self, max: usize) -> Option<Listening<'_>> {
let mut taken = false;
self.listening.send_if_modified(|n| {
taken = *n < max;
if taken {
*n += 1;
}
taken
});
taken.then(|| Listening(&self.listening))
}
pub(crate) fn allow_network(&self, network: u16) -> Result<(), ToolError> {
if self.policy.allows_network(network) {
return Ok(());
}
Err(ToolError::refused(
"network-not-allowed",
format!("network {network} is not in this server's [mcp] networks"),
))
}
pub(crate) fn allow(&self, instance: u32, network: u16) -> Result<(), ToolError> {
self.allow_network(network)?;
if !self.policy.allows_device(instance) {
return Err(ToolError::refused(
"device-not-allowed",
format!("device {instance} is not in this server's [mcp] devices"),
));
}
Ok(())
}
pub(crate) fn log_read(&self, tool: &str, call: &Call, outcome: &str) {
let requests = call.account.requests();
let device = call.device();
tracing::info!(
target: "bacnet_mcp::reads",
tool,
device,
requests,
outcome,
"read call"
);
if let Some(device) = device {
let mut reads = self.reads.lock().unwrap_or_else(PoisonError::into_inner);
let entry = reads.entry(device).or_default();
entry.0 += 1;
entry.1 += u64::from(requests);
}
}
pub(crate) fn log_summary(&self) {
let reads = self.reads.lock().unwrap_or_else(PoisonError::into_inner);
for (device, (calls, requests)) in reads.iter() {
tracing::info!(
target: "bacnet_mcp::reads",
principal = %self.principal,
device,
calls,
requests,
"read summary"
);
}
}
pub(crate) fn limits(&self, page: Option<usize>) -> Value {
let rates = &self.policy.rates;
let mut limits = json!({
"rate_per_s": rates.global,
"principal_rate_per_s": rates.principal,
"per_device_outstanding": 1,
});
if let Some(page) = page {
limits["page"] = json!(page);
}
limits
}
}