mod words;
use std::time::Duration;
use bacnet_types::enums::PropertyIdentifier;
use bacnet_types::primitives::ObjectIdentifier;
use self::words::Word;
use super::devices::DeviceTable;
use super::monitor::Setting;
use super::{App, FilterKey, Overlay, FLASH_FOR};
use crate::core::target::{parse_target, Target};
use crate::parse::{parse_object_specifier, parse_property, parse_value_with_priority};
use crate::tui::message::Command;
use crate::tui::safety::WriteSpec;
use crate::tui::watched::{Point, WatchCommand};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Syntax {
pub(crate) name: &'static str,
pub(crate) alias: Option<&'static str>,
pub(crate) usage: &'static [&'static str],
pub(crate) what: &'static str,
}
pub(crate) const COMMANDS: &[Syntax] = &[
Syntax {
name: "watch",
alias: Some("w"),
usage: &["watch DEVICE OBJECT [PROPERTY]"],
what: "Watch a property (present-value by default); w for short",
},
Syntax {
name: "csv",
alias: None,
usage: &["csv on|off"],
what: "Log the watched values to the workspace, or stop",
},
Syntax {
name: "writem",
alias: None,
usage: &["writem DEVICE OBJECT PROPERTY VALUE[@P] ..."],
what: "One WritePropertyMultiple to a device, previewed first",
},
Syntax {
name: "bbmd",
alias: None,
usage: &["bbmd [ADDRESS]"],
what: "Another BBMD's tables; alone, back to our own BBMD's",
},
Syntax {
name: "find",
alias: None,
usage: &["find NAME"],
what: "Who-Has by object name; alone, close the list",
},
Syntax {
name: "mstp",
alias: None,
usage: &["mstp rotation|hold MS", "mstp listen SECS"],
what: "The MS/TP monitor's thresholds; how long a safe join listens",
},
];
fn command(word: &str) -> Option<&'static Syntax> {
COMMANDS
.iter()
.find(|syntax| syntax.name == word || syntax.alias == Some(word))
}
pub(crate) fn usage() -> String {
let forms: Vec<&str> = COMMANDS
.iter()
.flat_map(|s| s.usage.iter().copied())
.collect();
format!("commands: {}", forms.join(" | "))
}
pub(crate) fn matching(text: &str) -> Vec<&'static Syntax> {
let first = text.split_whitespace().next().unwrap_or_default();
let typed_on = text.trim_start().len() > first.len();
if let Some(syntax) = command(first).filter(|_| typed_on || first.len() > 1) {
return vec![syntax];
}
if typed_on {
return Vec::new();
}
COMMANDS
.iter()
.filter(|syntax| syntax.name.starts_with(first))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Line {
Watch(Point),
Csv(bool),
Writem(Vec<WriteSpec>),
Bbmd(Option<std::net::SocketAddrV4>),
Find(String),
Mstp(Setting),
}
fn mstp_setting(which: &str, value: &str) -> Result<Setting, String> {
let number = value
.parse::<u64>()
.ok()
.filter(|n| (1..=3_600_000).contains(n))
.ok_or_else(|| format!("'{value}': a whole number from 1"))?;
match which {
"rotation" => Ok(Setting::Rotation(Duration::from_millis(number))),
"hold" => Ok(Setting::Hold(Duration::from_millis(number))),
"listen" if number <= 3_600 => Ok(Setting::Listen(Duration::from_secs(number))),
"listen" => Err("mstp listen takes 1 to 3600 seconds".into()),
_ => Err(format!(
"'{which}': mstp rotation|hold MS, or mstp listen SECS"
)),
}
}
fn device(word: &str, command: &str, devices: &DeviceTable) -> Result<(u32, Option<u16>), String> {
match parse_target(word)? {
Target::Instance(instance) => Ok((instance, None)),
Target::Routed { dnet, instance } => Ok((instance, Some(dnet))),
Target::RoutedMac { dnet, mac } => Ok((at_station(devices, word, dnet, &mac)?, Some(dnet))),
_ => Err(format!(
"'{word}': {command} takes a device from the device table \
(N, dnet:N or dnet:0xMAC)"
)),
}
}
fn at_station(devices: &DeviceTable, word: &str, dnet: u16, mac: &[u8]) -> Result<u32, String> {
let mut found: Vec<u32> = devices
.rows()
.filter(|row| row.network == Some(dnet))
.filter(|row| {
matches!(parse_target(&row.address),
Ok(Target::RoutedMac { dnet: network, mac: station })
if network == dnet && station == mac)
})
.map(|row| row.instance)
.collect();
found.sort_unstable();
match found.as_slice() {
[instance] => Ok(*instance),
[] => Err(format!(
"'{word}': no device in the device table is at that station; run a Who-Is first"
)),
several => {
let list: Vec<String> = several.iter().map(u32::to_string).collect();
Err(format!(
"'{word}': devices {} share that station; name one by its instance",
list.join(", ")
))
}
}
}
fn object(word: &str) -> Result<ObjectIdentifier, String> {
let (object_type, instance) = parse_object_specifier(word)?;
ObjectIdentifier::new(object_type, instance).map_err(|e| format!("'{word}': {e}"))
}
fn value(word: &Word) -> Result<(String, Option<u8>), String> {
let Some(literal_to) = word.literal_to else {
let (_, priority) = parse_value_with_priority(&word.text)?;
let text = match priority {
Some(_) => word.text.rsplit_once('@').map_or(&*word.text, |(v, _)| v),
None => &word.text,
};
return Ok((text.to_string(), priority));
};
let (text, priority) = match word.text[literal_to..].find('@') {
Some(at) => {
let (text, priority) = word.text.split_at(literal_to + at);
let priority = &priority[1..];
match priority.parse::<u8>() {
Ok(p) if (1..=16).contains(&p) => (text, Some(p)),
_ => return Err(format!("invalid priority '{priority}': 1-16")),
}
}
None => (&*word.text, None),
};
Ok((format!("\"{text}\""), priority))
}
fn find_name(rest: &str) -> String {
let quoted = matches!(rest.chars().next(), Some(q @ ('"' | '\''))
if rest.len() >= 2 && rest.ends_with(q));
if quoted {
if let Ok([word]) = words::split(rest).as_deref() {
return word.text.clone();
}
}
rest.to_string()
}
fn writes(device: u32, words: &[Word]) -> Result<Vec<WriteSpec>, String> {
if words.is_empty() || !words.len().is_multiple_of(3) {
return Err("writem DEVICE then OBJECT PROPERTY VALUE[@P] groups".into());
}
words
.chunks(3)
.map(|group| {
let (property, index) = parse_property(&group[1].text)?;
let (value, priority) = value(&group[2])?;
Ok(WriteSpec {
device,
object: object(&group[0].text)?,
property,
index,
value: Some(value),
priority,
})
})
.collect()
}
pub(crate) fn parse(text: &str, devices: &DeviceTable) -> Result<Line, String> {
let line = text.trim();
let (head, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
if command(head).is_some_and(|syntax| syntax.name == "find") {
return Ok(Line::Find(find_name(rest.trim())));
}
let all = words::split(text)?;
let Some((first, args)) = all.split_first() else {
return Err(usage());
};
let Some(syntax) = command(&first.text) else {
return Err(format!("unknown command '{}'; {}", text.trim(), usage()));
};
let wrong = || format!("usage: {}", syntax.usage.join(" | "));
let plain: Vec<&str> = args.iter().map(|w| w.text.as_str()).collect();
match (syntax.name, plain.as_slice()) {
("writem", [target, ..]) => Ok(Line::Writem(writes(
device(target, "writem", devices)?.0,
&args[1..],
)?)),
("watch", [target, object_word, rest @ ..]) => {
let (device, network) = device(target, "watch", devices)?;
let object = object(object_word)?;
let property = match rest {
[] => PropertyIdentifier::PRESENT_VALUE,
[property] => match parse_property(property)? {
(property, None) => property,
(_, Some(_)) => return Err("the watch list takes no array index".into()),
},
_ => return Err(format!("too many words; {}", wrong())),
};
Ok(Line::Watch(Point {
device,
network,
object,
property,
}))
}
("csv", ["on"]) => Ok(Line::Csv(true)),
("csv", ["off"]) => Ok(Line::Csv(false)),
("bbmd", []) => Ok(Line::Bbmd(None)),
("bbmd", [address]) => Ok(Line::Bbmd(Some(crate::foreign::parse_bbmd_arg(address)?))),
("mstp", [which, value]) => Ok(Line::Mstp(mstp_setting(which, value)?)),
("find", _) => Ok(Line::Find(find_name(rest.trim()))),
_ => Err(wrong()),
}
}
impl App {
pub(super) fn open_command_line(&mut self) {
self.overlay = Overlay::Command(String::new());
}
pub(super) fn command_line_key(&mut self, key: FilterKey, out: &mut Vec<Command>) {
let Overlay::Command(text) = &mut self.overlay else {
return;
};
match key {
FilterKey::Char(ch) => text.push(ch),
FilterKey::Backspace => {
text.pop();
}
FilterKey::Clear => self.overlay = Overlay::None,
FilterKey::Commit => {
let line = std::mem::take(text);
self.overlay = Overlay::None;
match parse(&line, &self.devices) {
Ok(Line::Watch(point)) => self.watch_point(point, out),
Ok(Line::Csv(on)) => out.push(Command::Watch(WatchCommand::Csv(on))),
Ok(Line::Writem(specs)) => self.prepare(specs, true, out),
Ok(Line::Bbmd(bbmd)) => self.show_bbmd(bbmd, out),
Ok(Line::Find(name)) => self.find(name, out),
Ok(Line::Mstp(setting)) => self.mstp_setting(setting),
Err(error) => self.flash(error, FLASH_FOR),
}
}
}
}
}
#[cfg(test)]
mod tests {
use bacnet_types::enums::{ObjectType, Segmentation};
use super::*;
use crate::core::target::routed_station;
use crate::tui::message::DeviceRow;
fn parse(text: &str) -> Result<Line, String> {
super::parse(text, &DeviceTable::default())
}
fn device_row(instance: u32, station: Option<(u16, &[u8])>) -> DeviceRow {
DeviceRow {
instance,
address: match station {
Some((network, mac)) => routed_station(network, mac).unwrap(),
None => format!("10.0.0.{}:47808", instance % 250),
},
via: station.map(|_| "10.0.1.1:47808".into()),
network: station.map(|(network, _)| network),
vendor_id: 260,
max_apdu: 480,
segmentation: Segmentation::NONE,
last_seen: std::time::Instant::now(),
}
}
#[test]
fn a_routed_station_names_the_device_the_table_lists_there() {
let mut devices = DeviceTable::default();
devices.upsert(device_row(2001, Some((2, &[5]))));
devices.upsert(device_row(2002, Some((2, &[0x7F, 0, 0, 1, 0xBA, 0xC0]))));
devices.upsert(device_row(100, None));
let ai1 = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
let watch = |device, network| {
Ok(Line::Watch(Point {
device,
network: Some(network),
object: ai1,
property: PropertyIdentifier::PRESENT_VALUE,
}))
};
assert_eq!(super::parse("watch 2:0x05 ai:1", &devices), watch(2001, 2));
assert_eq!(
super::parse("w 2:0x7f000001bac0 ai:1", &devices),
watch(2002, 2)
);
let Ok(Line::Writem(specs)) = super::parse("writem 2:0x05 av:1 pv 4", &devices) else {
panic!("expected writem");
};
assert_eq!(specs[0].device, 2001);
for missing in [
"watch 2:0x06 ai:1",
"watch 3:0x05 ai:1",
"watch 2:0x0005 ai:1",
"writem 2:0x06 av:1 pv 4",
] {
let error = super::parse(missing, &devices).unwrap_err();
assert!(error.contains("no device in the device table"), "{error}");
}
devices.upsert(device_row(2003, Some((2, &[5]))));
let error = super::parse("watch 2:0x05 ai:1", &devices).unwrap_err();
assert!(error.contains("devices 2001, 2003 share"), "{error}");
}
#[test]
fn find_takes_the_rest_of_the_line_as_typed() {
let find = |line: &str| match parse(line) {
Ok(Line::Find(name)) => name,
other => panic!("{line}: {other:?}"),
};
assert_eq!(find("find Joe's office"), "Joe's office");
assert_eq!(find("find Supply Air Temp "), "Supply Air Temp");
assert_eq!(find(r#"find "Zone Temp""#), "Zone Temp");
assert_eq!(find(r#"find 'Zone "A"'"#), r#"Zone "A""#);
assert_eq!(find(r#"find "a\"b""#), r#"a"b"#);
assert_eq!(find(r#"find """#), "");
assert_eq!(find("find"), "");
assert_eq!(find(r#"find "a" "b""#), r#""a" "b""#);
assert_eq!(find(r#"find "Zone 1"#), r#""Zone 1"#);
assert_eq!(find(r"find C:\temp"), r"C:\temp");
assert_eq!(find("\"find\" x y"), "x y");
}
#[test]
fn writem_takes_quoted_values() {
let values = |line: &str| -> Vec<(Option<String>, Option<u8>)> {
match parse(line) {
Ok(Line::Writem(specs)) => {
specs.into_iter().map(|s| (s.value, s.priority)).collect()
}
other => panic!("{line}: {other:?}"),
}
};
let string = |text: &str| Some(format!("\"{text}\""));
assert_eq!(
values(r#"writem 100 av:1 description "two words""#),
[(string("two words"), None)]
);
assert_eq!(
values(r#"writem 100 av:1 description 'two words' av:2 pv 5@9"#),
[(string("two words"), None), (Some("5".into()), Some(9))]
);
assert_eq!(
values(r#"writem 100 av:1 description "a@b"@8"#),
[(string("a@b"), Some(8))]
);
assert_eq!(
values(r#"writem 100 av:1 description """#),
[(string(""), None)]
);
assert_eq!(
values(r#"writem 100 av:1 description "say \"hi\"""#),
[(string(r#"say "hi""#), None)]
);
assert_eq!(
values(r#"writem 100 av:1 description zone' 'four"#),
[(string("zone four"), None)]
);
assert_eq!(
values(r#"writem 100 av:1 description \"x\""#),
[(string(r#""x""#), None)]
);
assert_eq!(
values(r"writem 100 av:1 description Zone\ 2@9 av:2 pv 4"),
[(string("Zone 2"), Some(9)), (Some("4".into()), None)]
);
assert_eq!(
values("writem 100 av:1 pv '72.5'"),
[(string("72.5"), None)]
);
assert!(parse(r"writem 100 av:1 description Zone\ 2@17").is_err());
assert!(parse(r"writem 100 av:1 description C:\temp")
.unwrap_err()
.contains("cannot parse value"));
let Some(text) = string("two words") else {
unreachable!()
};
assert_eq!(
crate::parse::parse_value(&text),
Ok(bacnet_types::primitives::PropertyValue::CharacterString(
"two words".into()
))
);
assert!(parse(r#"writem 100 av:1 description "x"@17"#)
.unwrap_err()
.contains("priority"));
assert!(parse(r#"writem 100 av:1 description "x"@"#).is_err());
assert!(parse(r#"writem 100 av:1 description "x"@0"#)
.unwrap_err()
.contains("priority"));
let open = parse(r#"writem 100 av:1 description "two words"#).unwrap_err();
assert!(open.contains("unclosed \" quote"), "{open}");
assert!(parse("writem 100 av:1 description two words").is_err());
}
#[test]
fn watch_lines_name_a_device_an_object_and_maybe_a_property() {
let ai1 = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
assert_eq!(
parse("watch 100 ai:1"),
Ok(Line::Watch(Point {
device: 100,
network: None,
object: ai1,
property: PropertyIdentifier::PRESENT_VALUE
}))
);
assert_eq!(
parse("w 2:100 analog-input:1 sf"),
Ok(Line::Watch(Point {
device: 100,
network: Some(2),
object: ai1,
property: PropertyIdentifier::STATUS_FLAGS
}))
);
assert_eq!(parse("csv on"), Ok(Line::Csv(true)));
assert_eq!(
parse("bbmd 10.0.0.1"),
Ok(Line::Bbmd(Some("10.0.0.1:47808".parse().unwrap())))
);
assert!(parse("bbmd 10.0.0").is_err());
assert_eq!(parse("bbmd"), Ok(Line::Bbmd(None)));
assert!(parse("watch 10.0.0.5 ai:1")
.unwrap_err()
.contains("a device from the device table"));
assert!(parse("watch 100 ai:1 object-list[2]").is_err());
assert!(parse("watch 100 ai:x").is_err());
assert_eq!(parse("find"), Ok(Line::Find(String::new())));
let ms = Duration::from_millis;
assert_eq!(
parse("mstp rotation 1500"),
Ok(Line::Mstp(Setting::Rotation(ms(1500))))
);
assert_eq!(
parse("mstp hold 250"),
Ok(Line::Mstp(Setting::Hold(ms(250))))
);
assert_eq!(
parse("mstp listen 30"),
Ok(Line::Mstp(Setting::Listen(Duration::from_secs(30))))
);
assert!(parse("mstp hold 0").is_err());
assert!(parse("mstp listen 4000").is_err());
assert!(parse("mstp poll 5").is_err());
assert!(parse("finder x").unwrap_err().contains("unknown command"));
assert!(parse("read 100 ai:1")
.unwrap_err()
.contains("unknown command"));
}
#[test]
fn writem_lines_take_object_property_value_groups() {
let Ok(Line::Writem(specs)) = parse("writem 100 ao:1 pv 42@8 av:1 description \"x\"")
else {
panic!("expected writem");
};
assert_eq!(specs.len(), 2);
assert_eq!(specs[0].value.as_deref(), Some("42"));
assert_eq!(specs[0].priority, Some(8));
assert_eq!(specs[1].property, PropertyIdentifier::DESCRIPTION);
assert_eq!(specs[1].priority, None);
assert!(parse("writem 100 ao:1 pv").is_err());
assert!(parse("writem 100 ao:1 pv 42@17").is_err());
assert!(parse("writem 100").is_err());
}
fn row_of(line: &Line) -> &'static str {
match line {
Line::Watch(_) => "watch",
Line::Csv(_) => "csv",
Line::Writem(_) => "writem",
Line::Bbmd(_) => "bbmd",
Line::Find(_) => "find",
Line::Mstp(_) => "mstp",
}
}
#[test]
fn every_command_has_its_hint_from_the_table_the_parser_reads() {
let samples = [
"watch 100 ai:1",
"w 100 ai:1 pv",
"csv on",
"writem 100 ao:1 pv 42@8",
"bbmd",
"bbmd 10.0.0.1",
"find Supply Temp",
"mstp hold 250",
"mstp listen 30",
];
let mut rows = std::collections::BTreeSet::new();
for sample in samples {
let line = parse(sample).unwrap_or_else(|e| panic!("{sample}: {e}"));
let row = row_of(&line);
assert!(command(row).is_some(), "{row} has a row");
let hint = matching(sample);
assert_eq!(hint.len(), 1, "{sample}");
assert_eq!(hint[0].name, row, "{sample}");
rows.insert(row);
}
let names: std::collections::BTreeSet<&str> = COMMANDS.iter().map(|s| s.name).collect();
assert_eq!(rows, names, "every row is parsed by a sample");
for syntax in COMMANDS {
for form in syntax.usage {
assert_eq!(form.split_whitespace().next(), Some(syntax.name), "{form}");
}
assert!(usage().contains(syntax.usage[0]));
}
assert_eq!(parse("csv maybe"), Err("usage: csv on|off".into()));
}
#[test]
fn hints_follow_what_is_typed() {
let names = |text: &str| -> Vec<&str> { matching(text).iter().map(|s| s.name).collect() };
assert_eq!(names("").len(), COMMANDS.len());
assert_eq!(names("w"), ["watch", "writem"]);
assert_eq!(names("w "), ["watch"]);
assert_eq!(names("wr"), ["writem"]);
assert_eq!(names("mstp hold"), ["mstp"]);
assert!(names("zz").is_empty());
assert!(names("zz 1").is_empty());
}
}