1pub mod audit;
21pub mod desk;
22pub mod file;
23pub mod ledger;
24pub mod lock;
25pub mod plan;
26mod send;
27
28use std::sync::OnceLock;
29use std::time::{SystemTime, UNIX_EPOCH};
30
31use bacnet_types::enums::{AbortReason, ErrorClass, ErrorCode, PropertyIdentifier};
32use bacnet_types::error::{Error, RemoteError};
33use bacnet_types::primitives::ObjectIdentifier;
34use serde::{Deserialize, Deserializer, Serialize, Serializer};
35
36use crate::class::Class;
37
38pub use send::{find, item_json};
39
40pub const DEFAULT_MINUTES: u32 = 15;
42
43pub const MAX_MINUTES: u32 = 480;
45
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub enum FrontEnd {
55 #[default]
57 Tui,
58 Mcp,
60 Other(String),
63}
64
65impl FrontEnd {
66 pub fn name(&self) -> &str {
68 match self {
69 Self::Tui => "tui",
70 Self::Mcp => "mcp",
71 Self::Other(name) => name,
72 }
73 }
74
75 pub fn is_tui(&self) -> bool {
77 *self == Self::Tui
78 }
79}
80
81impl Serialize for FrontEnd {
82 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
83 serializer.serialize_str(self.name())
84 }
85}
86
87impl<'de> Deserialize<'de> for FrontEnd {
88 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
89 let name = String::deserialize(deserializer)?;
90 Ok(match name.as_str() {
91 "tui" => Self::Tui,
92 "mcp" => Self::Mcp,
93 _ => Self::Other(name),
94 })
95 }
96}
97
98pub fn run_id() -> &'static str {
101 static RUN: OnceLock<String> = OnceLock::new();
102 RUN.get_or_init(|| {
103 let millis = SystemTime::now()
104 .duration_since(UNIX_EPOCH)
105 .unwrap_or_default()
106 .as_millis();
107 format!("{}-{millis}", std::process::id())
108 })
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum Level {
114 Passive,
116 ReadOnly,
118 Armed {
121 dangerous: bool,
123 },
124 Expired,
128}
129
130impl Level {
131 pub fn permits(self, class: Class) -> bool {
136 match (self, class) {
137 (Self::Passive, _) => false,
138 (_, Class::Read) => true,
139 (Self::Armed { .. }, Class::Write) => true,
140 (Self::Armed { dangerous }, Class::DeviceManagement) => dangerous,
141 (Self::ReadOnly | Self::Expired, _) => false,
142 }
143 }
144
145 pub fn audit_name(self) -> &'static str {
147 match self {
148 Self::Passive => "passive",
149 Self::ReadOnly => "read-only",
150 Self::Armed { dangerous: false } => "write-session",
151 Self::Armed { dangerous: true } => "write-session+dangerous",
152 Self::Expired => "write-session (expired)",
153 }
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
161pub enum Tier {
162 T1,
164 T2,
166 T3,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum Gate {
173 Allowed(Tier),
175 Blocked(String),
177}
178
179impl Gate {
180 pub fn name(&self) -> String {
182 match self {
183 Self::Allowed(tier) => format!("{tier:?}"),
184 Self::Blocked(_) => "blocked".into(),
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct Stored {
192 #[serde(serialize_with = "to_hex", deserialize_with = "from_hex")]
194 pub bytes: Vec<u8>,
195 pub text: String,
197}
198
199pub const NULL: [u8; 1] = [0x00];
201
202impl Stored {
203 pub fn null() -> Self {
205 Self {
206 bytes: NULL.to_vec(),
207 text: "NULL".into(),
208 }
209 }
210}
211
212fn to_hex<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
213 serializer.serialize_str(&hex(bytes))
214}
215
216pub fn hex(bytes: &[u8]) -> String {
218 bytes.iter().map(|b| format!("{b:02x}")).collect()
219}
220
221fn from_hex<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
222 let text = String::deserialize(deserializer)?;
223 if !text.len().is_multiple_of(2) || !text.is_ascii() {
224 return Err(serde::de::Error::custom("odd or non-ASCII hex"));
225 }
226 (0..text.len())
227 .step_by(2)
228 .map(|i| u8::from_str_radix(&text[i..i + 2], 16).map_err(serde::de::Error::custom))
229 .collect()
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct WriteSpec {
235 pub device: u32,
237 pub object: ObjectIdentifier,
239 pub property: PropertyIdentifier,
241 pub index: Option<u32>,
243 pub value: Option<String>,
245 pub priority: Option<u8>,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum Occupancy {
252 NotCommandable,
254 Empty,
256 Ours,
258 Other,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum Effect {
266 Changes {
268 from: String,
270 to: String,
272 },
273 Unchanged {
276 held_by: Option<u8>,
278 },
279 NotPresentValue,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct Planned {
286 pub spec: WriteSpec,
288 pub priority: Option<u8>,
291 pub value: Stored,
293 pub commandable: bool,
295 pub before: Option<Stored>,
298 pub occupancy: Occupancy,
300 pub effect: Effect,
302 pub out_of_service: Option<bool>,
304 pub notes: Vec<String>,
306 pub gate: Gate,
308}
309
310impl Planned {
311 pub fn label(&self) -> String {
313 label(
314 self.spec.device,
315 self.spec.object,
316 self.spec.property,
317 self.spec.index,
318 self.priority,
319 )
320 }
321
322 pub fn relinquishes(&self) -> bool {
324 self.value.bytes == NULL
325 }
326}
327
328pub fn label(
330 device: u32,
331 object: ObjectIdentifier,
332 property: PropertyIdentifier,
333 index: Option<u32>,
334 priority: Option<u8>,
335) -> String {
336 let object = crate::label::object_label(object);
337 let property = crate::label::kebab(property);
338 let index = index.map(|i| format!("[{i}]")).unwrap_or_default();
339 let priority = priority.map(|p| format!(" @{p}")).unwrap_or_default();
340 format!("device {device} {object} {property}{index}{priority}")
341}
342
343#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct Preview {
346 pub device: u32,
348 pub items: Vec<Planned>,
350 pub batch: bool,
352 pub gate: Gate,
354}
355
356impl Preview {
357 pub fn unchanged(&self, fresh: &Preview) -> bool {
361 self.items.len() == fresh.items.len()
362 && self.items.iter().zip(&fresh.items).all(|(a, b)| {
363 (a.before == b.before)
364 && (a.occupancy == b.occupancy)
365 && (a.priority == b.priority)
366 && (a.commandable == b.commandable)
367 })
368 }
369
370 pub fn service(&self) -> &'static str {
372 if self.batch {
373 "WritePropertyMultiple"
374 } else {
375 "WriteProperty"
376 }
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum Outcome {
383 Ack,
385 Denied,
388 Refused(RemoteError),
391 Timeout,
393 NotSent(String),
395 Failed(String),
397}
398
399impl Outcome {
400 pub fn of(result: &Result<(), Error>) -> Self {
402 let error = match result {
403 Ok(()) => return Self::Ack,
404 Err(Error::Timeout(_)) => return Self::Timeout,
405 Err(error) => error,
406 };
407 match error.remote() {
408 Some(RemoteError::Error {
409 class: ErrorClass::SERVICES,
410 code: ErrorCode::SERVICE_REQUEST_DENIED,
411 }) => Self::Denied,
412 Some(RemoteError::Abort {
413 reason: AbortReason::TSM_TIMEOUT,
414 }) => Self::Timeout,
415 Some(remote) => Self::Refused(remote),
416 None => Self::Failed(error.to_string()),
417 }
418 }
419
420 pub fn applied(&self) -> Option<bool> {
422 match self {
423 Self::Ack => Some(true),
424 Self::Denied | Self::Refused(_) | Self::NotSent(_) => Some(false),
425 Self::Timeout | Self::Failed(_) => None,
426 }
427 }
428
429 pub fn describe(&self) -> String {
432 match self {
433 Self::Ack => "acknowledged".into(),
434 Self::Denied => "DENIED by the device's policy (SERVICE_REQUEST_DENIED)".into(),
435 Self::Refused(RemoteError::Error { class, code }) => format!("error {class} / {code}"),
436 Self::Refused(RemoteError::Reject { reason }) => format!("rejected, reason {reason}"),
437 Self::Refused(RemoteError::Abort { reason }) => format!("aborted, reason {reason}"),
438 Self::Timeout => "no answer: it may or may not have been applied".into(),
439 Self::NotSent(why) => format!("not sent: {why}"),
440 Self::Failed(why) => format!("failed: {why}"),
441 }
442 }
443
444 pub fn json(&self) -> serde_json::Value {
447 use serde_json::json;
448 match self {
449 Self::Ack => json!({ "result": "ack" }),
450 Self::Denied => json!({ "result": "error", "class": "SERVICES",
451 "code": "SERVICE_REQUEST_DENIED", "denied": true }),
452 Self::Refused(RemoteError::Error { class, code }) => json!({ "result": "error",
453 "class": class.to_string(), "code": code.to_string() }),
454 Self::Refused(RemoteError::Reject { reason }) => json!({ "result": "reject",
455 "reason": reason.to_string() }),
456 Self::Refused(RemoteError::Abort { reason }) => json!({ "result": "abort",
457 "reason": reason.to_string() }),
458 Self::Timeout => json!({ "result": "timeout" }),
459 Self::NotSent(why) => json!({ "result": "not-sent", "why": why }),
460 Self::Failed(why) => json!({ "result": "failed", "why": why }),
461 }
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
471 fn levels_permit_the_shared_classes() {
472 use Class::{DeviceManagement, Read, Write};
473 let table = [
474 (Level::Passive, [false, false, false]),
475 (Level::ReadOnly, [true, false, false]),
476 (Level::Expired, [true, false, false]),
477 (Level::Armed { dangerous: false }, [true, true, false]),
478 (Level::Armed { dangerous: true }, [true, true, true]),
479 ];
480 for (level, permits) in table {
481 let got = [Read, Write, DeviceManagement].map(|class| level.permits(class));
482 assert_eq!(got, permits, "{level:?}");
483 }
484 }
485}