#![doc = include_str!("../README.md")]
use serde::{Deserialize, Serialize};
#[cfg(unix)]
pub mod daemon;
pub mod sidecar;
#[cfg(target_os = "linux")]
mod linux_output;
#[cfg(target_os = "linux")]
pub use linux_output::GpioOutput;
pub const GPIO_CMD_SOCK: &str = "/run/ados/gpio-cmd.sock";
pub const MAX_BEEP_CYCLES: u32 = 64;
pub const MAX_PHASE_MS: u32 = 5_000;
pub const MAX_TOTAL_MS: u64 = 30_000;
pub const MAX_CARRIER_HZ: u32 = 5_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Level {
Low,
High,
}
impl Level {
pub fn value(self) -> u8 {
match self {
Level::Low => 0,
Level::High => 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct BeepPattern {
pub freq_hz: u32,
pub duty_pct: u8,
pub on_ms: u32,
pub off_ms: u32,
pub cycles: u32,
}
impl BeepPattern {
pub fn clamp(self) -> Self {
Self {
freq_hz: self.freq_hz.min(MAX_CARRIER_HZ),
duty_pct: self.duty_pct.min(100),
on_ms: self.on_ms.min(MAX_PHASE_MS),
off_ms: self.off_ms.min(MAX_PHASE_MS),
cycles: self.cycles.min(MAX_BEEP_CYCLES),
}
}
fn raw_total_ms(self) -> u64 {
let per_cycle = self.on_ms as u64 + self.off_ms as u64;
per_cycle.saturating_mul(self.cycles as u64)
}
}
pub fn carrier_timing(pattern: BeepPattern) -> Option<(std::time::Duration, std::time::Duration)> {
let p = pattern.clamp();
if p.freq_hz == 0 || p.duty_pct == 0 || p.duty_pct >= 100 {
return None;
}
let period_us = 1_000_000u64 / p.freq_hz as u64;
let high_us = period_us * p.duty_pct as u64 / 100;
Some((
std::time::Duration::from_micros(high_us.max(1)),
std::time::Duration::from_micros((period_us - high_us).max(1)),
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BeepPhase {
pub level: Level,
pub hold_ms: u32,
}
pub fn beep_schedule(pattern: BeepPattern) -> Vec<BeepPhase> {
let p = pattern.clamp();
let mut phases = Vec::new();
let mut elapsed: u64 = 0;
let budget = p.raw_total_ms().min(MAX_TOTAL_MS);
'outer: for _ in 0..p.cycles {
for (level, ms) in [(Level::High, p.on_ms), (Level::Low, p.off_ms)] {
if ms == 0 {
continue;
}
let remaining = budget.saturating_sub(elapsed);
if remaining == 0 {
break 'outer;
}
let hold = (ms as u64).min(remaining) as u32;
phases.push(BeepPhase {
level,
hold_ms: hold,
});
elapsed += hold as u64;
}
}
phases.push(BeepPhase {
level: Level::Low,
hold_ms: 0,
});
phases
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Set { chip: u32, pin: u32, level: Level },
Beep {
chip: u32,
pin: u32,
pattern: BeepPattern,
},
Status,
}
#[derive(Debug, Deserialize)]
struct Request {
op: String,
#[serde(default)]
chip: Option<u32>,
#[serde(default)]
pin: Option<u32>,
#[serde(default)]
level: Option<Level>,
#[serde(default)]
freq_hz: Option<i64>,
#[serde(default)]
duty_pct: Option<i64>,
#[serde(default)]
on_ms: Option<i64>,
#[serde(default)]
off_ms: Option<i64>,
#[serde(default)]
cycles: Option<i64>,
}
fn clamp_u32(v: i64) -> u32 {
v.clamp(0, i64::from(u32::MAX)) as u32
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Parsed {
Cmd(Command),
Error(String),
}
pub fn parse_command(line: &[u8]) -> Parsed {
let req: Request = match serde_json::from_slice(line) {
Ok(r) => r,
Err(e) => return Parsed::Error(format!("E_BAD_REQUEST: {e}")),
};
match req.op.as_str() {
"set" => {
let pin = match req.pin {
Some(p) => p,
None => return Parsed::Error("E_MISSING_PIN".to_string()),
};
let level = match req.level {
Some(l) => l,
None => return Parsed::Error("E_MISSING_LEVEL".to_string()),
};
Parsed::Cmd(Command::Set {
chip: req.chip.unwrap_or(0),
pin,
level,
})
}
"beep" => {
let pin = match req.pin {
Some(p) => p,
None => return Parsed::Error("E_MISSING_PIN".to_string()),
};
let on_ms = match req.on_ms {
Some(v) => v,
None => return Parsed::Error("E_MISSING_ON_MS".to_string()),
};
let cycles = match req.cycles {
Some(v) => v,
None => return Parsed::Error("E_MISSING_CYCLES".to_string()),
};
let pattern = BeepPattern {
freq_hz: clamp_u32(req.freq_hz.unwrap_or(0)),
duty_pct: req.duty_pct.unwrap_or(50).clamp(0, 100) as u8,
on_ms: clamp_u32(on_ms),
off_ms: clamp_u32(req.off_ms.unwrap_or(0)),
cycles: clamp_u32(cycles),
}
.clamp();
Parsed::Cmd(Command::Beep {
chip: req.chip.unwrap_or(0),
pin,
pattern,
})
}
"status" => Parsed::Cmd(Command::Status),
other => Parsed::Error(format!("E_UNKNOWN_OP: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamp_bounds_every_field() {
let p = BeepPattern {
freq_hz: 2_000,
duty_pct: 200,
on_ms: 99_999,
off_ms: 99_999,
cycles: 10_000,
}
.clamp();
assert_eq!(p.duty_pct, 100);
assert_eq!(p.on_ms, MAX_PHASE_MS);
assert_eq!(p.off_ms, MAX_PHASE_MS);
assert_eq!(p.cycles, MAX_BEEP_CYCLES);
assert_eq!(p.freq_hz, 2_000);
let fast = BeepPattern {
freq_hz: 40_000,
..p
}
.clamp();
assert_eq!(fast.freq_hz, MAX_CARRIER_HZ);
}
#[test]
fn a_carrier_splits_the_period_by_duty() {
let p = BeepPattern {
freq_hz: 2_000,
duty_pct: 25,
on_ms: 100,
off_ms: 100,
cycles: 1,
};
let (high, low) = carrier_timing(p).unwrap();
assert_eq!(high, std::time::Duration::from_micros(125));
assert_eq!(low, std::time::Duration::from_micros(375));
}
#[test]
fn no_carrier_means_a_steady_on_phase() {
let base = BeepPattern {
freq_hz: 0,
duty_pct: 50,
on_ms: 100,
off_ms: 100,
cycles: 1,
};
assert_eq!(carrier_timing(base), None);
assert_eq!(
carrier_timing(BeepPattern {
freq_hz: 2_000,
duty_pct: 100,
..base
}),
None
);
assert_eq!(
carrier_timing(BeepPattern {
freq_hz: 2_000,
duty_pct: 0,
..base
}),
None
);
}
#[test]
fn clamp_is_idempotent() {
let p = BeepPattern {
freq_hz: 0,
duty_pct: 50,
on_ms: 100,
off_ms: 100,
cycles: 3,
};
assert_eq!(p.clamp(), p.clamp().clamp());
}
#[test]
fn schedule_expands_cycles_into_high_low_phases() {
let p = BeepPattern {
freq_hz: 0,
duty_pct: 50,
on_ms: 100,
off_ms: 50,
cycles: 2,
};
let s = beep_schedule(p);
assert_eq!(
s,
vec![
BeepPhase {
level: Level::High,
hold_ms: 100
},
BeepPhase {
level: Level::Low,
hold_ms: 50
},
BeepPhase {
level: Level::High,
hold_ms: 100
},
BeepPhase {
level: Level::Low,
hold_ms: 50
},
BeepPhase {
level: Level::Low,
hold_ms: 0
},
]
);
}
#[test]
fn schedule_always_ends_low() {
let p = BeepPattern {
freq_hz: 0,
duty_pct: 100,
on_ms: 200,
off_ms: 0,
cycles: 1,
};
let s = beep_schedule(p);
assert_eq!(s.last().unwrap().level, Level::Low);
assert_eq!(
s[0],
BeepPhase {
level: Level::High,
hold_ms: 200
}
);
assert_eq!(s.last().unwrap().hold_ms, 0);
}
#[test]
fn schedule_truncates_at_the_total_budget() {
let p = BeepPattern {
freq_hz: 0,
duty_pct: 100,
on_ms: MAX_PHASE_MS,
off_ms: MAX_PHASE_MS,
cycles: MAX_BEEP_CYCLES,
};
let s = beep_schedule(p);
let total: u64 = s.iter().map(|ph| ph.hold_ms as u64).sum();
assert!(
total <= MAX_TOTAL_MS,
"scheduled {total} ms exceeds the cap"
);
assert_eq!(s.last().unwrap().level, Level::Low);
}
#[test]
fn zero_cycle_pattern_is_a_single_terminal_low() {
let p = BeepPattern {
freq_hz: 0,
duty_pct: 50,
on_ms: 100,
off_ms: 100,
cycles: 0,
};
let s = beep_schedule(p);
assert_eq!(
s,
vec![BeepPhase {
level: Level::Low,
hold_ms: 0
}]
);
}
fn cmd(line: &[u8]) -> Command {
match parse_command(line) {
Parsed::Cmd(c) => c,
Parsed::Error(e) => panic!("expected a command, got error {e}"),
}
}
fn err(line: &[u8]) -> String {
match parse_command(line) {
Parsed::Error(e) => e,
Parsed::Cmd(c) => panic!("expected an error, got command {c:?}"),
}
}
#[test]
fn set_high_parses_with_a_default_chip() {
assert_eq!(
cmd(br#"{"op":"set","pin":17,"level":"high"}"#),
Command::Set {
chip: 0,
pin: 17,
level: Level::High
}
);
}
#[test]
fn set_low_with_explicit_chip() {
assert_eq!(
cmd(br#"{"op":"set","chip":1,"pin":4,"level":"low"}"#),
Command::Set {
chip: 1,
pin: 4,
level: Level::Low
}
);
}
#[test]
fn set_requires_pin_and_level() {
assert_eq!(err(br#"{"op":"set","level":"high"}"#), "E_MISSING_PIN");
assert_eq!(err(br#"{"op":"set","pin":17}"#), "E_MISSING_LEVEL");
}
#[test]
fn beep_parses_and_clamps_the_pattern() {
let c = cmd(
br#"{"op":"beep","pin":18,"on_ms":99999,"off_ms":99999,"cycles":10000,"duty_pct":200}"#,
);
match c {
Command::Beep { chip, pin, pattern } => {
assert_eq!(chip, 0);
assert_eq!(pin, 18);
assert_eq!(pattern.on_ms, MAX_PHASE_MS);
assert_eq!(pattern.off_ms, MAX_PHASE_MS);
assert_eq!(pattern.cycles, MAX_BEEP_CYCLES);
assert_eq!(pattern.duty_pct, 100);
}
other => panic!("expected a beep, got {other:?}"),
}
}
#[test]
fn beep_fields_outside_their_wire_type_are_clamped_not_rejected() {
let c = cmd(
br#"{"op":"beep","pin":18,"on_ms":9999999999,"off_ms":-5,"cycles":2,"duty_pct":300}"#,
);
match c {
Command::Beep { pattern, .. } => {
assert_eq!(pattern.duty_pct, 100);
assert_eq!(pattern.on_ms, MAX_PHASE_MS);
assert_eq!(pattern.off_ms, 0);
assert_eq!(pattern.cycles, 2);
}
other => panic!("expected a beep, got {other:?}"),
}
}
#[test]
fn beep_requires_an_envelope() {
assert_eq!(err(br#"{"op":"beep","pin":18}"#), "E_MISSING_ON_MS");
assert_eq!(
err(br#"{"op":"beep","pin":18,"on_ms":100}"#),
"E_MISSING_CYCLES"
);
assert_eq!(
err(br#"{"op":"beep","on_ms":100,"cycles":3}"#),
"E_MISSING_PIN"
);
}
#[test]
fn status_parses() {
assert_eq!(cmd(br#"{"op":"status"}"#), Command::Status);
}
#[test]
fn bad_json_is_a_clean_error_not_a_panic() {
assert!(err(b"not json").starts_with("E_BAD_REQUEST"));
assert!(err(b"").starts_with("E_BAD_REQUEST"));
}
#[test]
fn unknown_op_is_rejected() {
assert!(err(br#"{"op":"frob"}"#).starts_with("E_UNKNOWN_OP"));
}
#[test]
fn an_invalid_level_string_is_a_bad_request() {
assert!(err(br#"{"op":"set","pin":17,"level":"medium"}"#).starts_with("E_BAD_REQUEST"));
}
#[test]
fn level_value_maps_to_the_line_value() {
assert_eq!(Level::Low.value(), 0);
assert_eq!(Level::High.value(), 1);
}
}