diff --git a/Cargo.lock b/Cargo.lock index b0d784c5..3f910270 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -822,6 +822,7 @@ dependencies = [ "embedded-graphics", "embedded-graphics-simulator", "embedded-hal", + "gpiocdev", "logger", "menu", "nmrs", @@ -1001,6 +1002,28 @@ dependencies = [ "rand_core", ] +[[package]] +name = "gpiocdev" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6009ef6766dd62a7f5933d379d357ae89eaee4e9c2455b6596f359627660a1" +dependencies = [ + "gpiocdev-uapi", + "thiserror", +] + +[[package]] +name = "gpiocdev-uapi" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb1a3ace06ef708255a33e738ec7a5e7718fe8e53bd9a39299b4f1cd4b13e9a3" +dependencies = [ + "bitflags", + "ioctl-sys", + "libc", + "thiserror", +] + [[package]] name = "h2" version = "0.4.19" @@ -1395,6 +1418,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ioctl-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd11f3a29434026f5ff98c730b668ba74b1033637b8817940b54d040696133c" + [[package]] name = "itertools" version = "0.14.0" diff --git a/apps/firmware/Cargo.toml b/apps/firmware/Cargo.toml index 18b3161b..84e83e8f 100644 --- a/apps/firmware/Cargo.toml +++ b/apps/firmware/Cargo.toml @@ -9,6 +9,7 @@ version.workspace = true display-interface = "0.5.0" embedded-graphics = "0.8.2" embedded-hal = "1.0.0" +gpiocdev = "0.8.0" logger = { path = "../../crates/logger", features = ["std"] } menu = { path = "../../crates/menu" } nmrs = "3.5.2" diff --git a/apps/firmware/README.md b/apps/firmware/README.md index 511d2704..af9c11da 100644 --- a/apps/firmware/README.md +++ b/apps/firmware/README.md @@ -31,12 +31,16 @@ The E2E cases live in `tests/e2e/`. The default E2E run requires Docker: Rust `testcontainers` starts real NetworkManager in an Ubuntu container and a pinned Ubuntu QEMU VM. The VM loads two guest-kernel `mac80211_hwsim` radios; the Rust probe uses the production `Connectivity` API to discover and join open/WPA -networks and start/stop a visible hotspot. KVM accelerates the VM when -available; without `/dev/kvm`, QEMU uses slower software emulation. A cold VM -image download is about 600 MB. These tests verify Linux integration, **not** -the Yocto image or Pi hardware. GPIO E2E tests still feed scripted electrical -levels into the real debounce/event/runtime path because no Linux GPIO adapter -is implemented yet. +networks and start/stop a visible hotspot. In the same guest, `gpio-sim` creates +a 32-line Linux GPIO chip. The probe drives simulated input pulls and verifies +that the production `LinuxGpioReader` reads `/dev/gpiochip*` to deliver one +representative button's press and release to the firmware runtime. Existing +scripted GPIO cases cover all 12 mappings, bounce, initially held buttons, +and read failures deterministically. KVM accelerates the VM when available; +without `/dev/kvm`, QEMU uses slower software emulation. A cold VM image download is about 600 MB. +These tests verify Linux integration, **not** the Yocto image or Pi hardware. +The Linux GPIO reader is available to callers but is not yet wired into the +production executable's startup. `src/connectivity/` owns typed, presentation-agnostic Wi-Fi control. Its `Connectivity` API reports status, scans access points, manages the WPA @@ -57,10 +61,14 @@ Each request has one placeholder integration file under `src/menu/transitions/`, so future module ownership has an explicit location without entering the menu definition or runtime loop. -Button pins in `src/hardware/pins/gpio/button/` own polling, active-low +`src/hardware/buttons.rs` and `src/hardware/debounce.rs` own polling, active-low translation, and mechanical debounce. Call `start_subscription` with a GPIO reader, then await `on_message` for debounced pressed/released transitions. -Callers do not inspect GPIO levels. +`hardware::linux_gpio::LinuxGpioReader` requests inputs with internal pull-ups +on a caller-selected Linux GPIO chip; the button adapter interprets a low level +as pressed. The VM uses the external-bias constructor because `gpio-sim` drives +those levels via its separate sysfs interface. Callers do not inspect GPIO +levels. `src/hardware/display/` constructs the externally maintained `ssd1306` crate's buffered driver for the installed 128×64 OLED at address `0x3C`. It is @@ -80,7 +88,7 @@ The remaining hardware workers are not implemented yet. The board contract uses polled TCA9554 input-port reads at eight addresses, not a GPIO sensor interrupt; see [host acquisition](../../docs/host.md#reading-the-board). Reusable, function-named GPIO identities and type-safe descriptors live in -`src/hardware/pins/`; unused header functions are deliberately absent. The -module mirrors the three interfaces in the hardware wiring contract: -`hardware/pins/i2c.rs` for the shared I2C bus, `hardware/pins/spi.rs` for the LED -chain, and `hardware/pins/gpio.rs` for direct button lines. +`src/hardware/pins.rs`; OS-backed implementations live alongside them (currently +`src/hardware/linux_gpio.rs`). Unused header functions are deliberately absent. +`BoardPins` describes the three interfaces in the hardware wiring contract: +I2C for the shared bus, SPI for the LED chain, and direct GPIO button lines. diff --git a/apps/firmware/src/hardware/pins/gpio/button/subscription.rs b/apps/firmware/src/hardware/buttons.rs similarity index 58% rename from apps/firmware/src/hardware/pins/gpio/button/subscription.rs rename to apps/firmware/src/hardware/buttons.rs index 873dd971..e391f7d1 100644 --- a/apps/firmware/src/hardware/pins/gpio/button/subscription.rs +++ b/apps/firmware/src/hardware/buttons.rs @@ -1,15 +1,107 @@ +//! Control-panel buttons: debounce GPIO levels and publish typed events. + use std::{error::Error as StdError, fmt}; use tokio::{runtime::Handle, task::JoinHandle, time::Instant}; use crate::events::{Bus, ReceiveError, Subscription}; -use super::{Button, ButtonAction, ButtonEvent, debounce::Debouncer, debounce::POLL_INTERVAL}; -use crate::hardware::{ +use super::{ HardwareEventBus, - pins::{GPIO, ReadLevel}, + debounce::{Debouncer, POLL_INTERVAL}, + pins::{GPIO, Level, ReadLevel}, }; +/// The label printed beside a physical control-panel button. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Button { + Up, + Down, + Left, + Right, + Ok, + Reset, + Pass, + F1, + F2, + F3, + F4, + F5, +} + +/// A debounced electrical transition produced by a physical button adapter. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ButtonEvent { + Pressed(Button), + Released(Button), +} + +/// A debounced physical transition from one panel button. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ButtonAction { + Pressed, + Released, +} + +/// A control-panel button connected directly to a GPIO input. +pub struct ButtonPin { + gpio: GPIO, + button: Button, +} + +impl ButtonPin { + pub(super) const fn new(button: Button) -> Self { + Self { + gpio: GPIO::new(BCM), + button, + } + } + + pub const fn gpio(&self) -> GPIO { + self.gpio + } + + pub const fn bcm_number(&self) -> u8 { + BCM + } + + /// Returns the physical panel label assigned to this pin. + pub const fn button(&self) -> Button { + self.button + } + + pub fn read_level(&self, reader: &mut R) -> Result { + reader.read_level(self.gpio) + } + + /// Starts polling and debouncing this button. + pub fn start_subscription( + &self, + reader: R, + events: &HardwareEventBus, + ) -> Result + where + R: ReadLevel + Send + 'static, + { + let runtime = Handle::try_current().map_err(|_| StartSubscriptionError)?; + let button_events = Bus::new(); + let event_subscription = button_events.subscribe(); + let worker = runtime.spawn(poll( + self.gpio, + self.button, + reader, + events.clone(), + button_events, + )); + + Ok(ButtonSubscription { + button: self.button, + events: event_subscription, + worker, + }) + } +} + /// A running button poller and its domain-level event subscription. #[derive(Debug)] pub struct ButtonSubscription { @@ -53,27 +145,6 @@ impl fmt::Display for StartSubscriptionError { impl StdError for StartSubscriptionError {} -pub(super) fn start( - gpio: GPIO, - button: Button, - reader: R, - events: &HardwareEventBus, -) -> Result -where - R: ReadLevel + Send + 'static, -{ - let runtime = Handle::try_current().map_err(|_| StartSubscriptionError)?; - let button_events = Bus::new(); - let event_subscription = button_events.subscribe(); - let worker = runtime.spawn(poll(gpio, button, reader, events.clone(), button_events)); - - Ok(ButtonSubscription { - button, - events: event_subscription, - worker, - }) -} - async fn poll( gpio: GPIO, button: Button, diff --git a/apps/firmware/src/hardware/debounce.rs b/apps/firmware/src/hardware/debounce.rs new file mode 100644 index 00000000..bf2f8db4 --- /dev/null +++ b/apps/firmware/src/hardware/debounce.rs @@ -0,0 +1,126 @@ +//! Time-based debounce for active-low control-panel buttons. + +use core::time::Duration; +use tokio::time::Instant; + +use super::{buttons::ButtonAction, pins::Level}; + +pub(super) const POLL_INTERVAL: Duration = Duration::from_millis(5); +const DEBOUNCE: Duration = Duration::from_millis(20); + +pub(super) struct Debouncer { + stable: Level, + candidate: Option<(Level, Instant)>, +} + +impl Debouncer { + pub(super) fn new(initial: Level) -> Self { + Self { + stable: initial, + candidate: None, + } + } + + pub(super) fn interrupt(&mut self) { + self.candidate = None; + } + + pub(super) fn observe(&mut self, level: Level, observed_at: Instant) -> Option { + if level == self.stable { + self.candidate = None; + return None; + } + + match self.candidate { + Some((candidate, since)) if candidate == level => { + if observed_at.duration_since(since) < DEBOUNCE { + return None; + } + } + _ => { + self.candidate = Some((level, observed_at)); + return None; + } + } + + self.stable = level; + self.candidate = None; + Some(match level { + Level::Low => ButtonAction::Pressed, + Level::High => ButtonAction::Released, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounce_and_held_levels_emit_only_stable_edges() { + let start = Instant::now(); + let mut button = Debouncer::new(Level::High); + assert_eq!(button.observe(Level::Low, start), None); + assert_eq!(button.observe(Level::High, start + POLL_INTERVAL), None); + assert_eq!(button.observe(Level::Low, start + DEBOUNCE), None); + assert_eq!( + button.observe(Level::Low, start + DEBOUNCE * 2), + Some(ButtonAction::Pressed) + ); + assert_eq!(button.observe(Level::Low, start + DEBOUNCE * 3), None); + assert_eq!(button.observe(Level::High, start + DEBOUNCE * 4), None); + assert_eq!( + button.observe(Level::High, start + DEBOUNCE * 5), + Some(ButtonAction::Released) + ); + } + + #[test] + fn transition_waits_for_the_complete_debounce_period() { + let start = Instant::now(); + let mut button = Debouncer::new(Level::High); + + assert_eq!(button.observe(Level::Low, start), None); + assert_eq!( + button.observe(Level::Low, start + DEBOUNCE - POLL_INTERVAL), + None + ); + assert_eq!( + button.observe(Level::Low, start + DEBOUNCE), + Some(ButtonAction::Pressed) + ); + } + + #[test] + fn returning_to_the_stable_level_cancels_the_candidate() { + let start = Instant::now(); + let mut button = Debouncer::new(Level::High); + + assert_eq!(button.observe(Level::Low, start), None); + assert_eq!(button.observe(Level::High, start + POLL_INTERVAL), None); + assert_eq!(button.observe(Level::Low, start + DEBOUNCE), None); + assert_eq!( + button.observe(Level::Low, start + DEBOUNCE * 2), + Some(ButtonAction::Pressed) + ); + } + + #[test] + fn read_failure_restarts_debounce_without_losing_last_stable_level() { + let start = Instant::now(); + let mut button = Debouncer::new(Level::Low); + assert_eq!(button.observe(Level::High, start), None); + button.interrupt(); + assert_eq!(button.observe(Level::High, start + DEBOUNCE), None); + assert_eq!( + button.observe(Level::High, start + DEBOUNCE * 2), + Some(ButtonAction::Released) + ); + } + + #[test] + fn initial_level_is_a_baseline_not_a_synthetic_press() { + let mut button = Debouncer::new(Level::Low); + assert_eq!(button.observe(Level::Low, Instant::now()), None); + } +} diff --git a/apps/firmware/src/hardware/linux_gpio.rs b/apps/firmware/src/hardware/linux_gpio.rs new file mode 100644 index 00000000..b1a4e550 --- /dev/null +++ b/apps/firmware/src/hardware/linux_gpio.rs @@ -0,0 +1,66 @@ +//! Linux GPIO character-device reader for the board's pull-up button inputs. + +use std::{collections::HashMap, collections::hash_map::Entry, path::PathBuf}; + +use gpiocdev::{ + Request, + line::{Bias, Value}, +}; + +use super::pins::{GPIO, Level, ReadLevel}; + +/// Keeps one kernel input request per BCM-numbered line on the selected chip. +/// The caller chooses the chip: GPIO numbering is chip-relative, not global. +/// Requests the Pi's internal pull-up; the button shorts the input to ground. +/// This reader does not invert the level; button presses are wired active-low. +pub struct LinuxGpioReader { + chip: PathBuf, + pull_up: bool, + requests: HashMap, +} + +impl LinuxGpioReader { + pub fn new(chip: impl Into) -> Self { + Self { + chip: chip.into(), + pull_up: true, + requests: HashMap::new(), + } + } + + /// Use external bias instead of the chip's internal pull-up. `gpio-sim` + /// injects its own levels this way; the physical panel uses `new`. + pub fn with_external_bias(chip: impl Into) -> Self { + Self { + chip: chip.into(), + pull_up: false, + requests: HashMap::new(), + } + } +} + +impl ReadLevel for LinuxGpioReader { + type Error = gpiocdev::Error; + + fn read_level(&mut self, gpio: GPIO) -> Result { + let request = match self.requests.entry(gpio.bcm_number()) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + let mut builder = Request::builder(); + builder + .on_chip(&self.chip) + .with_consumer("firmware-button") + .with_line(u32::from(gpio.bcm_number())) + .as_input(); + if self.pull_up { + builder.with_bias(Bias::PullUp); + } + entry.insert(builder.request()?) + } + }; + match request.lone_value()? { + Value::Active => Ok(Level::High), + Value::Inactive => Ok(Level::Low), + } + } +} diff --git a/apps/firmware/src/hardware/mod.rs b/apps/firmware/src/hardware/mod.rs index 399d305b..5b7507cc 100644 --- a/apps/firmware/src/hardware/mod.rs +++ b/apps/firmware/src/hardware/mod.rs @@ -1,7 +1,11 @@ //! Thin adapters over the board's physical interfaces. +mod buttons; +mod debounce; pub mod display; pub mod events; +#[cfg(target_os = "linux")] +pub mod linux_gpio; pub mod pins; pub use events::{ diff --git a/apps/firmware/src/hardware/pins/gpio.rs b/apps/firmware/src/hardware/pins.rs similarity index 72% rename from apps/firmware/src/hardware/pins/gpio.rs rename to apps/firmware/src/hardware/pins.rs index 71137846..14216a44 100644 --- a/apps/firmware/src/hardware/pins/gpio.rs +++ b/apps/firmware/src/hardware/pins.rs @@ -1,8 +1,11 @@ -use core::marker::PhantomData; +//! Board wiring, GPIO identities, and capability-checked pin access. +//! +//! These descriptors contain no Linux device handles; the OS adapter lives in +//! `hardware::linux_gpio` and button polling lives in `hardware::buttons`. -mod button; +use core::marker::PhantomData; -pub use button::{ +pub use super::buttons::{ Button, ButtonAction, ButtonEvent, ButtonPin, ButtonSubscription, StartSubscriptionError, }; @@ -50,6 +53,40 @@ pub trait WriteLevel { fn write_level(&mut self, gpio: GPIO, level: Level) -> Result<(), Self::Error>; } +pub struct Pin { + gpio: GPIO, + capability: PhantomData, +} + +impl Pin { + const fn new() -> Self { + Self { + gpio: GPIO::new(BCM), + capability: PhantomData, + } + } + + pub const fn gpio(&self) -> GPIO { + self.gpio + } + + pub const fn bcm_number(&self) -> u8 { + BCM + } +} + +impl Pin { + pub fn read_level(&self, reader: &mut R) -> Result { + reader.read_level(self.gpio) + } +} + +impl Pin { + pub fn set_level(&self, writer: &mut W, level: Level) -> Result<(), W::Error> { + writer.write_level(self.gpio, level) + } +} + /// Direct high/low GPIO pins used by the control-panel buttons. pub struct GPIOPins { pub up_button: ButtonPin<5>, @@ -67,7 +104,7 @@ pub struct GPIOPins { } impl GPIOPins { - pub(super) const fn get() -> Self { + const fn get() -> Self { Self { up_button: ButtonPin::new(Button::Up), down_button: ButtonPin::new(Button::Down), @@ -85,36 +122,49 @@ impl GPIOPins { } } -pub struct Pin { - gpio: GPIO, - capability: PhantomData, +/// Pins for the board's shared I2C bus. +pub struct I2CPins { + pub data: Pin<2, InputOutput>, + pub clock: Pin<3, InputOutput>, } -impl Pin { - pub(super) const fn new() -> Self { +impl I2CPins { + const fn get() -> Self { Self { - gpio: GPIO::new(BCM), - capability: PhantomData, + data: Pin::new(), + clock: Pin::new(), } } +} - pub const fn gpio(&self) -> GPIO { - self.gpio - } +/// Pins for the SPI LED chain. +pub struct SPIPins { + pub data: Pin<10, Output>, + pub clock: Pin<11, Output>, +} - pub const fn bcm_number(&self) -> u8 { - BCM +impl SPIPins { + const fn get() -> Self { + Self { + data: Pin::new(), + clock: Pin::new(), + } } } -impl Pin { - pub fn read_level(&self, reader: &mut R) -> Result { - reader.read_level(self.gpio) - } +/// The three host interfaces connected by the board hardware. +pub struct BoardPins { + pub gpio: GPIOPins, + pub i2c: I2CPins, + pub spi: SPIPins, } -impl Pin { - pub fn set_level(&self, writer: &mut W, level: Level) -> Result<(), W::Error> { - writer.write_level(self.gpio, level) +impl BoardPins { + pub const fn get() -> Self { + Self { + gpio: GPIOPins::get(), + i2c: I2CPins::get(), + spi: SPIPins::get(), + } } } diff --git a/apps/firmware/src/hardware/pins/gpio/button.rs b/apps/firmware/src/hardware/pins/gpio/button.rs deleted file mode 100644 index 9f86019d..00000000 --- a/apps/firmware/src/hardware/pins/gpio/button.rs +++ /dev/null @@ -1,59 +0,0 @@ -use super::{GPIO, Level, ReadLevel}; - -mod debounce; -mod events; -mod subscription; - -pub use events::{Button, ButtonEvent}; -pub use subscription::{ButtonSubscription, StartSubscriptionError}; - -/// A control-panel button connected directly to a GPIO input. -pub struct ButtonPin { - gpio: GPIO, - button: Button, -} - -impl ButtonPin { - pub(super) const fn new(button: Button) -> Self { - Self { - gpio: GPIO::new(BCM), - button, - } - } - - pub const fn gpio(&self) -> GPIO { - self.gpio - } - - pub const fn bcm_number(&self) -> u8 { - BCM - } - - /// Returns the physical panel label assigned to this pin. - pub const fn button(&self) -> Button { - self.button - } - - pub fn read_level(&self, reader: &mut R) -> Result { - reader.read_level(self.gpio) - } - - /// Starts polling and debouncing this button. - pub fn start_subscription( - &self, - reader: R, - events: &crate::hardware::HardwareEventBus, - ) -> Result - where - R: ReadLevel + Send + 'static, - { - subscription::start(self.gpio, self.button, reader, events) - } -} - -/// A debounced physical transition from one panel button. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ButtonAction { - Pressed, - Released, -} diff --git a/apps/firmware/src/hardware/pins/gpio/button/debounce.rs b/apps/firmware/src/hardware/pins/gpio/button/debounce.rs deleted file mode 100644 index 9308f097..00000000 --- a/apps/firmware/src/hardware/pins/gpio/button/debounce.rs +++ /dev/null @@ -1,56 +0,0 @@ -use core::time::Duration; - -use tokio::time::Instant; - -use super::ButtonAction; -use crate::hardware::pins::Level; - -#[cfg(test)] -mod tests; - -pub(super) const POLL_INTERVAL: Duration = Duration::from_millis(5); -const DEBOUNCE: Duration = Duration::from_millis(20); - -pub(super) struct Debouncer { - stable: Level, - candidate: Option<(Level, Instant)>, -} - -impl Debouncer { - pub(super) fn new(initial: Level) -> Self { - Self { - stable: initial, - candidate: None, - } - } - - pub(super) fn interrupt(&mut self) { - self.candidate = None; - } - - pub(super) fn observe(&mut self, level: Level, observed_at: Instant) -> Option { - if level == self.stable { - self.candidate = None; - return None; - } - - match self.candidate { - Some((candidate, since)) if candidate == level => { - if observed_at.duration_since(since) < DEBOUNCE { - return None; - } - } - _ => { - self.candidate = Some((level, observed_at)); - return None; - } - } - - self.stable = level; - self.candidate = None; - Some(match level { - Level::Low => ButtonAction::Pressed, - Level::High => ButtonAction::Released, - }) - } -} diff --git a/apps/firmware/src/hardware/pins/gpio/button/debounce/tests.rs b/apps/firmware/src/hardware/pins/gpio/button/debounce/tests.rs deleted file mode 100644 index 19095520..00000000 --- a/apps/firmware/src/hardware/pins/gpio/button/debounce/tests.rs +++ /dev/null @@ -1,69 +0,0 @@ -use super::*; - -#[test] -fn bounce_and_held_levels_emit_only_stable_edges() { - let start = Instant::now(); - let mut button = Debouncer::new(Level::High); - assert_eq!(button.observe(Level::Low, start), None); - assert_eq!(button.observe(Level::High, start + POLL_INTERVAL), None); - assert_eq!(button.observe(Level::Low, start + DEBOUNCE), None); - assert_eq!( - button.observe(Level::Low, start + DEBOUNCE * 2), - Some(ButtonAction::Pressed) - ); - assert_eq!(button.observe(Level::Low, start + DEBOUNCE * 3), None); - assert_eq!(button.observe(Level::High, start + DEBOUNCE * 4), None); - assert_eq!( - button.observe(Level::High, start + DEBOUNCE * 5), - Some(ButtonAction::Released) - ); -} - -#[test] -fn transition_waits_for_the_complete_debounce_period() { - let start = Instant::now(); - let mut button = Debouncer::new(Level::High); - - assert_eq!(button.observe(Level::Low, start), None); - assert_eq!( - button.observe(Level::Low, start + DEBOUNCE - POLL_INTERVAL), - None - ); - assert_eq!( - button.observe(Level::Low, start + DEBOUNCE), - Some(ButtonAction::Pressed) - ); -} - -#[test] -fn returning_to_the_stable_level_cancels_the_candidate() { - let start = Instant::now(); - let mut button = Debouncer::new(Level::High); - - assert_eq!(button.observe(Level::Low, start), None); - assert_eq!(button.observe(Level::High, start + POLL_INTERVAL), None); - assert_eq!(button.observe(Level::Low, start + DEBOUNCE), None); - assert_eq!( - button.observe(Level::Low, start + DEBOUNCE * 2), - Some(ButtonAction::Pressed) - ); -} - -#[test] -fn read_failure_restarts_debounce_without_losing_last_stable_level() { - let start = Instant::now(); - let mut button = Debouncer::new(Level::Low); - assert_eq!(button.observe(Level::High, start), None); - button.interrupt(); - assert_eq!(button.observe(Level::High, start + DEBOUNCE), None); - assert_eq!( - button.observe(Level::High, start + DEBOUNCE * 2), - Some(ButtonAction::Released) - ); -} - -#[test] -fn initial_level_is_a_baseline_not_a_synthetic_press() { - let mut button = Debouncer::new(Level::Low); - assert_eq!(button.observe(Level::Low, Instant::now()), None); -} diff --git a/apps/firmware/src/hardware/pins/gpio/button/events.rs b/apps/firmware/src/hardware/pins/gpio/button/events.rs deleted file mode 100644 index 5c4908eb..00000000 --- a/apps/firmware/src/hardware/pins/gpio/button/events.rs +++ /dev/null @@ -1,23 +0,0 @@ -/// The label printed beside a physical control-panel button. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Button { - Up, - Down, - Left, - Right, - Ok, - Reset, - Pass, - F1, - F2, - F3, - F4, - F5, -} - -/// A debounced electrical transition produced by a physical button adapter. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ButtonEvent { - Pressed(Button), - Released(Button), -} diff --git a/apps/firmware/src/hardware/pins/i2c.rs b/apps/firmware/src/hardware/pins/i2c.rs deleted file mode 100644 index eb1b8b39..00000000 --- a/apps/firmware/src/hardware/pins/i2c.rs +++ /dev/null @@ -1,16 +0,0 @@ -use super::{InputOutput, Pin}; - -/// Pins for the board's shared I2C bus. -pub struct I2CPins { - pub data: Pin<2, InputOutput>, - pub clock: Pin<3, InputOutput>, -} - -impl I2CPins { - pub(super) const fn get() -> Self { - Self { - data: Pin::new(), - clock: Pin::new(), - } - } -} diff --git a/apps/firmware/src/hardware/pins/mod.rs b/apps/firmware/src/hardware/pins/mod.rs deleted file mode 100644 index 97e76279..00000000 --- a/apps/firmware/src/hardware/pins/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -#![allow(clippy::upper_case_acronyms)] - -//! Board interfaces connected through the Raspberry Pi GPIO header. -//! -//! Hardware interface names retain their schematic spellings (`GPIO`, `I2C`, -//! and `SPI`) throughout this API. - -mod gpio; -mod i2c; -mod spi; - -pub use gpio::{ - Button, ButtonAction, ButtonEvent, ButtonPin, ButtonSubscription, GPIO, GPIOPins, InputOutput, - Level, Output, Pin, ReadLevel, Readable, StartSubscriptionError, Writable, WriteLevel, -}; -pub use i2c::I2CPins; -pub use spi::SPIPins; - -/// The only three host interfaces connected by the board hardware. -pub struct BoardPins { - pub gpio: GPIOPins, - pub i2c: I2CPins, - pub spi: SPIPins, -} - -impl BoardPins { - pub const fn get() -> Self { - Self { - gpio: GPIOPins::get(), - i2c: I2CPins::get(), - spi: SPIPins::get(), - } - } -} diff --git a/apps/firmware/src/hardware/pins/spi.rs b/apps/firmware/src/hardware/pins/spi.rs deleted file mode 100644 index 464e0110..00000000 --- a/apps/firmware/src/hardware/pins/spi.rs +++ /dev/null @@ -1,16 +0,0 @@ -use super::{Output, Pin}; - -/// Pins for the SPI LED chain. -pub struct SPIPins { - pub data: Pin<10, Output>, - pub clock: Pin<11, Output>, -} - -impl SPIPins { - pub(super) const fn get() -> Self { - Self { - data: Pin::new(), - clock: Pin::new(), - } - } -} diff --git a/apps/firmware/tests/e2e/linux_probe.rs b/apps/firmware/tests/e2e/linux_probe.rs index a34c0330..dd8bae54 100644 --- a/apps/firmware/tests/e2e/linux_probe.rs +++ b/apps/firmware/tests/e2e/linux_probe.rs @@ -12,12 +12,20 @@ use firmware::connectivity::{ }; mod probe_case; +use firmware::{ + hardware::{ + HardwareEvent, + linux_gpio::LinuxGpioReader, + pins::{BoardPins, ButtonEvent, Level, ReadLevel}, + }, + runtime::Firmware, +}; use probe_case::ProbeCase; fn main() -> Result<(), Box> { let case = match std::env::args().nth(1) { Some(name) => ProbeCase::from_arg(&name).ok_or("unknown probe name")?, - None => ProbeCase::WifiJourney, // The VM runs this binary without arguments. + None => ProbeCase::LinuxJourney, // The VM runs this binary without arguments. }; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -29,7 +37,10 @@ fn main() -> Result<(), Box> { ProbeCase::OpenNetworkWithoutRadio => open_network().await, ProbeCase::PersonalNetworkWithoutRadio => personal_network().await, ProbeCase::HotspotWithoutRadio => hotspot().await, - ProbeCase::WifiJourney => wifi_journey().await, + ProbeCase::LinuxJourney => { + wifi_journey().await; + gpio_journey().await; + } } }); println!("{}: ok", case.as_arg()); @@ -143,6 +154,53 @@ async fn wifi_journey() { println!("provisioning hotspot lifecycle passed"); } +async fn gpio_journey() { + // The existing scripted tests cover every mapping and debounce edge case. + // One real chardev transition proves the Linux reader -> runtime boundary. + let config = std::path::Path::new("/sys/kernel/config/gpio-sim/chess-panel"); + let platform = fs::read_to_string(config.join("dev_name")).unwrap(); + let chip = fs::read_to_string(config.join("board/chip_name")).unwrap(); + let chip = chip.trim(); + let device = std::path::Path::new("/dev").join(chip); + let pin = BoardPins::get().gpio.down_button; + let pull = std::path::Path::new("/sys/devices/platform") + .join(platform.trim()) + .join(chip) + .join(format!("sim_gpio{}/pull", pin.bcm_number())); + assert!(device.exists(), "missing GPIO character device: {device:?}"); + fs::write(&pull, "pull-up").unwrap(); + let mut reader = LinuxGpioReader::with_external_bias(&device); + assert_eq!(reader.read_level(pin.gpio()).unwrap(), Level::High); + let mut firmware = Firmware::start().unwrap(); + let _subscription = pin.start_subscription(reader, &firmware.events()).unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(firmware.snapshot().processed_events, 0); + + fs::write(&pull, "pull-down").unwrap(); + let pressed = tokio::time::timeout(Duration::from_secs(5), firmware.after(0)) + .await + .expect("Linux GPIO press should reach the firmware runtime") + .unwrap(); + assert_eq!(pressed.processed_events, 1); + assert_eq!( + pressed.last_event, + Some(HardwareEvent::Button(ButtonEvent::Pressed(pin.button()))) + ); + + fs::write(&pull, "pull-up").unwrap(); + let released = tokio::time::timeout(Duration::from_secs(5), firmware.after(1)) + .await + .expect("Linux GPIO release should reach the firmware runtime") + .unwrap(); + assert_eq!(released.processed_events, 2); + assert_eq!( + released.last_event, + Some(HardwareEvent::Button(ButtonEvent::Released(pin.button()))) + ); + firmware.shutdown().await.unwrap(); + println!("Linux GPIO character-device button transition passed"); +} + async fn disconnected_and_scan() { let connectivity = Connectivity::network_manager().await.unwrap(); diff --git a/apps/firmware/tests/e2e/probe_case.rs b/apps/firmware/tests/e2e/probe_case.rs index e908164c..dcb8fede 100644 --- a/apps/firmware/tests/e2e/probe_case.rs +++ b/apps/firmware/tests/e2e/probe_case.rs @@ -5,7 +5,7 @@ pub enum ProbeCase { OpenNetworkWithoutRadio, PersonalNetworkWithoutRadio, HotspotWithoutRadio, - WifiJourney, + LinuxJourney, } impl ProbeCase { @@ -15,7 +15,7 @@ impl ProbeCase { Self::OpenNetworkWithoutRadio => "open_network", Self::PersonalNetworkWithoutRadio => "personal_network", Self::HotspotWithoutRadio => "hotspot", - Self::WifiJourney => "wifi_journey", + Self::LinuxJourney => "linux_journey", } } @@ -25,7 +25,7 @@ impl ProbeCase { Self::OpenNetworkWithoutRadio, Self::PersonalNetworkWithoutRadio, Self::HotspotWithoutRadio, - Self::WifiJourney, + Self::LinuxJourney, ] .into_iter() .find(|case| case.as_arg() == arg) @@ -43,7 +43,7 @@ mod tests { ProbeCase::OpenNetworkWithoutRadio, ProbeCase::PersonalNetworkWithoutRadio, ProbeCase::HotspotWithoutRadio, - ProbeCase::WifiJourney, + ProbeCase::LinuxJourney, ] { assert_eq!( ProbeCase::from_arg(case.as_arg()).unwrap().as_arg(), diff --git a/apps/firmware/tests/e2e/wifi-vm/bootstrap.sh b/apps/firmware/tests/e2e/wifi-vm/bootstrap.sh index 88df99cc..ee69a859 100644 --- a/apps/firmware/tests/e2e/wifi-vm/bootstrap.sh +++ b/apps/firmware/tests/e2e/wifi-vm/bootstrap.sh @@ -15,6 +15,15 @@ ip link set wlan1 up dnsmasq --interface=wlan1 --bind-interfaces --port=0 \ --dhcp-range=10.11.12.10,10.11.12.30,1h +# A 32-line gpio-sim chip exposes the same offsets as the BCM-numbered buttons. +# The Rust probe drives pull-up/down via sysfs, but reads the Linux GPIO chardev. +modprobe gpio-sim +mountpoint -q /sys/kernel/config || mount -t configfs none /sys/kernel/config +sim=/sys/kernel/config/gpio-sim/chess-panel +mkdir "$sim" "$sim/board" +echo 32 > "$sim/board/num_lines" +echo 1 > "$sim/live" + /mnt/seed/firmware-probe -echo WIFI_VM_PASS +echo LINUX_VM_PASS poweroff diff --git a/apps/firmware/tests/e2e/wifi.rs b/apps/firmware/tests/e2e/wifi.rs index 94274353..7ca86c12 100644 --- a/apps/firmware/tests/e2e/wifi.rs +++ b/apps/firmware/tests/e2e/wifi.rs @@ -5,8 +5,8 @@ use std::path::Path; use super::vm::VmFixture; #[tokio::test] -async fn firmware_joins_open_and_wpa_networks_and_starts_a_hotspot_in_linux_vm() --> Result<(), Box> { +async fn firmware_uses_virtual_wifi_and_gpio_in_linux_vm() -> Result<(), Box> +{ VmFixture { image_name: "firmware-wifi-vm", dockerfile: include_str!("wifi-vm/Dockerfile"), @@ -14,7 +14,7 @@ async fn firmware_joins_open_and_wpa_networks_and_starts_a_hotspot_in_linux_vm() bootstrap_script: include_bytes!("wifi-vm/bootstrap.sh"), user_data: include_bytes!("wifi-vm/user-data"), meta_data: include_bytes!("wifi-vm/meta-data"), - success_message: "WIFI_VM_PASS", + success_message: "LINUX_VM_PASS", } .run(Path::new(env!("CARGO_BIN_EXE_firmware-linux-probe"))) .await diff --git a/apps/firmware/yocto/Cargo.lock b/apps/firmware/yocto/Cargo.lock index 1d7b08f6..b497b181 100644 --- a/apps/firmware/yocto/Cargo.lock +++ b/apps/firmware/yocto/Cargo.lock @@ -364,6 +364,7 @@ dependencies = [ "display-interface", "embedded-graphics", "embedded-hal", + "gpiocdev", "logger", "menu", "nmrs", @@ -498,6 +499,28 @@ dependencies = [ "r-efi", ] +[[package]] +name = "gpiocdev" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6009ef6766dd62a7f5933d379d357ae89eaee4e9c2455b6596f359627660a1" +dependencies = [ + "gpiocdev-uapi", + "thiserror", +] + +[[package]] +name = "gpiocdev-uapi" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb1a3ace06ef708255a33e738ec7a5e7718fe8e53bd9a39299b4f1cd4b13e9a3" +dependencies = [ + "bitflags", + "ioctl-sys", + "libc", + "thiserror", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -526,6 +549,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ioctl-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd11f3a29434026f5ff98c730b668ba74b1033637b8817940b54d040696133c" + [[package]] name = "js-sys" version = "0.3.105" diff --git a/apps/firmware/yocto/Cargo.toml b/apps/firmware/yocto/Cargo.toml index 0c02b22c..abcbca13 100644 --- a/apps/firmware/yocto/Cargo.toml +++ b/apps/firmware/yocto/Cargo.toml @@ -18,6 +18,7 @@ path = "../src/main.rs" display-interface = "0.5.0" embedded-graphics = "0.8.2" embedded-hal = "1.0.0" +gpiocdev = "0.8.0" logger = { path = "../../../crates/logger", features = ["std"] } menu = { path = "../../../crates/menu" } nmrs = "3.5.2" diff --git a/apps/firmware/yocto/meta-firmware/recipes-firmware/firmware/firmware-crates.inc b/apps/firmware/yocto/meta-firmware/recipes-firmware/firmware/firmware-crates.inc index 317d558d..ed401ee8 100644 --- a/apps/firmware/yocto/meta-firmware/recipes-firmware/firmware/firmware-crates.inc +++ b/apps/firmware/yocto/meta-firmware/recipes-firmware/firmware/firmware-crates.inc @@ -53,10 +53,13 @@ SRC_URI += " \ crate://crates.io/futures-timer/3.0.4 \ crate://crates.io/futures-util/0.3.34 \ crate://crates.io/getrandom/0.4.3 \ + crate://crates.io/gpiocdev/0.8.0 \ + crate://crates.io/gpiocdev-uapi/0.7.0 \ crate://crates.io/hashbrown/0.17.1 \ crate://crates.io/hermit-abi/0.5.3 \ crate://crates.io/hex/0.4.3 \ crate://crates.io/indexmap/2.14.2 \ + crate://crates.io/ioctl-sys/0.8.0 \ crate://crates.io/js-sys/0.3.105 \ crate://crates.io/libc/0.2.189 \ crate://crates.io/linux-raw-sys/0.12.1 \ @@ -179,10 +182,13 @@ SRC_URI[futures-task-0.3.34.sha256sum] = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef SRC_URI[futures-timer-3.0.4.sha256sum] = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" SRC_URI[futures-util-0.3.34.sha256sum] = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" SRC_URI[getrandom-0.4.3.sha256sum] = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +SRC_URI[gpiocdev-0.8.0.sha256sum] = "4a6009ef6766dd62a7f5933d379d357ae89eaee4e9c2455b6596f359627660a1" +SRC_URI[gpiocdev-uapi-0.7.0.sha256sum] = "cb1a3ace06ef708255a33e738ec7a5e7718fe8e53bd9a39299b4f1cd4b13e9a3" SRC_URI[hashbrown-0.17.1.sha256sum] = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" SRC_URI[hermit-abi-0.5.3.sha256sum] = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" SRC_URI[hex-0.4.3.sha256sum] = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" SRC_URI[indexmap-2.14.2.sha256sum] = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +SRC_URI[ioctl-sys-0.8.0.sha256sum] = "8bd11f3a29434026f5ff98c730b668ba74b1033637b8817940b54d040696133c" SRC_URI[js-sys-0.3.105.sha256sum] = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" SRC_URI[libc-0.2.189.sha256sum] = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" SRC_URI[linux-raw-sys-0.12.1.sha256sum] = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"