diff --git a/_bmad-output/implementation-artifacts/4-1-gpio-button-hold-detection-and-led-feedback.md b/_bmad-output/implementation-artifacts/4-1-gpio-button-hold-detection-and-led-feedback.md new file mode 100644 index 0000000..bfe453c --- /dev/null +++ b/_bmad-output/implementation-artifacts/4-1-gpio-button-hold-detection-and-led-feedback.md @@ -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 | diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 016838b..414f47f 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -69,7 +69,7 @@ development_status: epic-3-retrospective: optional # Epic 4: Reset & Reconfiguration - epic-4: backlog - 4-1-gpio-button-hold-detection-and-led-feedback: backlog + epic-4: in-progress + 4-1-gpio-button-hold-detection-and-led-feedback: review 4-2-config-wipe-setup-screen-and-return-to-provisioning: backlog epic-4-retrospective: optional diff --git a/src/planemapper/gpio_ctrl.py b/src/planemapper/gpio_ctrl.py index 8f3520a..94e7322 100644 --- a/src/planemapper/gpio_ctrl.py +++ b/src/planemapper/gpio_ctrl.py @@ -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: + def __init__(self, pin: int = BUTTON_GPIO_PIN) -> None: + self._button = Button(pin, hold_time=RESET_HOLD_S) + def check(self) -> bool: - return False + return self._button.is_held class LEDController: + def __init__(self, pin: int = LED_GPIO_PIN) -> None: + self._led = LED(pin) + def on(self) -> None: - pass + self._led.on() def off(self) -> None: - pass + self._led.off() diff --git a/tests/test_gpio_ctrl.py b/tests/test_gpio_ctrl.py index feda462..b780cdd 100644 --- a/tests/test_gpio_ctrl.py +++ b/tests/test_gpio_ctrl.py @@ -1,12 +1,26 @@ +import pytest +from gpiozero import Device +from gpiozero.pins.mock import MockFactory + 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: detector = ButtonHoldDetector() result = detector.check() 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: led = LEDController() led.on()