use std::time::Duration;
use crate::resolve::{parse_target, Target};
use crate::tui::message::{AddressStyle, WhoIsScope, WhoIsSpec, MAX_INSTANCE};
use bacnet_services::who_is::DeviceInstanceRange;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ScopeChoice {
Local,
Global,
Directed,
Network,
}
impl ScopeChoice {
const ALL: [Self; 4] = [Self::Local, Self::Global, Self::Directed, Self::Network];
fn step(self, forward: bool) -> Self {
let i = Self::ALL.iter().position(|&s| s == self).unwrap_or(0);
let n = Self::ALL.len();
Self::ALL[if forward {
(i + 1) % n
} else {
(i + n - 1) % n
}]
}
pub(crate) fn label(self) -> &'static str {
match self {
Self::Local => "Local broadcast",
Self::Global => "Global broadcast",
Self::Directed => "Directed (one address)",
Self::Network => "Remote network",
}
}
pub(crate) fn needs_target(self) -> bool {
matches!(self, Self::Directed | Self::Network)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Field {
Scope,
Target,
Range,
Listen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FormKey {
Next,
Prev,
Left,
Right,
Char(char),
Backspace,
Submit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FormOutcome {
Editing,
Send(WhoIsSpec),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WhoIsForm {
pub(crate) scope: ScopeChoice,
pub(crate) target: String,
pub(crate) range: String,
pub(crate) listen: String,
pub(crate) focus: Field,
pub(crate) error: Option<String>,
pub(crate) warning: Option<String>,
}
impl Default for WhoIsForm {
fn default() -> Self {
Self {
scope: ScopeChoice::Local,
target: String::new(),
range: String::new(),
listen: "3".into(),
focus: Field::Scope,
error: None,
warning: None,
}
}
}
impl WhoIsForm {
pub(crate) fn from_spec(spec: &WhoIsSpec) -> Self {
let (scope, target) = match &spec.scope {
WhoIsScope::Local => (ScopeChoice::Local, String::new()),
WhoIsScope::Global => (ScopeChoice::Global, String::new()),
WhoIsScope::Directed { label, .. } => (ScopeChoice::Directed, label.clone()),
WhoIsScope::Network(dnet) => (ScopeChoice::Network, dnet.to_string()),
};
let range = match spec.range {
None => String::new(),
Some(range) if range.low() == range.high() => range.low().to_string(),
Some(range) => format!("{}-{}", range.low(), range.high()),
};
Self {
scope,
target,
range,
listen: spec.listen.as_secs().to_string(),
..Self::default()
}
}
fn fields(&self) -> &'static [Field] {
if self.scope.needs_target() {
&[Field::Scope, Field::Target, Field::Range, Field::Listen]
} else {
&[Field::Scope, Field::Range, Field::Listen]
}
}
fn step_focus(&mut self, forward: bool) {
let fields = self.fields();
let i = fields.iter().position(|&f| f == self.focus).unwrap_or(0);
let n = fields.len();
self.focus = fields[if forward {
(i + 1) % n
} else {
(i + n - 1) % n
}];
}
fn text_mut(&mut self) -> Option<&mut String> {
match self.focus {
Field::Scope => None,
Field::Target => Some(&mut self.target),
Field::Range => Some(&mut self.range),
Field::Listen => Some(&mut self.listen),
}
}
pub(crate) fn key(&mut self, key: FormKey, style: AddressStyle, known: usize) -> FormOutcome {
if key != FormKey::Submit {
self.warning = None;
}
match key {
FormKey::Next => self.step_focus(true),
FormKey::Prev => self.step_focus(false),
FormKey::Left | FormKey::Right if self.focus == Field::Scope => {
self.scope = self.scope.step(key == FormKey::Right);
}
FormKey::Left | FormKey::Right => {}
FormKey::Char(' ') if self.focus == Field::Scope => {
self.scope = self.scope.step(true);
}
FormKey::Char(ch) => {
if let Some(text) = self.text_mut() {
if text.chars().count() < 48 && !ch.is_control() {
text.push(ch);
}
}
}
FormKey::Backspace => {
if let Some(text) = self.text_mut() {
text.pop();
}
}
FormKey::Submit => return self.submit(style, known),
}
self.error = None;
FormOutcome::Editing
}
fn submit(&mut self, style: AddressStyle, known: usize) -> FormOutcome {
let spec = match self.validate(style) {
Ok(spec) => spec,
Err(error) => {
self.error = Some(error);
self.warning = None;
return FormOutcome::Editing;
}
};
self.error = None;
match (self.warning.take(), warning_for(&spec, known)) {
(Some(_), _) | (None, None) => FormOutcome::Send(spec),
(None, Some(warning)) => {
self.warning = Some(warning);
FormOutcome::Editing
}
}
}
pub(crate) fn validate(&self, style: AddressStyle) -> Result<WhoIsSpec, String> {
let target = self.target.trim();
let scope = match self.scope {
ScopeChoice::Local => WhoIsScope::Local,
ScopeChoice::Global => WhoIsScope::Global,
ScopeChoice::Directed => WhoIsScope::Directed {
mac: parse_directed(target, style)?,
label: target.to_string(),
},
ScopeChoice::Network => WhoIsScope::Network(parse_network(target)?),
};
Ok(WhoIsSpec {
scope,
range: parse_range(self.range.trim())?,
listen: parse_listen(self.listen.trim())?,
})
}
}
fn parse_directed(target: &str, style: AddressStyle) -> Result<Vec<u8>, String> {
if target.is_empty() {
return Err("enter the address to send the Who-Is to".into());
}
if style == AddressStyle::Hex {
if let Ok(vmac) = crate::transport::parse_sc_vmac_arg(target) {
return Ok(vmac.to_vec());
}
}
match parse_target(target)? {
Target::Mac(mac) => Ok(mac),
Target::Instance(_) | Target::Routed(..) => Err(format!(
"'{target}' is not an address; a directed Who-Is needs IP[:port] or [IPv6]:port"
)),
}
}
fn parse_network(target: &str) -> Result<u16, String> {
match target.parse::<u16>() {
Ok(dnet @ 1..=65_534) => Ok(dnet),
Ok(65_535) => Err("network 65535 is the global broadcast; choose Global scope".into()),
_ => Err(format!("'{target}' is not a network number (1-65534)")),
}
}
fn parse_range(text: &str) -> Result<Option<DeviceInstanceRange>, String> {
if text.is_empty() {
return Ok(None);
}
if text.contains('-') {
return crate::core::range::parse_discover_range(Some(text)).map_err(|e| e.to_string());
}
let n = text
.parse::<u32>()
.map_err(|_| format!("invalid instance range '{text}': use N or LOW-HIGH"))?;
DeviceInstanceRange::single(n)
.map(Some)
.map_err(|_| format!("instances run from 0 to {MAX_INSTANCE}"))
}
fn parse_listen(text: &str) -> Result<Duration, String> {
match text.parse::<u64>() {
Ok(secs @ 1..=60) => Ok(Duration::from_secs(secs)),
_ => Err("listen for 1 to 60 seconds".into()),
}
}
pub(crate) fn warning_for(spec: &WhoIsSpec, known: usize) -> Option<String> {
if matches!(spec.scope, WhoIsScope::Directed { .. }) {
return None;
}
let volume = match known {
0 => String::new(),
1 => " (1 device known)".to_string(),
n => format!(" ({n} devices known)"),
};
let text = match (spec.is_global(), spec.is_full_range()) {
(true, true) => {
format!("Global Who-Is, no range: every device on every network answers{volume}.")
}
(true, false) => {
format!("Global Who-Is: matching devices on every network answer{volume}.")
}
(false, true) => format!("No instance range: every device in scope answers{volume}."),
(false, false) => return None,
};
Some(text)
}
#[cfg(test)]
mod tests {
use super::*;
fn type_text(form: &mut WhoIsForm, text: &str) {
for ch in text.chars() {
form.key(FormKey::Char(ch), AddressStyle::Bip, 0);
}
}
#[test]
fn bounded_local_who_is_sends_on_first_enter() {
let mut form = WhoIsForm::default();
form.key(FormKey::Next, AddressStyle::Bip, 0);
assert_eq!(form.focus, Field::Range, "local scope skips the target");
type_text(&mut form, "101-103");
let FormOutcome::Send(spec) = form.key(FormKey::Submit, AddressStyle::Bip, 5) else {
panic!("expected Send");
};
assert_eq!(spec.scope, WhoIsScope::Local);
assert_eq!(
spec.range,
Some(DeviceInstanceRange::new(101, 103).unwrap())
);
assert_eq!(spec.listen, Duration::from_secs(3));
}
#[test]
fn unbounded_or_global_who_is_needs_a_second_enter() {
let mut form = WhoIsForm::default();
assert_eq!(
form.key(FormKey::Submit, AddressStyle::Bip, 7),
FormOutcome::Editing
);
let warning = form.warning.clone().expect("warning shown");
assert!(warning.contains("every device") && warning.contains("7 devices known"));
assert!(matches!(
form.key(FormKey::Submit, AddressStyle::Bip, 7),
FormOutcome::Send(_)
));
let mut global = WhoIsForm::default();
global.key(FormKey::Right, AddressStyle::Bip, 0);
assert_eq!(global.scope, ScopeChoice::Global);
global.key(FormKey::Next, AddressStyle::Bip, 0);
type_text(&mut global, "5-6");
assert_eq!(
global.key(FormKey::Submit, AddressStyle::Bip, 0),
FormOutcome::Editing
);
assert!(global
.warning
.as_deref()
.unwrap()
.starts_with("Global Who-Is"));
global.key(FormKey::Backspace, AddressStyle::Bip, 0);
assert_eq!(global.warning, None);
}
#[test]
fn a_directed_who_is_without_a_range_sends_on_the_first_enter() {
let mut form = WhoIsForm {
scope: ScopeChoice::Directed,
target: "10.0.0.7:47809".into(),
..WhoIsForm::default()
};
let FormOutcome::Send(spec) = form.key(FormKey::Submit, AddressStyle::Bip, 9) else {
panic!("a directed Who-Is reaches one address and must not warn");
};
assert_eq!(spec.range, None);
assert_eq!(warning_for(&spec, 9), None);
}
#[test]
fn directed_and_network_targets_are_validated() {
let mut form = WhoIsForm {
scope: ScopeChoice::Directed,
target: "10.0.0.7".into(),
range: "42".into(),
..WhoIsForm::default()
};
let FormOutcome::Send(spec) = form.key(FormKey::Submit, AddressStyle::Bip, 0) else {
panic!("expected Send");
};
assert_eq!(
spec.scope,
WhoIsScope::Directed {
mac: vec![10, 0, 0, 7, 0xBA, 0xC0],
label: "10.0.0.7".into()
}
);
assert_eq!(spec.range, Some(DeviceInstanceRange::single(42).unwrap()));
form.target = "1234".into();
form.key(FormKey::Submit, AddressStyle::Bip, 0);
assert!(form.error.as_deref().unwrap().contains("not an address"));
let mut sc = form.clone();
sc.target = "020000000009".into();
assert!(matches!(
sc.key(FormKey::Submit, AddressStyle::Hex, 0),
FormOutcome::Send(WhoIsSpec {
scope: WhoIsScope::Directed { .. },
..
})
));
form.scope = ScopeChoice::Network;
for (target, expected) in [("0", "not a network"), ("65535", "global broadcast")] {
form.target = target.into();
form.key(FormKey::Submit, AddressStyle::Bip, 0);
assert!(
form.error.as_deref().unwrap().contains(expected),
"{target}"
);
}
}
#[test]
fn range_and_listen_limits() {
assert_eq!(
parse_range("0-4194303"),
Ok(Some(DeviceInstanceRange::new(0, MAX_INSTANCE).unwrap()))
);
assert!(parse_range("0-4194304")
.unwrap_err()
.contains("above the highest instance"));
assert_eq!(
parse_range("4194303"),
Ok(Some(DeviceInstanceRange::single(MAX_INSTANCE).unwrap()))
);
assert!(parse_range("4194304")
.unwrap_err()
.contains("instances run from 0 to 4194303"));
assert!(parse_range("9-1")
.unwrap_err()
.contains("low (9) > high (1)"));
assert!(parse_range("9-")
.unwrap_err()
.contains("a low limit but no high limit"));
assert!(parse_listen("0").is_err() && parse_listen("61").is_err());
let spec = WhoIsForm {
range: "0-4194303".into(),
..WhoIsForm::default()
}
.validate(AddressStyle::Bip)
.unwrap();
assert!(
warning_for(&spec, 0).is_some(),
"the explicit full range warns too"
);
}
#[test]
fn from_spec_round_trips_the_last_request() {
let spec = WhoIsSpec {
scope: WhoIsScope::Network(7),
range: Some(DeviceInstanceRange::new(10, 20).unwrap()),
listen: Duration::from_secs(5),
};
let form = WhoIsForm::from_spec(&spec);
assert_eq!(form.validate(AddressStyle::Bip), Ok(spec));
}
}