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:
+65
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user