use std::path::PathBuf;
use bacnet_types::enums::PropertyIdentifier;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum WriteMode {
#[default]
Never,
Elicit,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ArmMode {
#[default]
Never,
Elicit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Workspace {
pub dir: PathBuf,
pub profile: Option<String>,
pub transport: &'static str,
pub local: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanGrant {
pub devices: Vec<(u32, u32)>,
pub properties: Vec<PropertyIdentifier>,
pub priorities: (u8, u8),
pub max_writes: u32,
pub expires_unix: u64,
pub reason: Option<String>,
}
pub const MAX_PLAN_SECS: u64 = 8 * 3_600;
pub const MAX_PLAN_WRITES: u32 = 10_000;
pub const MAX_WRITES: usize = 20;
impl PlanGrant {
pub fn covers_device(&self, device: u32) -> bool {
self.devices
.iter()
.any(|&(low, high)| (low..=high).contains(&device))
}
pub fn covers(&self, device: u32, property: PropertyIdentifier, priority: Option<u8>) -> bool {
let (low, high) = self.priorities;
self.covers_device(device)
&& self.properties.contains(&property)
&& priority.is_none_or(|p| p != 6 && (low..=high).contains(&p))
}
fn validate(&self) -> Result<(), String> {
if self.devices.is_empty() || self.properties.is_empty() {
return Err("plan: name its devices and properties".into());
}
for &(low, high) in &self.devices {
if low > high || high > bacnet_types::primitives::ObjectIdentifier::MAX_INSTANCE {
return Err(format!("plan: devices {low}-{high} is not a range"));
}
}
let (low, high) = self.priorities;
if low == 0 || low > high || high > 16 {
return Err(format!("plan: priorities {low}-{high} is not within 1-16"));
}
if !(1..=MAX_PLAN_WRITES).contains(&self.max_writes) {
return Err(format!("plan: max_writes must be 1 to {MAX_PLAN_WRITES}"));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs());
if self.expires_unix > now.saturating_add(MAX_PLAN_SECS) {
return Err(format!(
"plan: it may run at most {} hours from now",
MAX_PLAN_SECS / 3_600
));
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WritePolicy {
pub mode: WriteMode,
pub arm: ArmMode,
pub plan: Option<PlanGrant>,
pub workspace: Option<Workspace>,
}
impl WritePolicy {
pub fn offered(&self) -> bool {
self.mode == WriteMode::Elicit
}
pub(crate) fn validate(&self) -> Result<(), String> {
if self.plan.is_some() && !self.offered() {
return Err("a plan grant needs mcp_writes = \"elicit\"".into());
}
if self.offered() && self.workspace.is_none() {
return Err("writes need a profile workspace for the ledger and the audit log".into());
}
if self.arm == ArmMode::Elicit && !self.offered() {
return Err("mcp_arm = \"elicit\" needs mcp_writes = \"elicit\"".into());
}
self.plan.as_ref().map_or(Ok(()), PlanGrant::validate)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> u64 {
crate::writes::unix_now()
}
fn grant() -> PlanGrant {
PlanGrant {
devices: vec![(10, 20)],
properties: vec![PropertyIdentifier::PRESENT_VALUE],
priorities: (1, 16),
max_writes: 5,
expires_unix: now() + 60,
reason: None,
}
}
#[test]
fn a_plan_covers_its_devices_properties_and_priorities_but_never_6() {
let plan = grant();
let pv = PropertyIdentifier::PRESENT_VALUE;
assert!(plan.covers(10, pv, Some(1)) && plan.covers(20, pv, Some(16)));
assert!(plan.covers(15, pv, None), "not commandable");
assert!(!plan.covers(15, pv, Some(6)));
assert!(!plan.covers(21, pv, Some(8)));
assert!(!plan.covers(15, PropertyIdentifier::OUT_OF_SERVICE, None));
let narrow = PlanGrant {
priorities: (8, 16),
..grant()
};
assert!(!narrow.covers(15, pv, Some(7)));
}
#[test]
fn a_plan_lasts_at_most_eight_hours() {
assert!(grant().validate().is_ok());
let long = PlanGrant {
expires_unix: now() + MAX_PLAN_SECS + 60,
..grant()
};
assert!(long.validate().is_err());
}
}