66884270a8
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>
66 lines
3.5 KiB
Markdown
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 |
|