ados_gpio/lib.rs
1#![doc = include_str!("../README.md")]
2
3use serde::{Deserialize, Serialize};
4
5#[cfg(unix)]
6pub mod daemon;
7pub mod sidecar;
8
9#[cfg(target_os = "linux")]
10mod linux_output;
11#[cfg(target_os = "linux")]
12pub use linux_output::GpioOutput;
13
14/// Operator command socket the service serves and the plugin host forwards to
15/// (`/run/ados/gpio-cmd.sock`). Mirrors the radio's `radio-cmd.sock` and the
16/// net manager's `wifi-cmd.sock` pattern: one newline-JSON request, one
17/// newline-JSON response per connection.
18pub const GPIO_CMD_SOCK: &str = "/run/ados/gpio-cmd.sock";
19
20// ---------------------------------------------------------------------------
21// Bounds. A command is always clamped into these before it can reach hardware,
22// so a malformed or hostile request can never hold a line high indefinitely.
23// ---------------------------------------------------------------------------
24
25/// Largest number of on/off cycles a single beep pattern may schedule. A request
26/// for more is clamped down to this.
27pub const MAX_BEEP_CYCLES: u32 = 64;
28
29/// Longest a single on or off phase may last, in milliseconds. Caps the worst
30/// case where one cycle of a clamped pattern still holds the line for a long
31/// time.
32pub const MAX_PHASE_MS: u32 = 5_000;
33
34/// Hard ceiling on the total scheduled duration of one pattern, in milliseconds
35/// (30 s). Even a pattern within the per-phase and per-cycle bounds is truncated
36/// so the whole beep can never run away; the service stops driving and returns
37/// the line low once the schedule completes.
38pub const MAX_TOTAL_MS: u64 = 30_000;
39
40/// Highest carrier frequency the service toggles, in Hz. The carrier is driven
41/// from userspace, so a request above this is clamped down rather than
42/// producing a toggle loop the scheduler cannot keep up with.
43pub const MAX_CARRIER_HZ: u32 = 5_000;
44
45/// The logical level a pin is driven to.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum Level {
49 /// Drive the line low (0).
50 Low,
51 /// Drive the line high (1).
52 High,
53}
54
55impl Level {
56 /// The 0/1 line value this level writes.
57 pub fn value(self) -> u8 {
58 match self {
59 Level::Low => 0,
60 Level::High => 1,
61 }
62 }
63}
64
65// ---------------------------------------------------------------------------
66// Beep pattern (software PWM) — pure timing model.
67// ---------------------------------------------------------------------------
68
69/// A bounded software-PWM beep description.
70///
71/// The envelope toggles the line on for `on_ms` then off for `off_ms`, for
72/// `cycles`. During each on phase the service also drives the carrier a
73/// passive buzzer needs: the line toggles at `freq_hz` with `duty_pct` high
74/// time ([`carrier_timing`]). With `freq_hz` 0 (an active buzzer or an LED) the
75/// on phase is a steady high.
76///
77/// Every field is advisory until [`clamp`](Self::clamp) bounds it; the service
78/// always clamps before scheduling, so a value out of range is corrected, never
79/// trusted.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81pub struct BeepPattern {
82 /// Buzzer carrier frequency in Hz (0 = no carrier, a plain on/off envelope
83 /// for an active buzzer or LED). Clamped to [`MAX_CARRIER_HZ`].
84 pub freq_hz: u32,
85 /// Carrier duty cycle as a percentage (0..=100). Clamped to 100.
86 pub duty_pct: u8,
87 /// Milliseconds the line is held high per cycle.
88 pub on_ms: u32,
89 /// Milliseconds the line is held low between cycles.
90 pub off_ms: u32,
91 /// Number of on/off cycles to run.
92 pub cycles: u32,
93}
94
95impl BeepPattern {
96 /// Clamp every field into the safe bounds. `duty_pct` is capped at 100,
97 /// `freq_hz` at [`MAX_CARRIER_HZ`], `on_ms`/`off_ms` at [`MAX_PHASE_MS`],
98 /// `cycles` at [`MAX_BEEP_CYCLES`]. Idempotent: clamping an already-clamped
99 /// pattern is a no-op.
100 pub fn clamp(self) -> Self {
101 Self {
102 freq_hz: self.freq_hz.min(MAX_CARRIER_HZ),
103 duty_pct: self.duty_pct.min(100),
104 on_ms: self.on_ms.min(MAX_PHASE_MS),
105 off_ms: self.off_ms.min(MAX_PHASE_MS),
106 cycles: self.cycles.min(MAX_BEEP_CYCLES),
107 }
108 }
109
110 /// Total scheduled duration of the clamped pattern, in milliseconds, before
111 /// the [`MAX_TOTAL_MS`] truncation. Used by [`beep_schedule`] to truncate.
112 fn raw_total_ms(self) -> u64 {
113 let per_cycle = self.on_ms as u64 + self.off_ms as u64;
114 per_cycle.saturating_mul(self.cycles as u64)
115 }
116}
117
118/// The carrier half-periods for a clamped pattern: how long the line is high
119/// and low within one carrier period during an on phase. `None` means the on
120/// phase is a steady high: no carrier requested (`freq_hz` 0), or a duty of 0
121/// or 100 that a toggle cannot express.
122pub fn carrier_timing(pattern: BeepPattern) -> Option<(std::time::Duration, std::time::Duration)> {
123 let p = pattern.clamp();
124 if p.freq_hz == 0 || p.duty_pct == 0 || p.duty_pct >= 100 {
125 return None;
126 }
127 let period_us = 1_000_000u64 / p.freq_hz as u64;
128 let high_us = period_us * p.duty_pct as u64 / 100;
129 Some((
130 std::time::Duration::from_micros(high_us.max(1)),
131 std::time::Duration::from_micros((period_us - high_us).max(1)),
132 ))
133}
134
135/// One phase of a beep schedule: hold the line at `level` for `hold_ms`, then
136/// move to the next phase. The final phase always returns the line low.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct BeepPhase {
139 /// The level to drive for this phase.
140 pub level: Level,
141 /// How long to hold it, in milliseconds.
142 pub hold_ms: u32,
143}
144
145/// Compute the toggle schedule for a beep pattern. Pure: no hardware, no clock,
146/// fully unit-testable. The pattern is clamped first, then expanded into a list
147/// of [`BeepPhase`]s (a `High` for `on_ms` then a `Low` for `off_ms`, repeated
148/// `cycles` times), and the whole sequence is truncated so its summed duration
149/// never exceeds [`MAX_TOTAL_MS`]. The returned schedule always ends with the
150/// line low, so the service never leaves a buzzer/LED stuck on.
151///
152/// A zero-cycle (or all-zero-duration) pattern yields a single terminal `Low`
153/// phase of 0 ms — the service drives the line low and is done.
154pub fn beep_schedule(pattern: BeepPattern) -> Vec<BeepPhase> {
155 let p = pattern.clamp();
156 let mut phases = Vec::new();
157 let mut elapsed: u64 = 0;
158
159 // The truncation budget: never schedule past MAX_TOTAL_MS regardless of how
160 // the clamped per-phase/per-cycle bounds multiply out.
161 let budget = p.raw_total_ms().min(MAX_TOTAL_MS);
162
163 'outer: for _ in 0..p.cycles {
164 for (level, ms) in [(Level::High, p.on_ms), (Level::Low, p.off_ms)] {
165 if ms == 0 {
166 continue;
167 }
168 let remaining = budget.saturating_sub(elapsed);
169 if remaining == 0 {
170 break 'outer;
171 }
172 let hold = (ms as u64).min(remaining) as u32;
173 phases.push(BeepPhase {
174 level,
175 hold_ms: hold,
176 });
177 elapsed += hold as u64;
178 }
179 }
180
181 // Always finish low so a buzzer/LED is never left energized. If the last
182 // emitted phase is already Low we still append a 0 ms Low terminator so the
183 // contract ("the schedule ends low") holds without inspecting the tail.
184 phases.push(BeepPhase {
185 level: Level::Low,
186 hold_ms: 0,
187 });
188 phases
189}
190
191// ---------------------------------------------------------------------------
192// Command schema — the wire protocol the command socket accepts. Pure parsing.
193// ---------------------------------------------------------------------------
194
195/// A validated GPIO-output command, parsed from one request line. Parsing is
196/// pure (no hardware), so every malformed request is rejected before the service
197/// touches a line. A `set` drives one line to a level; a `beep` runs a bounded
198/// software-PWM envelope on one line; `status` reports the current line states.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum Command {
201 /// Drive `pin` on chip `chip` to `level`.
202 Set { chip: u32, pin: u32, level: Level },
203 /// Run a bounded beep envelope on `pin` of chip `chip`.
204 Beep {
205 chip: u32,
206 pin: u32,
207 pattern: BeepPattern,
208 },
209 /// Report the current driven line states.
210 Status,
211}
212
213/// The raw request wire shape. `op` selects the command; the remaining fields
214/// are optional and validated per op. A `default` chip of 0 matches the common
215/// single-controller board where every line is on `/dev/gpiochip0`.
216#[derive(Debug, Deserialize)]
217struct Request {
218 op: String,
219 #[serde(default)]
220 chip: Option<u32>,
221 #[serde(default)]
222 pin: Option<u32>,
223 #[serde(default)]
224 level: Option<Level>,
225 // The beep fields are read wide and clamped in `parse_command`, so a value
226 // out of the narrow field's range (negative, a duty of 300, an hour-long
227 // phase) is corrected rather than failing the parse.
228 #[serde(default)]
229 freq_hz: Option<i64>,
230 #[serde(default)]
231 duty_pct: Option<i64>,
232 #[serde(default)]
233 on_ms: Option<i64>,
234 #[serde(default)]
235 off_ms: Option<i64>,
236 #[serde(default)]
237 cycles: Option<i64>,
238}
239
240/// Clamp a wide request integer into `u32` (negative becomes 0).
241fn clamp_u32(v: i64) -> u32 {
242 v.clamp(0, i64::from(u32::MAX)) as u32
243}
244
245/// The outcome of parsing a request line: a routed [`Command`], or a terminal
246/// machine-readable error string for a malformed/unknown request (answered
247/// without ever touching hardware).
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum Parsed {
250 /// A validated, apply-ready command.
251 Cmd(Command),
252 /// A stable error code the caller renders as `{"ok":false,"error":<code>}`.
253 Error(String),
254}
255
256/// Parse + validate one request line. Pure: no hardware, no I/O, fully
257/// unit-testable. Bad JSON, an unknown op, or a missing required field resolve
258/// to [`Parsed::Error`] with a stable `E_*` code; a `beep` pattern is clamped
259/// into the safe bounds here so an out-of-range request is corrected, never
260/// rejected outright (the operator still gets a bounded beep). An empty line is
261/// a clean `E_BAD_REQUEST`, never a panic.
262pub fn parse_command(line: &[u8]) -> Parsed {
263 let req: Request = match serde_json::from_slice(line) {
264 Ok(r) => r,
265 Err(e) => return Parsed::Error(format!("E_BAD_REQUEST: {e}")),
266 };
267 match req.op.as_str() {
268 "set" => {
269 let pin = match req.pin {
270 Some(p) => p,
271 None => return Parsed::Error("E_MISSING_PIN".to_string()),
272 };
273 let level = match req.level {
274 Some(l) => l,
275 None => return Parsed::Error("E_MISSING_LEVEL".to_string()),
276 };
277 Parsed::Cmd(Command::Set {
278 chip: req.chip.unwrap_or(0),
279 pin,
280 level,
281 })
282 }
283 "beep" => {
284 let pin = match req.pin {
285 Some(p) => p,
286 None => return Parsed::Error("E_MISSING_PIN".to_string()),
287 };
288 // A beep with no on/off envelope is meaningless; require at least
289 // on_ms and a cycle count so a "beep" actually beeps.
290 let on_ms = match req.on_ms {
291 Some(v) => v,
292 None => return Parsed::Error("E_MISSING_ON_MS".to_string()),
293 };
294 let cycles = match req.cycles {
295 Some(v) => v,
296 None => return Parsed::Error("E_MISSING_CYCLES".to_string()),
297 };
298 let pattern = BeepPattern {
299 freq_hz: clamp_u32(req.freq_hz.unwrap_or(0)),
300 duty_pct: req.duty_pct.unwrap_or(50).clamp(0, 100) as u8,
301 on_ms: clamp_u32(on_ms),
302 off_ms: clamp_u32(req.off_ms.unwrap_or(0)),
303 cycles: clamp_u32(cycles),
304 }
305 .clamp();
306 Parsed::Cmd(Command::Beep {
307 chip: req.chip.unwrap_or(0),
308 pin,
309 pattern,
310 })
311 }
312 "status" => Parsed::Cmd(Command::Status),
313 other => Parsed::Error(format!("E_UNKNOWN_OP: {other}")),
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 // ---- BeepPattern::clamp -------------------------------------------------
322
323 #[test]
324 fn clamp_bounds_every_field() {
325 let p = BeepPattern {
326 freq_hz: 2_000,
327 duty_pct: 200,
328 on_ms: 99_999,
329 off_ms: 99_999,
330 cycles: 10_000,
331 }
332 .clamp();
333 assert_eq!(p.duty_pct, 100);
334 assert_eq!(p.on_ms, MAX_PHASE_MS);
335 assert_eq!(p.off_ms, MAX_PHASE_MS);
336 assert_eq!(p.cycles, MAX_BEEP_CYCLES);
337 assert_eq!(p.freq_hz, 2_000);
338 let fast = BeepPattern {
339 freq_hz: 40_000,
340 ..p
341 }
342 .clamp();
343 assert_eq!(fast.freq_hz, MAX_CARRIER_HZ);
344 }
345
346 #[test]
347 fn a_carrier_splits_the_period_by_duty() {
348 let p = BeepPattern {
349 freq_hz: 2_000,
350 duty_pct: 25,
351 on_ms: 100,
352 off_ms: 100,
353 cycles: 1,
354 };
355 let (high, low) = carrier_timing(p).unwrap();
356 assert_eq!(high, std::time::Duration::from_micros(125));
357 assert_eq!(low, std::time::Duration::from_micros(375));
358 }
359
360 #[test]
361 fn no_carrier_means_a_steady_on_phase() {
362 let base = BeepPattern {
363 freq_hz: 0,
364 duty_pct: 50,
365 on_ms: 100,
366 off_ms: 100,
367 cycles: 1,
368 };
369 assert_eq!(carrier_timing(base), None);
370 assert_eq!(
371 carrier_timing(BeepPattern {
372 freq_hz: 2_000,
373 duty_pct: 100,
374 ..base
375 }),
376 None
377 );
378 assert_eq!(
379 carrier_timing(BeepPattern {
380 freq_hz: 2_000,
381 duty_pct: 0,
382 ..base
383 }),
384 None
385 );
386 }
387
388 #[test]
389 fn clamp_is_idempotent() {
390 let p = BeepPattern {
391 freq_hz: 0,
392 duty_pct: 50,
393 on_ms: 100,
394 off_ms: 100,
395 cycles: 3,
396 };
397 assert_eq!(p.clamp(), p.clamp().clamp());
398 }
399
400 // ---- beep_schedule ------------------------------------------------------
401
402 #[test]
403 fn schedule_expands_cycles_into_high_low_phases() {
404 let p = BeepPattern {
405 freq_hz: 0,
406 duty_pct: 50,
407 on_ms: 100,
408 off_ms: 50,
409 cycles: 2,
410 };
411 let s = beep_schedule(p);
412 // 2 cycles → High,Low,High,Low + the terminal Low.
413 assert_eq!(
414 s,
415 vec![
416 BeepPhase {
417 level: Level::High,
418 hold_ms: 100
419 },
420 BeepPhase {
421 level: Level::Low,
422 hold_ms: 50
423 },
424 BeepPhase {
425 level: Level::High,
426 hold_ms: 100
427 },
428 BeepPhase {
429 level: Level::Low,
430 hold_ms: 50
431 },
432 BeepPhase {
433 level: Level::Low,
434 hold_ms: 0
435 },
436 ]
437 );
438 }
439
440 #[test]
441 fn schedule_always_ends_low() {
442 // Even a pure-on pattern (no off phase) ends with a terminal Low so the
443 // line is never left high.
444 let p = BeepPattern {
445 freq_hz: 0,
446 duty_pct: 100,
447 on_ms: 200,
448 off_ms: 0,
449 cycles: 1,
450 };
451 let s = beep_schedule(p);
452 assert_eq!(s.last().unwrap().level, Level::Low);
453 // The High phase is present, the zero-ms Off is skipped, the terminal
454 // Low is appended.
455 assert_eq!(
456 s[0],
457 BeepPhase {
458 level: Level::High,
459 hold_ms: 200
460 }
461 );
462 assert_eq!(s.last().unwrap().hold_ms, 0);
463 }
464
465 #[test]
466 fn schedule_truncates_at_the_total_budget() {
467 // A clamped pattern can still ask for 64 * (5000+5000) ms = 640 s; the
468 // schedule must truncate so its summed duration never exceeds the 30 s
469 // ceiling.
470 let p = BeepPattern {
471 freq_hz: 0,
472 duty_pct: 100,
473 on_ms: MAX_PHASE_MS,
474 off_ms: MAX_PHASE_MS,
475 cycles: MAX_BEEP_CYCLES,
476 };
477 let s = beep_schedule(p);
478 let total: u64 = s.iter().map(|ph| ph.hold_ms as u64).sum();
479 assert!(
480 total <= MAX_TOTAL_MS,
481 "scheduled {total} ms exceeds the cap"
482 );
483 assert_eq!(s.last().unwrap().level, Level::Low);
484 }
485
486 #[test]
487 fn zero_cycle_pattern_is_a_single_terminal_low() {
488 let p = BeepPattern {
489 freq_hz: 0,
490 duty_pct: 50,
491 on_ms: 100,
492 off_ms: 100,
493 cycles: 0,
494 };
495 let s = beep_schedule(p);
496 assert_eq!(
497 s,
498 vec![BeepPhase {
499 level: Level::Low,
500 hold_ms: 0
501 }]
502 );
503 }
504
505 // ---- parse_command ------------------------------------------------------
506
507 fn cmd(line: &[u8]) -> Command {
508 match parse_command(line) {
509 Parsed::Cmd(c) => c,
510 Parsed::Error(e) => panic!("expected a command, got error {e}"),
511 }
512 }
513
514 fn err(line: &[u8]) -> String {
515 match parse_command(line) {
516 Parsed::Error(e) => e,
517 Parsed::Cmd(c) => panic!("expected an error, got command {c:?}"),
518 }
519 }
520
521 #[test]
522 fn set_high_parses_with_a_default_chip() {
523 assert_eq!(
524 cmd(br#"{"op":"set","pin":17,"level":"high"}"#),
525 Command::Set {
526 chip: 0,
527 pin: 17,
528 level: Level::High
529 }
530 );
531 }
532
533 #[test]
534 fn set_low_with_explicit_chip() {
535 assert_eq!(
536 cmd(br#"{"op":"set","chip":1,"pin":4,"level":"low"}"#),
537 Command::Set {
538 chip: 1,
539 pin: 4,
540 level: Level::Low
541 }
542 );
543 }
544
545 #[test]
546 fn set_requires_pin_and_level() {
547 assert_eq!(err(br#"{"op":"set","level":"high"}"#), "E_MISSING_PIN");
548 assert_eq!(err(br#"{"op":"set","pin":17}"#), "E_MISSING_LEVEL");
549 }
550
551 #[test]
552 fn beep_parses_and_clamps_the_pattern() {
553 // An over-range request is clamped, not rejected: the operator still gets
554 // a bounded beep.
555 let c = cmd(
556 br#"{"op":"beep","pin":18,"on_ms":99999,"off_ms":99999,"cycles":10000,"duty_pct":200}"#,
557 );
558 match c {
559 Command::Beep { chip, pin, pattern } => {
560 assert_eq!(chip, 0);
561 assert_eq!(pin, 18);
562 assert_eq!(pattern.on_ms, MAX_PHASE_MS);
563 assert_eq!(pattern.off_ms, MAX_PHASE_MS);
564 assert_eq!(pattern.cycles, MAX_BEEP_CYCLES);
565 assert_eq!(pattern.duty_pct, 100);
566 }
567 other => panic!("expected a beep, got {other:?}"),
568 }
569 }
570
571 #[test]
572 fn beep_fields_outside_their_wire_type_are_clamped_not_rejected() {
573 // duty 300 exceeds u8, a negative off_ms and an on_ms past u32::MAX
574 // exceed u32; each is corrected into bounds.
575 let c = cmd(
576 br#"{"op":"beep","pin":18,"on_ms":9999999999,"off_ms":-5,"cycles":2,"duty_pct":300}"#,
577 );
578 match c {
579 Command::Beep { pattern, .. } => {
580 assert_eq!(pattern.duty_pct, 100);
581 assert_eq!(pattern.on_ms, MAX_PHASE_MS);
582 assert_eq!(pattern.off_ms, 0);
583 assert_eq!(pattern.cycles, 2);
584 }
585 other => panic!("expected a beep, got {other:?}"),
586 }
587 }
588
589 #[test]
590 fn beep_requires_an_envelope() {
591 assert_eq!(err(br#"{"op":"beep","pin":18}"#), "E_MISSING_ON_MS");
592 assert_eq!(
593 err(br#"{"op":"beep","pin":18,"on_ms":100}"#),
594 "E_MISSING_CYCLES"
595 );
596 assert_eq!(
597 err(br#"{"op":"beep","on_ms":100,"cycles":3}"#),
598 "E_MISSING_PIN"
599 );
600 }
601
602 #[test]
603 fn status_parses() {
604 assert_eq!(cmd(br#"{"op":"status"}"#), Command::Status);
605 }
606
607 #[test]
608 fn bad_json_is_a_clean_error_not_a_panic() {
609 assert!(err(b"not json").starts_with("E_BAD_REQUEST"));
610 assert!(err(b"").starts_with("E_BAD_REQUEST"));
611 }
612
613 #[test]
614 fn unknown_op_is_rejected() {
615 assert!(err(br#"{"op":"frob"}"#).starts_with("E_UNKNOWN_OP"));
616 }
617
618 #[test]
619 fn an_invalid_level_string_is_a_bad_request() {
620 // `level` only deserializes "low"/"high"; anything else fails the whole
621 // request parse (a clean error, never a panic).
622 assert!(err(br#"{"op":"set","pin":17,"level":"medium"}"#).starts_with("E_BAD_REQUEST"));
623 }
624
625 #[test]
626 fn level_value_maps_to_the_line_value() {
627 assert_eq!(Level::Low.value(), 0);
628 assert_eq!(Level::High.value(), 1);
629 }
630}