Implement GPIO button hold detection and LED feedback (story 4-1)

Replace stub ButtonHoldDetector and LEDController with real gpiozero
implementations; update tests to use MockFactory so they run without
physical GPIO hardware.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Edholm
2026-04-22 23:55:42 -04:00
parent ddde3358ef
commit 66884270a8
4 changed files with 102 additions and 5 deletions
@@ -0,0 +1,65 @@
# Story 4.1: GPIO Button Hold Detection & LED Feedback
Status: review
## Story
As a user wanting to reconfigure the device,
I want to hold the reset button for 3 seconds and receive immediate LED confirmation,
So that I know the reset was registered before anything else changes.
## Acceptance Criteria
AC1: **Given** the button is held for `RESET_HOLD_S` (3s) **When** `ButtonHoldDetector.check()` is called **Then** it returns `True`
AC2: **Given** the button is held for less than 3s **When** `ButtonHoldDetector.check()` is called **Then** it returns `False`
AC3: **Given** `ButtonHoldDetector.check()` returns `True` **When** the main loop processes the result **Then** `LEDController.on()` is called immediately
AC4: **Given** gpiozero `MockFactory` is active **When** button/LED tests run **Then** they pass without physical GPIO hardware
AC5: **Given** `check()` is called once per render cycle **When** the render loop runs **Then** the call is non-blocking
## Tasks / Subtasks
- [ ] Task 1: Implement `ButtonHoldDetector` and `LEDController` in `src/planemapper/gpio_ctrl.py` (AC: #1, #2, #3, #5)
- [ ] 1.1 Add imports: `import logging`, `from gpiozero import Button, LED`, `from planemapper.constants import RESET_HOLD_S`
- [ ] 1.2 Define `BUTTON_GPIO_PIN = 17`, `LED_GPIO_PIN = 27` as module-level constants
- [ ] 1.3 Implement `ButtonHoldDetector.__init__(self, pin=BUTTON_GPIO_PIN)`: `self._button = Button(pin, hold_time=RESET_HOLD_S)`
- [ ] 1.4 Implement `ButtonHoldDetector.check(self) -> bool`: `return self._button.is_held`
- [ ] 1.5 Implement `LEDController.__init__(self, pin=LED_GPIO_PIN)`: `self._led = LED(pin)`
- [ ] 1.6 Implement `LEDController.on(self) -> None` and `LEDController.off(self) -> None`
- [ ] Task 2: Update `tests/test_gpio_ctrl.py` (AC: #4)
- [ ] 2.1 Add `from gpiozero import Device`, `from gpiozero.pins.mock import MockFactory`
- [ ] 2.2 Set `Device.pin_factory = MockFactory()` before instantiating classes in each test or in a fixture
- [ ] 2.3 Test `ButtonHoldDetector.check()` returns `False` when button not pressed (MockFactory default)
- [ ] 2.4 Test `LEDController.on()` and `LEDController.off()` work without exception
- [ ] Task 3: Run quality gates
- [ ] 3.1 `python -m pytest tests/` — all tests pass
- [ ] 3.2 `python -m ruff check .` — zero violations
- [ ] 3.3 `python -m ruff format --check .` — no formatting issues
## Implementation Notes
### gpiozero integration
`ButtonHoldDetector` wraps `gpiozero.Button` with `hold_time=RESET_HOLD_S`. The `is_held` property returns `True` only after the button has been continuously held for `hold_time` seconds — this is non-blocking (no sleep, no polling loop), satisfying AC5.
`LEDController` wraps `gpiozero.LED`. Calls to `on()` and `off()` delegate directly to the underlying device.
### MockFactory for tests
`gpiozero.Device.pin_factory = MockFactory()` substitutes all GPIO pin access with in-process mock objects. Tests construct `ButtonHoldDetector` and `LEDController` without needing real hardware. The mock button's default state is not-held, so `check()` returns `False` unless the mock pin is explicitly driven.
### Module-level pin constants
`BUTTON_GPIO_PIN = 17` and `LED_GPIO_PIN = 27` are defined at module level in `gpio_ctrl.py`. This makes them visible and easy to change for a hardware revision without touching any logic.
### Files changed
| File | Change |
|---|---|
| `src/planemapper/gpio_ctrl.py` | Full implementation replacing stubs |
| `tests/test_gpio_ctrl.py` | Update to use MockFactory |
@@ -69,7 +69,7 @@ development_status:
epic-3-retrospective: optional epic-3-retrospective: optional
# Epic 4: Reset & Reconfiguration # Epic 4: Reset & Reconfiguration
epic-4: backlog epic-4: in-progress
4-1-gpio-button-hold-detection-and-led-feedback: backlog 4-1-gpio-button-hold-detection-and-led-feedback: review
4-2-config-wipe-setup-screen-and-return-to-provisioning: backlog 4-2-config-wipe-setup-screen-and-return-to-provisioning: backlog
epic-4-retrospective: optional epic-4-retrospective: optional
+21 -3
View File
@@ -1,11 +1,29 @@
import logging
from gpiozero import LED, Button
from planemapper.constants import RESET_HOLD_S
log = logging.getLogger(__name__)
BUTTON_GPIO_PIN = 17
LED_GPIO_PIN = 27
class ButtonHoldDetector: class ButtonHoldDetector:
def __init__(self, pin: int = BUTTON_GPIO_PIN) -> None:
self._button = Button(pin, hold_time=RESET_HOLD_S)
def check(self) -> bool: def check(self) -> bool:
return False return self._button.is_held
class LEDController: class LEDController:
def __init__(self, pin: int = LED_GPIO_PIN) -> None:
self._led = LED(pin)
def on(self) -> None: def on(self) -> None:
pass self._led.on()
def off(self) -> None: def off(self) -> None:
pass self._led.off()
+14
View File
@@ -1,12 +1,26 @@
import pytest
from gpiozero import Device
from gpiozero.pins.mock import MockFactory
from planemapper.gpio_ctrl import ButtonHoldDetector, LEDController from planemapper.gpio_ctrl import ButtonHoldDetector, LEDController
@pytest.fixture(autouse=True)
def mock_gpio() -> None:
Device.pin_factory = MockFactory()
def test_button_hold_detector_returns_bool() -> None: def test_button_hold_detector_returns_bool() -> None:
detector = ButtonHoldDetector() detector = ButtonHoldDetector()
result = detector.check() result = detector.check()
assert isinstance(result, bool) assert isinstance(result, bool)
def test_button_hold_detector_not_held_by_default() -> None:
detector = ButtonHoldDetector()
assert detector.check() is False
def test_led_controller_on_off_no_exception() -> None: def test_led_controller_on_off_no_exception() -> None:
led = LEDController() led = LEDController()
led.on() led.on()