Files
planeMapper/_bmad-output/implementation-artifacts/4-1-gpio-button-hold-detection-and-led-feedback.md
T
Matt Edholm 66884270a8 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>
2026-04-22 23:55:42 -04:00

66 lines
3.5 KiB
Markdown

# 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 |