From 7f34a7b9268d093f81aa53ba75b7bdc19a68307a Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 22 Apr 2026 23:59:22 -0400 Subject: [PATCH] feat(reset): config wipe, setup screen, and re-exec into provisioning (story 4-2) Add _handle_reset() and _make_setup_screen() to main.py; integrate ButtonHoldDetector and LEDController into the radar loop; LED lights immediately on hold, config is wiped, setup screen shown, then os.execvp hands off to planemapper-provision. Wipe failures log ERROR and abort without exec. Completes epic-4. Co-Authored-By: Claude Sonnet 4.6 --- ...setup-screen-and-return-to-provisioning.md | 70 +++++++++++++++++++ .../implementation-artifacts/deferred-work.md | 14 ++++ .../sprint-status.yaml | 6 +- src/planemapper/main.py | 33 +++++++++ tests/test_reset.py | 48 +++++++++++++ 5 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/4-2-config-wipe-setup-screen-and-return-to-provisioning.md create mode 100644 tests/test_reset.py diff --git a/_bmad-output/implementation-artifacts/4-2-config-wipe-setup-screen-and-return-to-provisioning.md b/_bmad-output/implementation-artifacts/4-2-config-wipe-setup-screen-and-return-to-provisioning.md new file mode 100644 index 0000000..33433cd --- /dev/null +++ b/_bmad-output/implementation-artifacts/4-2-config-wipe-setup-screen-and-return-to-provisioning.md @@ -0,0 +1,70 @@ +# Story 4.2: Config Wipe, Setup Screen & Return to Provisioning + +Status: done + +## Story + +As a user who has triggered a reset, +I want the device to wipe its configuration, show a setup screen on the e-ink display, and restart into the provisioning flow, +So that I can re-configure the device from scratch. + +## Acceptance Criteria + +AC1: **Given** `ButtonHoldDetector.check()` returns `True` **When** the reset handler runs **Then** `config.wipe()` is called + +AC2: **Given** config has been wiped **When** the reset handler continues **Then** `display.show(setup_screen_image)` is called + +AC3: **Given** setup screen is shown **When** reset handler completes **Then** `os.execvp('planemapper-provision', ['planemapper-provision'])` replaces the current process + +AC4: **Given** `config.wipe()` raises an unexpected error **When** the reset handler encounters it **Then** ERROR is logged and `os.execvp` is NOT called + +## Tasks / Subtasks + +- [ ] Task 1: Implement `_handle_reset()` and `_make_setup_screen()` in `src/planemapper/main.py` (AC: #1–#4) + - [ ] 1.1 Add `import os` to imports + - [ ] 1.2 Add `from planemapper.provisioning.config import wipe as wipe_config` + - [ ] 1.3 Add `from planemapper.gpio_ctrl import ButtonHoldDetector, LEDController` + - [ ] 1.4 Implement `_make_setup_screen() -> Image.Image` + - [ ] 1.5 Implement `_handle_reset(display, led) -> None` + - [ ] 1.6 Update `main()` to instantiate `ButtonHoldDetector` and `LEDController` + - [ ] 1.7 Add reset check at top of loop + +- [ ] Task 2: Write tests in `tests/test_reset.py` (AC: #1–#4) + +- [ ] 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 + +### `_handle_reset` flow + +1. `led.on()` — immediate LED feedback before any wipe attempt +2. `wipe_config()` — deletes config file; on failure, log ERROR, call `led.off()`, return +3. `display.show(_make_setup_screen())` — render "Resetting..." screen +4. `os.execvp("planemapper-provision", ["planemapper-provision"])` — replaces process (never returns) + +### `_make_setup_screen()` + +Returns a white 800×480 PIL Image with "Resetting..." text. Same pattern as `_make_startup_screen()`. + +### main() loop update + +`ButtonHoldDetector` and `LEDController` are instantiated once in `main()` before the loop. Inside the loop, `button.check()` is called once per cycle (non-blocking). If `True`, `_handle_reset` is called (which never returns on success). + +### TID251 per-file-ignore + +`main.py` already has `TID251` in per-file-ignores in `pyproject.toml`, so importing from `planemapper.provisioning.config` is allowed. + +### Patching in tests + +- `wipe_config` is patched at `planemapper.main.wipe_config` +- `os.execvp` is patched at `planemapper.main.os.execvp` + +### Files changed + +| File | Change | +|---|---| +| `src/planemapper/main.py` | Add `import os`, new imports, `_make_setup_screen()`, `_handle_reset()`, update `main()` | +| `tests/test_reset.py` | New file with 3 tests | diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index f1ccd2d..6883ac5 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -214,3 +214,17 @@ Description: `BUTTON_GPIO_PIN = 17` and `LED_GPIO_PIN = 27` are module-level con Story: `4-1-gpio-button-hold-detection-and-led-feedback` Category: Infrastructure/environment Description: `ButtonHoldDetector.__init__` constructs a `gpiozero.Button` immediately. The radar main loop must construct `ButtonHoldDetector` at startup (not at import time), or the application will fail if GPIO is unavailable when the module is imported. This is currently safe because `gpio_ctrl.py` is not imported at module level in `main.py`, but any future reorganisation that imports it at the top of a module that runs on non-GPIO hardware will raise a `BadPinFactory` error unless a `MockFactory` is active. + +--- + +## Story 4.2: Config Wipe, Setup Screen & Return to Provisioning + +### [4-2] ButtonHoldDetector and LEDController raise at startup on Pi without GPIO +Story: `4-2-config-wipe-setup-screen-and-return-to-provisioning` +Category: Infrastructure/environment +Description: `ButtonHoldDetector` and `LEDController` are now instantiated unconditionally in `main()` before the loop begins. On a Pi without GPIO hardware (or without a `MockFactory` active), both constructors will raise a `BadPinFactory` error at startup, crashing the radar service before it can display anything. Same concern as story 4-1. Future hardening: wrap construction in a try/except and fall back to no-op stubs, or defer construction until first use. + +### [4-2] os.execvp replaces process — no cleanup before re-exec +Story: `4-2-config-wipe-setup-screen-and-return-to-provisioning` +Category: Technical debt +Description: `os.execvp` replaces the current process image immediately. Any cleanup that would normally happen at shutdown — flushing log handlers, closing the SPI connection to the e-ink display, releasing GPIO pins — is not performed. Acceptable for MVP: the SPI and GPIO resources will be re-acquired by the provisioning process, and the OS reclaims file descriptors. A future improvement could flush logs and call `display.close()` (if such a method exists) before exec. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index ab5e9bc..5f4b183 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-22 -last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 done, 2-6 done, 2-7 done, epic-2 done, epic-3 done, 3-1 done, 3-2 done +last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 done, 2-6 done, 2-7 done, epic-2 done, epic-3 done, 3-1 done, 3-2 done, 4-1 done, 4-2 done, epic-4 done project: planeMapper project_key: NOKEY tracking_system: file-system @@ -69,7 +69,7 @@ development_status: epic-3-retrospective: optional # Epic 4: Reset & Reconfiguration - epic-4: in-progress + epic-4: done 4-1-gpio-button-hold-detection-and-led-feedback: done - 4-2-config-wipe-setup-screen-and-return-to-provisioning: backlog + 4-2-config-wipe-setup-screen-and-return-to-provisioning: done epic-4-retrospective: optional diff --git a/src/planemapper/main.py b/src/planemapper/main.py index e1937f3..e2e0aa3 100644 --- a/src/planemapper/main.py +++ b/src/planemapper/main.py @@ -2,6 +2,7 @@ from __future__ import annotations import dataclasses import logging +import os import time import requests @@ -15,8 +16,10 @@ from planemapper.constants import ( ) from planemapper.display import DisplayInterface, WaveshareDisplay from planemapper.fetcher import HttpFetcher +from planemapper.gpio_ctrl import ButtonHoldDetector, LEDController from planemapper.models import Aircraft from planemapper.provisioning.config import read as read_config +from planemapper.provisioning.config import wipe as wipe_config from planemapper.renderer.basemap import load as load_basemap from planemapper.renderer.projection import MapBounds from planemapper.renderer.renderer import Renderer @@ -37,6 +40,32 @@ def _make_startup_screen() -> Image.Image: return image +def _make_setup_screen() -> Image.Image: + image = Image.new("RGB", (DISPLAY_WIDTH, DISPLAY_HEIGHT), color=(255, 255, 255)) + draw = ImageDraw.Draw(image) + font = ImageFont.load_default() + draw.text( + (DISPLAY_WIDTH // 2 - 60, DISPLAY_HEIGHT // 2), + "Resetting...", + fill=(0, 0, 0), + font=font, + ) + return image + + +def _handle_reset(display: DisplayInterface, led: LEDController) -> None: + led.on() + try: + wipe_config() + except Exception: + log.error("config wipe failed — aborting reset", exc_info=True) + led.off() + return + setup_screen = _make_setup_screen() + display.show(setup_screen) + os.execvp("planemapper-provision", ["planemapper-provision"]) + + def _run_one_cycle( renderer: Renderer, fetcher: HttpFetcher, @@ -89,9 +118,13 @@ def main() -> None: fetcher = HttpFetcher() renderer = Renderer(base_map, bounds) display = WaveshareDisplay() + button = ButtonHoldDetector() + led = LEDController() startup = _make_startup_screen() display.show(startup) last: list[Aircraft] = [] while True: + if button.check(): + _handle_reset(display, led) last = _run_one_cycle(renderer, fetcher, display, last) time.sleep(REFRESH_INTERVAL_S) diff --git a/tests/test_reset.py b/tests/test_reset.py new file mode 100644 index 0000000..fa742c2 --- /dev/null +++ b/tests/test_reset.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from gpiozero import Device +from gpiozero.pins.mock import MockFactory +from PIL import Image + +from planemapper.display import NullDisplay +from planemapper.gpio_ctrl import LEDController +from planemapper.main import _handle_reset + + +@pytest.fixture(autouse=True) +def mock_gpio(): + Device.pin_factory = MockFactory() + + +def test_reset_wipes_config_and_execvp(): + display = NullDisplay() + led = LEDController() + with patch("planemapper.main.wipe_config") as mock_wipe: + with patch("planemapper.main.os.execvp") as mock_exec: + _handle_reset(display, led) + mock_wipe.assert_called_once() + mock_exec.assert_called_once_with("planemapper-provision", ["planemapper-provision"]) + + +def test_reset_shows_setup_screen(): + display = MagicMock() + led = LEDController() + with patch("planemapper.main.wipe_config"): + with patch("planemapper.main.os.execvp"): + _handle_reset(display, led) + display.show.assert_called_once() + img = display.show.call_args[0][0] + assert isinstance(img, Image.Image) + assert img.size == (800, 480) + + +def test_reset_does_not_execvp_on_wipe_failure(): + display = NullDisplay() + led = LEDController() + with patch("planemapper.main.wipe_config", side_effect=PermissionError("no perms")): + with patch("planemapper.main.os.execvp") as mock_exec: + _handle_reset(display, led) + mock_exec.assert_not_called()