Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/firmware/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 20 additions & 12 deletions apps/firmware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Original file line number Diff line number Diff line change
@@ -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<const BCM: u8> {
gpio: GPIO,
button: Button,
}

impl<const BCM: u8> ButtonPin<BCM> {
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<R: ReadLevel>(&self, reader: &mut R) -> Result<Level, R::Error> {
reader.read_level(self.gpio)
}

/// Starts polling and debouncing this button.
pub fn start_subscription<R>(
&self,
reader: R,
events: &HardwareEventBus,
) -> Result<ButtonSubscription, StartSubscriptionError>
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 {
Expand Down Expand Up @@ -53,27 +145,6 @@ impl fmt::Display for StartSubscriptionError {

impl StdError for StartSubscriptionError {}

pub(super) fn start<R>(
gpio: GPIO,
button: Button,
reader: R,
events: &HardwareEventBus,
) -> Result<ButtonSubscription, StartSubscriptionError>
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<R>(
gpio: GPIO,
button: Button,
Expand Down
126 changes: 126 additions & 0 deletions apps/firmware/src/hardware/debounce.rs
Original file line number Diff line number Diff line change
@@ -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<ButtonAction> {
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);
}
}
Loading
Loading