Skip to main content

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}