use std::time::{Duration, Instant};
use bacnet_types::enums::PropertyIdentifier;
use super::browser::ViewState;
use super::{App, Link, Overlay, FLASH_FOR};
use crate::core::class::Class;
use crate::parse::{parse_property, parse_value};
use crate::tui::message::Command;
use crate::tui::point::BrowseRead;
use crate::tui::profile::{Safety, Writes};
use crate::tui::safety::ledger::Entry;
use crate::tui::safety::{
Gate, Level, Outcome, Preview, SafetyCommand, SafetyEvent, Tier, WriteSpec, DEFAULT_MINUTES,
MAX_MINUTES,
};
pub(crate) const CONFIRM_GUARD: Duration = Duration::from_millis(300);
pub(crate) const PREVIEW_LIFETIME: Duration = Duration::from_secs(120);
pub(crate) const ARM_HINT: &str = "Read-only: press W to arm a write session first.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Armed {
pub(crate) until: Instant,
pub(crate) reason: String,
}
#[derive(Debug, Default)]
pub(crate) struct SafetyState {
pub(crate) passive: bool,
pub(crate) policy: Option<Safety>,
pub(crate) workspace: bool,
pub(crate) run: String,
pub(crate) armed: Option<Armed>,
pub(crate) ledger: Vec<Entry>,
pending_reason: String,
pub(crate) pending: Option<u64>,
next_req: u64,
quitting: bool,
last_second: u64,
}
impl SafetyState {
pub(crate) fn new(passive: bool) -> Self {
Self {
passive,
..Self::default()
}
}
pub(crate) fn level(&self) -> Level {
match (&self.armed, &self.policy) {
_ if self.passive => Level::Passive,
(Some(_), policy) => Level::Armed {
dangerous: policy.as_ref().is_some_and(|p| p.dangerous),
},
(None, _) => Level::ReadOnly,
}
}
pub(crate) fn leftovers(&self) -> usize {
self.ledger.iter().filter(|e| e.run != self.run).count()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ModalKey {
Char(char),
Backspace,
Next,
Prev,
Enter,
Esc,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Form {
pub(crate) fields: Vec<(&'static str, String)>,
pub(crate) focus: usize,
pub(crate) error: Option<String>,
}
impl Form {
fn new(fields: Vec<(&'static str, String)>) -> Self {
Self {
fields,
focus: 0,
error: None,
}
}
fn get(&self, label: &str) -> &str {
self.fields
.iter()
.find(|(l, _)| *l == label)
.map_or("", |(_, text)| text.trim())
}
fn key(&mut self, key: ModalKey) -> bool {
let count = self.fields.len();
match key {
ModalKey::Char(ch) if self.fields[self.focus].1.len() < 128 => {
self.fields[self.focus].1.push(ch)
}
ModalKey::Backspace => {
self.fields[self.focus].1.pop();
}
ModalKey::Next => self.focus = (self.focus + 1) % count,
ModalKey::Prev => self.focus = (self.focus + count - 1) % count,
ModalKey::Enter => return true,
_ => {}
}
false
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Modal {
Arm(Form),
Write {
device: u32,
object: bacnet_types::primitives::ObjectIdentifier,
relinquish: bool,
form: Form,
},
Waiting {
req: u64,
what: &'static str,
},
Confirm {
req: u64,
preview: Box<Preview>,
typed: String,
error: Option<String>,
opened: Instant,
},
Result(Vec<(String, Outcome)>),
Ledger {
notice: Option<String>,
confirm: bool,
},
ExitGuard(Option<String>),
}
fn modal(modal: Modal) -> Overlay {
Overlay::Safety(Box::new(modal))
}
impl App {
pub(crate) fn level(&self) -> Level {
self.safety.level()
}
fn safety_req(&mut self) -> u64 {
self.safety.next_req += 1;
self.safety.next_req
}
pub(super) fn may_transmit(&mut self) -> bool {
if self.safety.passive {
self.flash("Passive: this session transmits nothing.", FLASH_FOR);
}
!self.safety.passive
}
pub(super) fn toggle_arm(&mut self, out: &mut Vec<Command>) {
if self.safety.armed.take().is_some() {
out.push(Command::Safety(SafetyCommand::Disarm { expired: false }));
self.flash("Disarmed: read-only.", FLASH_FOR);
return;
}
if !self.may_transmit() {
return;
}
let why = match &self.safety.policy {
None => Some("Not connected: a write session needs a session to arm."),
Some(policy) if policy.writes == Writes::Never => {
Some("Writes are disabled: this profile sets writes = \"never\".")
}
Some(_) if !self.safety.workspace => {
Some("Write sessions need a profile: its workspace holds the ledger and audit log.")
}
Some(_) => None,
};
match why {
Some(why) => self.flash(why, FLASH_FOR),
None => {
self.overlay = modal(Modal::Arm(Form::new(vec![
("Reason", String::new()),
("Minutes", DEFAULT_MINUTES.to_string()),
])))
}
}
}
pub(super) fn open_write(&mut self, relinquish: bool) {
if !self.may_transmit() {
return;
}
if !self.level().permits(Class::Write) {
self.flash(ARM_HINT, FLASH_FOR);
return;
}
let Some(browser) = self.shown_browser() else {
self.flash("Open a device and an object first.", FLASH_FOR);
return;
};
let ViewState::Ready(view) = &browser.view else {
self.flash("Read an object first (Enter).", FLASH_FOR);
return;
};
let (device, object) = (browser.device, view.object);
let ours = self
.safety
.ledger
.iter()
.find(|e| e.device == device && e.object() == Some(object) && e.priority.is_some());
let winner = view.priority.as_ref().and_then(|p| p.winner());
let priority = match (relinquish, ours, &view.priority) {
(true, Some(entry), _) => entry.priority.map(|p| p.to_string()),
(true, None, _) => winner.map(|p| p.to_string()),
(false, _, Some(_)) => Some("16".into()),
(false, _, None) => None,
};
let mut fields = Vec::new();
if !relinquish {
fields.push(("Property", "present-value".to_string()));
fields.push(("Value", String::new()));
}
fields.push(("Priority", priority.unwrap_or_default()));
let mut form = Form::new(fields);
form.focus = usize::from(!relinquish);
self.overlay = modal(Modal::Write {
device,
object,
relinquish,
form,
});
}
pub(super) fn open_ledger(&mut self) {
self.overlay = modal(Modal::Ledger {
notice: None,
confirm: false,
});
}
pub(super) fn request_quit(&mut self) {
let guarding =
matches!(&self.overlay, Overlay::Safety(m) if matches!(**m, Modal::ExitGuard(_)));
let open = !self.safety.ledger.is_empty() || self.safety.pending.is_some();
if !open || guarding {
self.quit = true;
} else {
self.overlay = modal(Modal::ExitGuard(None));
}
}
pub(super) fn prepare(&mut self, specs: Vec<WriteSpec>, batch: bool, out: &mut Vec<Command>) {
if !self.may_transmit() {
return;
}
if !self.level().permits(Class::Write) {
self.flash(ARM_HINT, FLASH_FOR);
return;
}
let req = self.safety_req();
self.safety.pending = Some(req);
out.push(Command::Safety(SafetyCommand::Prepare {
req,
specs,
batch,
}));
self.overlay = modal(Modal::Waiting {
req,
what: "Reading the priority array and the old values...",
});
}
pub(super) fn safety_tick(&mut self, out: &mut Vec<Command>) {
let stale = matches!(&self.overlay, Overlay::Safety(m)
if matches!(**m, Modal::Confirm { opened, .. }
if self.now.saturating_duration_since(opened) >= PREVIEW_LIFETIME));
if stale {
self.modal_key(ModalKey::Esc, out);
self.flash(
"The preview expired unconfirmed: nothing was sent.",
FLASH_FOR,
);
}
let Some(armed) = &self.safety.armed else {
return;
};
if self.now >= armed.until {
self.safety.armed = None;
out.push(Command::Safety(SafetyCommand::Disarm { expired: true }));
self.flash("The write session expired: read-only again.", FLASH_FOR);
self.dirty = true;
return;
}
let left = armed.until.saturating_duration_since(self.now).as_secs();
if left != self.safety.last_second {
self.safety.last_second = left;
self.dirty = true;
}
}
pub(super) fn modal_key(&mut self, key: ModalKey, out: &mut Vec<Command>) {
let Overlay::Safety(current) = std::mem::replace(&mut self.overlay, Overlay::None) else {
return;
};
let next = match *current {
Modal::Arm(mut form) => match key {
ModalKey::Esc => None,
_ if form.key(key) => match form.get("Minutes").parse::<u32>() {
Ok(minutes) if (1..=MAX_MINUTES).contains(&minutes) => {
self.safety.pending_reason = form.get("Reason").to_string();
out.push(Command::Safety(SafetyCommand::Arm {
reason: self.safety.pending_reason.clone(),
minutes,
}));
None
}
_ => {
form.error = Some(format!("Minutes: 1 to {MAX_MINUTES}."));
Some(Modal::Arm(form))
}
},
_ => Some(Modal::Arm(form)),
},
Modal::Write {
device,
object,
relinquish,
mut form,
} => match key {
ModalKey::Esc => None,
_ if form.key(key) => match write_spec(device, object, relinquish, &form) {
Ok(spec) => {
self.prepare(vec![spec], false, out);
return;
}
Err(error) => {
form.error = Some(error);
Some(Modal::Write {
device,
object,
relinquish,
form,
})
}
},
_ => Some(Modal::Write {
device,
object,
relinquish,
form,
}),
},
Modal::Confirm {
req,
preview,
typed,
error,
opened,
} => self.confirm_key(key, req, preview, (typed, error, opened), out),
Modal::Ledger { confirm, notice } => match key {
ModalKey::Char('R') if !self.safety.ledger.is_empty() => Some(Modal::Ledger {
notice,
confirm: true,
}),
ModalKey::Char('y') if confirm => self.revert(out),
ModalKey::Esc | ModalKey::Char('q') => None,
_ => Some(Modal::Ledger { notice, confirm }),
},
Modal::ExitGuard(error) => match key {
ModalKey::Char('r') if !matches!(self.link, Link::Up { .. }) => Some(
Modal::ExitGuard(Some("Not connected: reverting needs the network.".into())),
),
ModalKey::Char('r') => {
self.safety.quitting = true;
self.revert(out)
}
ModalKey::Char('k') => {
out.push(Command::Safety(SafetyCommand::Keep));
self.quit = true;
None
}
ModalKey::Esc | ModalKey::Char('c') => None,
_ => Some(Modal::ExitGuard(error)),
},
other @ (Modal::Waiting { .. } | Modal::Result(_)) => match key {
ModalKey::Esc | ModalKey::Enter => None,
_ => Some(other),
},
};
if let Some(next) = next {
self.overlay = modal(next);
}
}
fn revert(&mut self, out: &mut Vec<Command>) -> Option<Modal> {
if !self.may_transmit() {
return None;
}
let req = self.safety_req();
self.safety.pending = Some(req);
out.push(Command::Safety(SafetyCommand::Revert { req }));
Some(Modal::Waiting {
req,
what: "Reverting, newest first...",
})
}
fn confirm_key(
&mut self,
key: ModalKey,
req: u64,
preview: Box<Preview>,
(mut typed, error, opened): (String, Option<String>, Instant),
out: &mut Vec<Command>,
) -> Option<Modal> {
let fresh = self.now.saturating_duration_since(opened) < CONFIRM_GUARD;
let confirmed = match (&preview.gate, key) {
(_, ModalKey::Esc) => {
out.push(Command::Safety(SafetyCommand::Declined { preview }));
self.flash("Declined: nothing was sent.", FLASH_FOR);
return None;
}
(_, ModalKey::Enter | ModalKey::Char('y')) if fresh => false,
(Gate::Allowed(Tier::T1), ModalKey::Enter) => true,
(Gate::Allowed(Tier::T2), ModalKey::Char('y')) => true,
(Gate::Allowed(Tier::T3), ModalKey::Char(ch)) if ch.is_ascii_digit() => {
typed.push(ch);
false
}
(Gate::Allowed(Tier::T3), ModalKey::Backspace) => {
typed.pop();
false
}
(Gate::Allowed(Tier::T3), ModalKey::Enter) => {
if typed == preview.device.to_string() {
true
} else {
let error =
format!("Type the device instance, {}, to confirm.", preview.device);
return Some(Modal::Confirm {
req,
preview,
typed: String::new(),
error: Some(error),
opened,
});
}
}
_ => false,
};
if !confirmed {
return Some(Modal::Confirm {
req,
preview,
typed,
error,
opened,
});
}
if self.safety.armed.is_none() {
self.flash(
"The write session has expired; nothing was sent.",
FLASH_FOR,
);
out.push(Command::Safety(SafetyCommand::Declined { preview }));
return None;
}
self.safety.pending = Some(req);
out.push(Command::Safety(SafetyCommand::Execute { req, preview }));
Some(Modal::Waiting {
req,
what: "Writing...",
})
}
pub(super) fn safety_event(&mut self, event: SafetyEvent, out: &mut Vec<Command>) {
let waiting = |app: &App, id: u64| matches!(&app.overlay, Overlay::Safety(m) if matches!(**m, Modal::Waiting { req, .. } if req == id));
let answered = match &event {
SafetyEvent::Preview { req, .. }
| SafetyEvent::Done { req, .. }
| SafetyEvent::Reverted { req, .. }
| SafetyEvent::Refused { req: Some(req), .. } => Some(*req),
_ => None,
};
if answered.is_some() && answered == self.safety.pending {
self.safety.pending = None;
}
match event {
SafetyEvent::Ready {
policy,
workspace,
run,
ledger,
} => {
self.safety.policy = Some(policy);
self.safety.workspace = workspace;
self.safety.run = run;
self.safety.ledger = ledger;
let left = self.safety.leftovers();
if left > 0 && self.overlay == Overlay::None {
let notice = format!(
"{left} change(s) left by an earlier session are still recorded. Revert them (R) or check the devices."
);
self.overlay = modal(Modal::Ledger {
notice: Some(notice),
confirm: false,
});
}
}
SafetyEvent::Armed { minutes } => {
self.safety.armed = Some(Armed {
until: self.now + Duration::from_secs(u64::from(minutes) * 60),
reason: std::mem::take(&mut self.safety.pending_reason),
});
self.flash(format!("Write session armed for {minutes} min."), FLASH_FOR);
}
SafetyEvent::Disarmed => self.safety.armed = None,
SafetyEvent::Refused { req, reason } => {
if req.is_none_or(|req| waiting(self, req)) {
if self.safety.quitting {
self.safety.quitting = false;
self.overlay = modal(Modal::ExitGuard(Some(reason.clone())));
} else if req.is_some() {
self.overlay = Overlay::None;
}
}
self.flash(format!("Refused: {reason}"), FLASH_FOR);
}
SafetyEvent::Preview { req, preview } => {
if waiting(self, req) {
self.overlay = modal(Modal::Confirm {
req,
preview,
typed: String::new(),
error: None,
opened: self.now,
});
}
}
SafetyEvent::Done { req, outcomes } => {
if waiting(self, req) {
self.overlay = modal(Modal::Result(outcomes));
} else if let Some((label, outcome)) = outcomes.first() {
self.flash(format!("{label}: {}", outcome.describe()), FLASH_FOR);
}
self.reread_object(out);
}
SafetyEvent::Ledger(ledger) => self.safety.ledger = ledger,
SafetyEvent::Reverted { req, failed } => {
if !waiting(self, req) {
return;
}
self.reread_object(out);
if std::mem::take(&mut self.safety.quitting) {
if failed == 0 {
self.quit = true;
return;
}
let error = format!("{failed} change(s) could not be reverted.");
self.overlay = modal(Modal::ExitGuard(Some(error)));
} else {
let notice = format!("Reverted; {failed} change(s) left.");
self.overlay = modal(Modal::Ledger {
notice: Some(notice),
confirm: false,
});
}
}
}
}
fn reread_object(&mut self, out: &mut Vec<Command>) {
let req = self.next_req();
if let Some(browser) = self.browser.as_deref_mut() {
if let ViewState::Ready(view) = &browser.view {
let object = view.object;
browser.reading(req, object);
out.push(Command::Browse {
req,
device: browser.device,
read: BrowseRead::Object(object),
});
}
}
}
}
fn write_spec(
device: u32,
object: bacnet_types::primitives::ObjectIdentifier,
relinquish: bool,
form: &Form,
) -> Result<WriteSpec, String> {
let (property, index) = if relinquish {
(PropertyIdentifier::PRESENT_VALUE, None)
} else {
parse_property(form.get("Property"))?
};
let value = if relinquish {
None
} else {
let text = form.get("Value");
parse_value(text)?;
Some(text.to_string())
};
let priority = match form.get("Priority") {
"" if relinquish => return Err("Priority: the slot to relinquish, 1-16.".into()),
"" => None,
text => match text.parse::<u8>() {
Ok(p) if (1..=16).contains(&p) => Some(p),
_ => {
return Err(
"Priority: 1-16, or blank for a property that isn't commandable.".into(),
)
}
},
};
Ok(WriteSpec {
device,
object,
property,
index,
value,
priority,
})
}