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 <noreply@anthropic.com>
This commit is contained in:
+70
@@ -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 |
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user