mod content;
pub(crate) mod uri;
use std::sync::Arc;
use std::time::Duration;
use bacnet_ops::limits::{Account, charged};
use bacnet_transport::port::TransportPort;
use rmcp::ErrorData;
use rmcp::model::{ReadResourceResult, Resource, ResourceContents, ResourceTemplate};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
pub(crate) use self::content::{Cache, point_body, refresh_alarms};
use self::uri::{ALARMS, DEVICES, LEDGER, SESSION, Uri};
use crate::context::{Call, Ctx};
use crate::errors::ToolError;
const JSON: &str = "application/json";
const GRACE: Duration = Duration::from_secs(15);
pub(crate) fn list<T: TransportPort + 'static>(ctx: &Ctx<T>) -> Vec<Resource> {
let all = [
(
DEVICES,
"devices",
"Discovered devices",
"The devices in reach in the discovery table; updated as devices arrive or are lost",
),
(
ALARMS,
"alarms",
"Active alarms",
"Active and unacknowledged alarms of the devices in reach, polled every 60 s",
),
(
SESSION,
"session",
"MCP session",
"The server's level, limits, rate and watch footprint",
),
(
LEDGER,
"ledger",
"Write ledger",
"This principal's writes still in effect, with what undoes each",
),
];
all.into_iter()
.filter(|(uri, ..)| ctx.engine.is_some() || *uri == SESSION || *uri == LEDGER)
.map(|(uri, name, title, description)| {
Resource::new(uri, name)
.with_title(title)
.with_description(description)
.with_mime_type(JSON)
})
.collect()
}
pub(crate) fn templates<T: TransportPort + 'static>(ctx: &Ctx<T>) -> Vec<ResourceTemplate> {
if ctx.engine.is_none() {
return Vec::new();
}
[
(
"bacnet://devices/after/{instance}",
"devices-after",
"More discovered devices",
"The next page of the device list: the devices whose instance is above the one \
given, as many as bacnet://devices holds; its `next` names the page after",
),
(
"bacnet://device/{instance}",
"device",
"A device",
"One device's summary: vendor, model, revisions, services and object types",
),
(
"bacnet://device/{instance}/object/{object}",
"point",
"A point",
"An object's point facts (present value, status, units, priorities); a \
subscription watches it by COV, or by polling when the device refuses",
),
(
"bacnet://device/{instance}/object/{object}/property/{property}",
"property",
"A property",
"One property of an object; a subscription watches it by COV for the property, \
or by polling",
),
(
"bacnet://device/{instance}/alarms",
"device-alarms",
"A device's alarms",
"One device's active and unacknowledged alarms, polled every 60 s",
),
]
.into_iter()
.map(|(template, name, title, description)| {
ResourceTemplate::new(template, name)
.with_title(title)
.with_description(description)
.with_mime_type(JSON)
})
.collect()
}
pub(crate) async fn read<T: TransportPort + 'static>(
ctx: &Ctx<T>,
text: &str,
cancel: CancellationToken,
) -> Result<ReadResourceResult, ErrorData> {
let parsed = uri::parse(text).map_err(|why| {
let error = ToolError::invalid("uri", why);
ErrorData::invalid_params(error.summary(), Some(error.json()))
})?;
let account = Arc::new(Account::new(Some(Arc::clone(&ctx.bucket))));
let budget = ctx.policy.caps.budget;
let call = Call::new(budget, Arc::clone(&account));
let work = async {
match parsed {
Uri::Point(point) => content::point(ctx, &call, point).await,
Uri::Devices => content::devices(ctx, None).await,
Uri::DevicesAfter(after) => content::devices(ctx, Some(after)).await,
Uri::Device(device) => content::device(ctx, &call, device).await,
Uri::Alarms => content::alarms(ctx, &call).await,
Uri::DeviceAlarms(device) => content::device_alarms(ctx, &call, device).await,
Uri::Session => Ok(content::session(ctx)),
Uri::Ledger => Ok(content::ledger(ctx)),
}
};
let finished = tokio::select! {
finished = tokio::time::timeout(budget + GRACE, charged(account, work)) => finished,
() = cancel.cancelled() => {
ctx.log_read("resources/read", &call, "cancelled");
return Err(ErrorData::internal_error("the read was cancelled", None));
}
};
let outcome = finished.unwrap_or(Err(ToolError::Timeout {
device: None,
after_ms: u64::try_from((budget + GRACE).as_millis()).unwrap_or(u64::MAX),
}));
match outcome {
Ok(mut body) => {
body["uri"] = Value::from(text);
body["cost"] = call.cost();
ctx.log_read("resources/read", &call, "ok");
let contents = ResourceContents::text(body.to_string(), text).with_mime_type(JSON);
Ok(ReadResourceResult::new(vec![contents]))
}
Err(error) => {
ctx.log_read("resources/read", &call, &error.summary());
let mut data = error.json();
data["cost"] = call.cost();
Err(match error {
ToolError::Invalid { .. }
| ToolError::Refused { .. }
| ToolError::UnknownDevice { .. } => {
ErrorData::invalid_params(error.summary(), Some(data))
}
_ => ErrorData::internal_error(error.summary(), Some(data)),
})
}
}
}