pub mod audit;
pub mod desk;
pub mod file;
pub mod ledger;
pub mod lock;
pub mod plan;
mod send;
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use bacnet_types::enums::{AbortReason, ErrorClass, ErrorCode, PropertyIdentifier};
use bacnet_types::error::{Error, RemoteError};
use bacnet_types::primitives::ObjectIdentifier;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::class::Class;
pub use send::{find, item_json};
pub const DEFAULT_MINUTES: u32 = 15;
pub const MAX_MINUTES: u32 = 480;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum FrontEnd {
#[default]
Tui,
Mcp,
Other(String),
}
impl FrontEnd {
pub fn name(&self) -> &str {
match self {
Self::Tui => "tui",
Self::Mcp => "mcp",
Self::Other(name) => name,
}
}
pub fn is_tui(&self) -> bool {
*self == Self::Tui
}
}
impl Serialize for FrontEnd {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl<'de> Deserialize<'de> for FrontEnd {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
Ok(match name.as_str() {
"tui" => Self::Tui,
"mcp" => Self::Mcp,
_ => Self::Other(name),
})
}
}
pub fn run_id() -> &'static str {
static RUN: OnceLock<String> = OnceLock::new();
RUN.get_or_init(|| {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("{}-{millis}", std::process::id())
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Passive,
ReadOnly,
Armed {
dangerous: bool,
},
Expired,
}
impl Level {
pub fn permits(self, class: Class) -> bool {
match (self, class) {
(Self::Passive, _) => false,
(_, Class::Read) => true,
(Self::Armed { .. }, Class::Write) => true,
(Self::Armed { dangerous }, Class::DeviceManagement) => dangerous,
(Self::ReadOnly | Self::Expired, _) => false,
}
}
pub fn audit_name(self) -> &'static str {
match self {
Self::Passive => "passive",
Self::ReadOnly => "read-only",
Self::Armed { dangerous: false } => "write-session",
Self::Armed { dangerous: true } => "write-session+dangerous",
Self::Expired => "write-session (expired)",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Tier {
T1,
T2,
T3,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Gate {
Allowed(Tier),
Blocked(String),
}
impl Gate {
pub fn name(&self) -> String {
match self {
Self::Allowed(tier) => format!("{tier:?}"),
Self::Blocked(_) => "blocked".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Stored {
#[serde(serialize_with = "to_hex", deserialize_with = "from_hex")]
pub bytes: Vec<u8>,
pub text: String,
}
pub const NULL: [u8; 1] = [0x00];
impl Stored {
pub fn null() -> Self {
Self {
bytes: NULL.to_vec(),
text: "NULL".into(),
}
}
}
fn to_hex<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&hex(bytes))
}
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn from_hex<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let text = String::deserialize(deserializer)?;
if !text.len().is_multiple_of(2) || !text.is_ascii() {
return Err(serde::de::Error::custom("odd or non-ASCII hex"));
}
(0..text.len())
.step_by(2)
.map(|i| u8::from_str_radix(&text[i..i + 2], 16).map_err(serde::de::Error::custom))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WriteSpec {
pub device: u32,
pub object: ObjectIdentifier,
pub property: PropertyIdentifier,
pub index: Option<u32>,
pub value: Option<String>,
pub priority: Option<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Occupancy {
NotCommandable,
Empty,
Ours,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
Changes {
from: String,
to: String,
},
Unchanged {
held_by: Option<u8>,
},
NotPresentValue,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Planned {
pub spec: WriteSpec,
pub priority: Option<u8>,
pub value: Stored,
pub commandable: bool,
pub before: Option<Stored>,
pub occupancy: Occupancy,
pub effect: Effect,
pub out_of_service: Option<bool>,
pub notes: Vec<String>,
pub gate: Gate,
}
impl Planned {
pub fn label(&self) -> String {
label(
self.spec.device,
self.spec.object,
self.spec.property,
self.spec.index,
self.priority,
)
}
pub fn relinquishes(&self) -> bool {
self.value.bytes == NULL
}
}
pub fn label(
device: u32,
object: ObjectIdentifier,
property: PropertyIdentifier,
index: Option<u32>,
priority: Option<u8>,
) -> String {
let object = crate::label::object_label(object);
let property = crate::label::kebab(property);
let index = index.map(|i| format!("[{i}]")).unwrap_or_default();
let priority = priority.map(|p| format!(" @{p}")).unwrap_or_default();
format!("device {device} {object} {property}{index}{priority}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Preview {
pub device: u32,
pub items: Vec<Planned>,
pub batch: bool,
pub gate: Gate,
}
impl Preview {
pub fn unchanged(&self, fresh: &Preview) -> bool {
self.items.len() == fresh.items.len()
&& self.items.iter().zip(&fresh.items).all(|(a, b)| {
(a.before == b.before)
&& (a.occupancy == b.occupancy)
&& (a.priority == b.priority)
&& (a.commandable == b.commandable)
})
}
pub fn service(&self) -> &'static str {
if self.batch {
"WritePropertyMultiple"
} else {
"WriteProperty"
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Ack,
Denied,
Refused(RemoteError),
Timeout,
NotSent(String),
Failed(String),
}
impl Outcome {
pub fn of(result: &Result<(), Error>) -> Self {
let error = match result {
Ok(()) => return Self::Ack,
Err(Error::Timeout(_)) => return Self::Timeout,
Err(error) => error,
};
match error.remote() {
Some(RemoteError::Error {
class: ErrorClass::SERVICES,
code: ErrorCode::SERVICE_REQUEST_DENIED,
}) => Self::Denied,
Some(RemoteError::Abort {
reason: AbortReason::TSM_TIMEOUT,
}) => Self::Timeout,
Some(remote) => Self::Refused(remote),
None => Self::Failed(error.to_string()),
}
}
pub fn applied(&self) -> Option<bool> {
match self {
Self::Ack => Some(true),
Self::Denied | Self::Refused(_) | Self::NotSent(_) => Some(false),
Self::Timeout | Self::Failed(_) => None,
}
}
pub fn describe(&self) -> String {
match self {
Self::Ack => "acknowledged".into(),
Self::Denied => "DENIED by the device's policy (SERVICE_REQUEST_DENIED)".into(),
Self::Refused(RemoteError::Error { class, code }) => format!("error {class} / {code}"),
Self::Refused(RemoteError::Reject { reason }) => format!("rejected, reason {reason}"),
Self::Refused(RemoteError::Abort { reason }) => format!("aborted, reason {reason}"),
Self::Timeout => "no answer: it may or may not have been applied".into(),
Self::NotSent(why) => format!("not sent: {why}"),
Self::Failed(why) => format!("failed: {why}"),
}
}
pub fn json(&self) -> serde_json::Value {
use serde_json::json;
match self {
Self::Ack => json!({ "result": "ack" }),
Self::Denied => json!({ "result": "error", "class": "SERVICES",
"code": "SERVICE_REQUEST_DENIED", "denied": true }),
Self::Refused(RemoteError::Error { class, code }) => json!({ "result": "error",
"class": class.to_string(), "code": code.to_string() }),
Self::Refused(RemoteError::Reject { reason }) => json!({ "result": "reject",
"reason": reason.to_string() }),
Self::Refused(RemoteError::Abort { reason }) => json!({ "result": "abort",
"reason": reason.to_string() }),
Self::Timeout => json!({ "result": "timeout" }),
Self::NotSent(why) => json!({ "result": "not-sent", "why": why }),
Self::Failed(why) => json!({ "result": "failed", "why": why }),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_permit_the_shared_classes() {
use Class::{DeviceManagement, Read, Write};
let table = [
(Level::Passive, [false, false, false]),
(Level::ReadOnly, [true, false, false]),
(Level::Expired, [true, false, false]),
(Level::Armed { dangerous: false }, [true, true, false]),
(Level::Armed { dangerous: true }, [true, true, true]),
];
for (level, permits) in table {
let got = [Read, Write, DeviceManagement].map(|class| level.permits(class));
assert_eq!(got, permits, "{level:?}");
}
}
}