use std::time::Duration;
use bacnet_transport::mstp::monitor::{MaxMaster, MonitorStats, StationStats};
use super::MonitorSample;
use crate::mstp::MAX_MASTER_MAC;
pub(crate) const SILENT_AFTER: Duration = Duration::from_secs(10);
pub(crate) const HIGH_HEADROOM: u8 = 8;
pub(crate) const GAP_LIMIT: u8 = 8;
pub(crate) const DEFAULT_ROTATION: Duration = Duration::from_secs(2);
pub(crate) const DEFAULT_HOLD: Duration = Duration::from_millis(500);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Role {
Master,
Slave,
Router,
Silent,
}
impl Role {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Master => "master",
Self::Slave => "slave",
Self::Router => "router",
Self::Silent => "silent",
}
}
pub(crate) fn letter(self) -> char {
match self {
Self::Master => 'M',
Self::Slave => 'S',
Self::Router => 'R',
Self::Silent => 'x',
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StationRow {
pub(crate) mac: u8,
pub(crate) role: Role,
pub(crate) device: Option<u32>,
pub(crate) frames: u64,
pub(crate) last_seen: Option<Duration>,
}
fn addressed(station: &StationStats) -> bool {
let received = &station.frames_received;
received.total() > received.poll_for_master
}
pub(crate) fn stations(sample: &MonitorSample) -> Vec<StationRow> {
let stats = &sample.stats;
let now = stats.last_activity.unwrap_or_default();
stats
.stations
.iter()
.filter(|(_, station)| station.is_present() || addressed(station))
.map(|(&mac, station)| {
let quiet = station
.last_seen
.is_none_or(|seen| now.saturating_sub(seen) > SILENT_AFTER);
let role = if quiet {
Role::Silent
} else if sample.routers.contains(&mac) {
Role::Router
} else if station.is_master() {
Role::Master
} else {
Role::Slave
};
StationRow {
mac,
role,
device: sample.devices.get(&mac).copied(),
frames: station.frames_sent.total(),
last_seen: station.last_seen,
}
})
.collect()
}
pub(crate) fn masters(stats: &MonitorStats) -> impl Iterator<Item = (u8, &StationStats)> {
stats
.stations
.iter()
.filter(|(&mac, station)| mac <= MAX_MASTER_MAC && station.is_master())
.map(|(&mac, station)| (mac, station))
}
pub(crate) fn chain(stats: &MonitorStats) -> Vec<u8> {
let Some((first, _)) = masters(stats).find(|(_, s)| s.next_station.is_some()) else {
return Vec::new();
};
let mut chain = vec![first];
let mut at = first;
while let Some(next) = stats.station(at).and_then(|s| s.next_station) {
chain.push(next);
if next == first || chain.len() > usize::from(MAX_MASTER_MAC) + 2 {
break;
}
at = next;
}
chain
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Rotation {
pub(crate) count: u64,
pub(crate) min: Option<Duration>,
pub(crate) mean: Option<Duration>,
pub(crate) max: Option<Duration>,
}
pub(crate) fn rotation(stats: &MonitorStats) -> Rotation {
let mut out = Rotation::default();
let mut total = Duration::ZERO;
for (_, station) in masters(stats) {
let r = &station.token_rotation;
out.count += r.count;
total = total.saturating_add(r.total);
out.min = r.min.into_iter().chain(out.min).min();
out.max = r.max.into_iter().chain(out.max).max();
}
out.mean = u32::try_from(out.count)
.ok()
.filter(|count| *count > 0)
.map(|count| total / count);
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Severity {
Warning,
Notice,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Finding {
pub(crate) severity: Severity,
pub(crate) text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Thresholds {
pub(crate) rotation: Duration,
pub(crate) hold: Duration,
}
impl Default for Thresholds {
fn default() -> Self {
Self {
rotation: DEFAULT_ROTATION,
hold: DEFAULT_HOLD,
}
}
}
pub(crate) fn highest_station(sample: &MonitorSample) -> Option<u8> {
let stats = &sample.stats;
let heard = stats
.stations
.iter()
.filter(|(_, s)| s.is_present() || addressed(s))
.map(|(mac, _)| *mac);
heard
.chain(sample.devices.keys().copied())
.filter(|mac| *mac <= MAX_MASTER_MAC)
.max()
}
fn described(value: MaxMaster) -> String {
match value {
MaxMaster::Exact(v) => format!("Max_Master is {v}"),
MaxMaster::AtLeast(v) => format!("Max_Master is at least {v}"),
}
}
pub(crate) fn findings(sample: &MonitorSample, thresholds: &Thresholds) -> Vec<Finding> {
let stats = &sample.stats;
let mut out = Vec::new();
let mut add = |severity, text| out.push(Finding { severity, text });
let highest = highest_station(sample).unwrap_or(0);
for (mac, station) in masters(stats) {
let Some(value) = station.max_master else {
continue;
};
if let MaxMaster::Exact(v) = value {
let above: Vec<String> = sample_above(sample, v);
let which = match above.as_slice() {
[] => None,
[one] => Some(format!("{one}: if {one} is a master")),
many => Some(format!("{}: if any of them is a master", many.join(", "))),
};
if let Some(which) = which {
add(
Severity::Warning,
format!(
"mstp:{mac}: Max_Master is {v} (its polls wrap there), below \
{which}, it never gets the token from mstp:{mac}. Suggest \
Max_Master {highest} on every master."
),
);
}
}
if value.value() > highest.saturating_add(HIGH_HEADROOM) {
add(
Severity::Notice,
format!(
"mstp:{mac}: {} but no station is above {highest}: it spends poll time \
on empty addresses. Suggest Max_Master {highest}, plus room for \
stations to come.",
described(value)
),
);
}
}
for (mac, station) in masters(stats) {
let Some(next) = station.next_station.filter(|next| *next > mac) else {
continue;
};
let empty = next - mac - 1;
if empty > GAP_LIMIT {
add(
Severity::Notice,
format!(
"mstp:{mac} polls the {empty} empty addresses before mstp:{next} one by \
one, for as long as they stay empty (Clause 9.5.6). Numbering the \
masters closer together saves those polls."
),
);
}
}
let worst = rotation(stats).max.filter(|max| *max > thresholds.rotation);
if let Some(max) = worst {
add(
Severity::Warning,
format!(
"Token rotation reached {}, over the {} threshold.",
millis(max),
millis(thresholds.rotation)
),
);
}
for (mac, station) in masters(stats) {
if let Some(max) = station.token_hold.max.filter(|max| *max > thresholds.hold) {
add(
Severity::Warning,
format!(
"mstp:{mac} held the token for {}, over the {} threshold.",
millis(max),
millis(thresholds.hold)
),
);
}
}
out.sort_by_key(|finding| finding.severity);
out
}
fn sample_above(sample: &MonitorSample, value: u8) -> Vec<String> {
let stats = &sample.stats;
stats
.stations
.iter()
.filter(|(&mac, s)| {
mac > value && mac <= MAX_MASTER_MAC && (s.is_present() || addressed(s))
})
.map(|(mac, _)| format!("mstp:{mac}"))
.collect()
}
pub(crate) fn millis(duration: Duration) -> String {
if duration >= Duration::from_secs(10) {
format!("{} s", duration.as_secs())
} else if duration >= Duration::from_secs(1) {
format!("{:.1} s", duration.as_secs_f64())
} else {
format!("{} ms", duration.as_millis())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Proposal {
pub(crate) candidates: Vec<u8>,
pub(crate) preferred: usize,
pub(crate) max_master: u8,
pub(crate) notes: Vec<String>,
}
impl Proposal {
pub(crate) fn max_master_for(&self, mac: u8) -> u8 {
self.max_master.max(mac)
}
}
fn lower_bound(master: u8, station: &StationStats) -> Option<u8> {
match station.max_master? {
MaxMaster::Exact(v) => Some(v),
MaxMaster::AtLeast(v) => Some(v.max(master).max(station.next_station.unwrap_or(0))),
}
}
fn polled_by(master: u8, station: &StationStats, mac: u8) -> bool {
let next = station.next_station.unwrap_or(master);
if next > master {
return mac > master && mac < next;
}
match station.max_master {
Some(bound) if mac > master => mac <= bound.value(),
Some(MaxMaster::Exact(_)) => mac < next,
_ => false,
}
}
pub(crate) fn propose(sample: &MonitorSample) -> Result<Proposal, String> {
let stats = &sample.stats;
let masters: Vec<(u8, &StationStats)> = masters(stats).collect();
if masters.is_empty() {
return Err(
"No master has passed the token or polled yet: check the baud rate and \
the wiring, or listen longer."
.into(),
);
}
let taken = |mac: u8| {
sample.devices.contains_key(&mac)
|| stats
.station(mac)
.is_some_and(|s| s.is_present() || addressed(s))
};
let bounds: Vec<u8> = masters
.iter()
.filter_map(|(mac, s)| lower_bound(*mac, s))
.collect();
let candidates: Vec<u8> = (0..=MAX_MASTER_MAC)
.filter(|mac| !taken(*mac))
.filter(|mac| bounds.iter().all(|bound| mac <= bound))
.filter(|mac| masters.iter().any(|(m, s)| polled_by(*m, s, *mac)))
.collect();
if candidates.is_empty() {
return Err(
"No free MAC is both polled by a master and within every Max_Master \
seen: listen longer, or raise Max_Master on the masters."
.into(),
);
}
let top = masters.iter().map(|(mac, _)| *mac).max().unwrap_or(0);
let bottom = masters.iter().map(|(mac, _)| *mac).min().unwrap_or(0);
let preferred = candidates
.iter()
.position(|mac| *mac > top)
.or_else(|| candidates.iter().position(|mac| *mac > bottom))
.unwrap_or(0);
let exact = masters
.iter()
.filter_map(|(_, s)| match s.max_master {
Some(MaxMaster::Exact(v)) => Some(v),
_ => None,
})
.max();
let max_master = exact
.into_iter()
.chain(
exact
.is_none()
.then(|| bounds.iter().copied().max())
.flatten(),
)
.chain(highest_station(sample))
.max()
.unwrap_or(top);
let mut notes = Vec::new();
for (mac, station) in &masters {
if station.max_master.is_none() {
notes.push(format!(
"mstp:{mac} polled nothing, so its Max_Master can't be seen: check it \
reaches the MAC you join as."
));
}
}
if exact.is_none() {
notes.push(
"No master's polls have wrapped yet, so Max_Master is a lower bound: listen \
longer for an exact value."
.into(),
);
}
Ok(Proposal {
candidates,
preferred,
max_master,
notes,
})
}