use std::sync::OnceLock;
use std::time::Duration;
use crate::runtime::stdlib::epics_parse_double_units;
pub const SCAN_1ST_PERIODIC: u16 = 3;
pub fn stock_choices() -> &'static [&'static str] {
super::dbd_generated::MENU_SCAN
}
pub struct MenuScan {
choices: &'static [&'static str],
periods: Vec<Option<Duration>>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum InstallError {
AlreadyInUse,
FixedChoicesRenamed,
}
impl std::fmt::Display for InstallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyInUse => write!(
f,
"menuScan is already in use — load it before any record or SCAN value"
),
Self::FixedChoicesRenamed => write!(
f,
"menuScan must begin with the three choices dbScan.c names: \
Passive, Event, I/O Intr"
),
}
}
}
impl std::error::Error for InstallError {}
static MENU_SCAN: OnceLock<MenuScan> = OnceLock::new();
static PENDING: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
pub fn install(choices: &[String]) -> Result<(), InstallError> {
if choices.len() < SCAN_1ST_PERIODIC as usize
|| choices[..SCAN_1ST_PERIODIC as usize]
.iter()
.zip(stock_choices())
.any(|(have, want)| have != want)
{
return Err(InstallError::FixedChoicesRenamed);
}
if let Some(frozen) = MENU_SCAN.get() {
return if frozen.choices.len() == choices.len()
&& frozen.choices.iter().zip(choices).all(|(a, b)| a == b)
{
Ok(())
} else {
Err(InstallError::AlreadyInUse)
};
}
*PENDING.lock().expect("menuScan install") = Some(choices.to_vec());
Ok(())
}
pub fn menu_scan() -> &'static MenuScan {
if let Some(m) = MENU_SCAN.get() {
return m;
}
let pending = PENDING.lock().expect("menuScan freeze").take();
let choices: &'static [&'static str] = match pending {
Some(v) => Box::leak(
v.into_iter()
.map(|s| &*Box::leak(s.into_boxed_str()))
.collect::<Vec<_>>()
.into_boxed_slice(),
),
None => stock_choices(),
};
let menu = MENU_SCAN.get_or_init(|| MenuScan::from_choices(choices));
crate::runtime::background::scan_once::set_periodic_scan_band_count(menu.n_periodic());
menu
}
impl MenuScan {
fn from_choices(choices: &'static [&'static str]) -> Self {
let periods: Vec<Option<Duration>> = choices[SCAN_1ST_PERIODIC as usize..]
.iter()
.map(|choice| period_of(choice))
.collect();
for (choice, period) in choices[SCAN_1ST_PERIODIC as usize..].iter().zip(&periods) {
match period {
None => crate::runtime::log::errlog_printf(&format!(
"initPeriodic: Bad menuScan choice '{choice}'\n"
)),
Some(p) if !is_achievable(*p) => crate::runtime::log::errlog_printf(&format!(
"initPeriodic: Scan rate '{choice}' is not achievable.\n"
)),
Some(_) => {}
}
}
Self { choices, periods }
}
pub fn choices(&self) -> &'static [&'static str] {
self.choices
}
pub fn n_periodic(&self) -> usize {
self.periods.len()
}
pub fn period_at(&self, index: u16) -> Option<Duration> {
if index < SCAN_1ST_PERIODIC {
return None;
}
self.periods
.get((index - SCAN_1ST_PERIODIC) as usize)
.copied()
.flatten()
}
pub fn label_at(&self, index: u16) -> Option<&'static str> {
self.choices.get(index as usize).copied()
}
pub fn index_of(&self, label: &str) -> Option<u16> {
self.choices
.iter()
.position(|c| *c == label)
.map(|i| i as u16)
}
pub fn is_in_menu(&self, index: u16) -> bool {
(index as usize) < self.choices.len()
}
}
fn period_of(choice: &str) -> Option<Duration> {
let (number, unit) = epics_parse_double_units(choice).ok()?;
if number <= 0.0 {
return None;
}
let seconds = if unit.is_empty()
|| unit.eq_ignore_ascii_case("second")
|| unit.eq_ignore_ascii_case("seconds")
{
number
} else if unit.eq_ignore_ascii_case("minute") || unit.eq_ignore_ascii_case("minutes") {
number * 60.0
} else if unit.eq_ignore_ascii_case("hour") || unit.eq_ignore_ascii_case("hours") {
number * 60.0 * 60.0
} else if unit.eq_ignore_ascii_case("Hz") || unit.eq_ignore_ascii_case("Hertz") {
1.0 / number
} else {
return None;
};
if seconds <= 0.0 || !seconds.is_finite() {
return None;
}
Some(Duration::from_secs_f64(seconds))
}
fn is_achievable(period: Duration) -> bool {
let quantum = crate::runtime::time::thread_sleep_quantum();
if quantum <= 0.0 {
return true;
}
let period = period.as_secs_f64();
let ticks = period / quantum;
period >= 2.0 * quantum && ticks / ticks.floor() <= 1.1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_stock_menu_parses_to_bases_seven_rates() {
let m = MenuScan::from_choices(stock_choices());
assert_eq!(m.n_periodic(), 7);
let want = [
(3, Duration::from_secs(10)),
(4, Duration::from_secs(5)),
(5, Duration::from_secs(2)),
(6, Duration::from_secs(1)),
(7, Duration::from_millis(500)),
(8, Duration::from_millis(200)),
(9, Duration::from_millis(100)),
];
for (index, period) in want {
assert_eq!(m.period_at(index), Some(period), "menu index {index}");
}
assert_eq!(m.period_at(2), None, "I/O Intr is not periodic");
assert_eq!(m.period_at(10), None, "outside the menu");
}
#[test]
fn every_unit_c_accepts_is_accepted_here() {
for (choice, want) in [
("2", Duration::from_secs(2)),
("2 second", Duration::from_secs(2)),
("2 seconds", Duration::from_secs(2)),
("2 SECONDS", Duration::from_secs(2)),
("2 minute", Duration::from_secs(120)),
("2 minutes", Duration::from_secs(120)),
("1 hour", Duration::from_secs(3600)),
("2 hours", Duration::from_secs(7200)),
("60 Hz", Duration::from_secs_f64(1.0 / 60.0)),
("60 hertz", Duration::from_secs_f64(1.0 / 60.0)),
("0.5 Hertz", Duration::from_secs(2)),
] {
assert_eq!(period_of(choice), Some(want), "choice {choice:?}");
}
}
#[test]
fn a_choice_c_rejects_yields_no_period() {
for choice in [
"Passive", "0 second", "-1 second", "1 fortnight", "", ] {
assert_eq!(period_of(choice), None, "choice {choice:?}");
}
}
#[test]
fn a_bad_choice_holds_its_slot_instead_of_shifting_the_rest() {
let choices: &'static [&'static str] =
&["Passive", "Event", "I/O Intr", "1 fortnight", "1 second"];
let m = MenuScan::from_choices(choices);
assert_eq!(m.n_periodic(), 2);
assert_eq!(m.period_at(3), None);
assert_eq!(m.period_at(4), Some(Duration::from_secs(1)));
}
#[test]
fn a_site_menu_replaces_the_rates_wholesale() {
let choices: &'static [&'static str] =
&["Passive", "Event", "I/O Intr", "60 Hz", "5 minutes"];
let m = MenuScan::from_choices(choices);
assert_eq!(m.n_periodic(), 2);
assert_eq!(m.period_at(3), Some(Duration::from_secs_f64(1.0 / 60.0)));
assert_eq!(m.period_at(4), Some(Duration::from_secs(300)));
assert_eq!(m.label_at(4), Some("5 minutes"));
assert_eq!(m.index_of("60 Hz"), Some(3));
assert_eq!(m.index_of("1 second"), None, "not in this site's menu");
assert!(!m.is_in_menu(5));
}
#[test]
fn the_three_fixed_choices_may_not_be_renamed_or_dropped() {
let bad: Vec<String> = ["Passive", "Event", "IoIntr", "1 second"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(install(&bad), Err(InstallError::FixedChoicesRenamed));
let short: Vec<String> = ["Passive", "Event"].iter().map(|s| s.to_string()).collect();
assert_eq!(install(&short), Err(InstallError::FixedChoicesRenamed));
}
#[test]
fn a_frequency_that_underflows_to_a_zero_period_has_no_list() {
assert_eq!(period_of("1e400 Hz"), None);
}
}