d759f4e575
All 10 review criteria pass with no code changes required. 99 tests pass, ruff clean. Added 4 deferred items (WaveshareDisplay stub, HAT crash-on-boot, dumb fixed sleep, startup text position). Epic-2 marked done as all 7 stories are now in done state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
184 lines
10 KiB
Markdown
184 lines
10 KiB
Markdown
# Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring
|
||
|
||
Status: done
|
||
|
||
## Story
|
||
|
||
As a device operator,
|
||
I want the device to show a startup screen during boot, then enter a 60-second radar refresh loop that runs indefinitely and resumes automatically after power cycling,
|
||
So that the display is always current with zero manual intervention.
|
||
|
||
## Acceptance Criteria
|
||
|
||
AC1: **Given** the device boots with a valid config **When** `planemapper-radar` starts **Then** a startup screen is displayed before the first radar render begins
|
||
|
||
AC2: **Given** the radar loop is running **When** each 60-second cycle completes **Then** `fetcher.fetch()` → `renderer.render()` → `display.show()` executes in sequence **And** render phase timings are logged at INFO level
|
||
|
||
AC3: **Given** total render time exceeds `RENDER_WARN_S` (40s) **When** the cycle completes **Then** a WARNING is logged
|
||
|
||
AC4: **Given** `planemapper-radar.service` **When** the service file is inspected **Then** it has `Restart=always` and `After=planemapper-provision.service`
|
||
|
||
AC5: **Given** device reboots with `provisioned: true` in config **When** `planemapper-provision.service` starts **Then** it detects the flag and exits immediately without running the portal
|
||
|
||
## Tasks / Subtasks
|
||
|
||
- [x] Task 1: Update `src/planemapper/main.py` with full implementation (AC: #1, #2, #3)
|
||
- [x] 1.1 Add `"src/planemapper/main.py"` to TID251 per-file-ignores in `pyproject.toml` (alongside the existing `provision.py` entry) so that `main.py` may import from `planemapper.provisioning.config`
|
||
- [x] 1.2 Implement `_make_startup_screen() -> Image.Image` — returns a white 800×480 RGB `PIL.Image` with `"planeMapper starting..."` drawn using `ImageFont.load_default()`
|
||
- [x] 1.3 Implement `_run_one_cycle(renderer, fetcher, display) -> None` with per-phase timing and slow-render warning:
|
||
```python
|
||
def _run_one_cycle(renderer, fetcher, display):
|
||
t0 = time.monotonic()
|
||
aircraft_list = fetcher.fetch()
|
||
t1 = time.monotonic()
|
||
image = renderer.render(aircraft_list)
|
||
t2 = time.monotonic()
|
||
display.show(image)
|
||
t3 = time.monotonic()
|
||
total = t3 - t0
|
||
log.info("cycle: fetch=%.1fs render=%.1fs spi=%.1fs total=%.1fs",
|
||
t1 - t0, t2 - t1, t3 - t2, total)
|
||
if total > RENDER_WARN_S:
|
||
log.warning("render slow: %.1fs > %ds threshold", total, RENDER_WARN_S)
|
||
```
|
||
- [x] 1.4 Implement `main()`: read config → build `MapBounds` + load base map → init `HttpFetcher`, `Renderer`, `WaveshareDisplay` → show startup screen → enter `while True` loop calling `_run_one_cycle` then `time.sleep(REFRESH_INTERVAL_S)`:
|
||
```python
|
||
def main() -> None:
|
||
logging.basicConfig(level=logging.INFO)
|
||
from planemapper.provisioning.config import read as read_config
|
||
cfg = read_config() # raises FileNotFoundError if not provisioned
|
||
|
||
bounds = MapBounds(
|
||
home_lat=cfg["home_lat"],
|
||
home_lon=cfg["home_lon"],
|
||
radius_nm=cfg["coverage_radius_nm"],
|
||
)
|
||
base_map = basemap.load() # raises FileNotFoundError if missing
|
||
|
||
fetcher = HttpFetcher()
|
||
renderer = Renderer(base_map, bounds)
|
||
display = WaveshareDisplay()
|
||
|
||
# Startup screen
|
||
startup = _make_startup_screen()
|
||
display.show(startup)
|
||
|
||
# Radar loop
|
||
while True:
|
||
_run_one_cycle(renderer, fetcher, display)
|
||
time.sleep(REFRESH_INTERVAL_S)
|
||
```
|
||
- [x] 1.5 Add module-level constants: `REFRESH_INTERVAL_S = 60` and `RENDER_WARN_S = 40`
|
||
- [x] 1.6 Add required imports: `import logging`, `import time`, `from PIL import Image, ImageDraw, ImageFont`, `from planemapper.fetcher import HttpFetcher`, `from planemapper.renderer.projection import MapBounds`, `from planemapper.renderer.renderer import Renderer`, `from planemapper.renderer import basemap`, `from planemapper.display import WaveshareDisplay`
|
||
|
||
- [x] Task 2: Update `src/planemapper/provision.py` to exit early if already provisioned (AC: #5)
|
||
- [x] 2.1 Add import for `planemapper.provisioning.config` at the top of `provision.py` (already in TID251 per-file-ignores)
|
||
- [x] 2.2 At the top of `main()` in `provision.py`, before the provisioning loop, add:
|
||
```python
|
||
try:
|
||
cfg = config.read()
|
||
if cfg.get("provisioned"):
|
||
log.info("already provisioned — exiting")
|
||
return
|
||
except FileNotFoundError:
|
||
pass # no config — proceed to portal
|
||
```
|
||
|
||
- [x] Task 3: Verify/update systemd service files (AC: #4)
|
||
- [x] 3.1 Verify `systemd/planemapper-radar.service` has `Restart=always` and `After=planemapper-provision.service` — these already exist in the file; confirm `User=root` is present (currently absent — add it)
|
||
- [x] 3.2 Verify `systemd/planemapper-provision.service` has `Type=oneshot` and `RemainAfterExit=yes` — these already exist in the file; confirm `User=root` is present (currently absent — add it)
|
||
|
||
- [x] Task 4: Write tests in `tests/test_main.py` (AC: #2, #3, #5)
|
||
- [x] 4.1 Test AC2: call `_run_one_cycle` with `NullDisplay`, a `FileFixtureFetcher` (or `unittest.mock.MagicMock` returning a list), and a `Renderer` backed by a fake 800×480 white base map; assert `display.show()` was called exactly once
|
||
- [x] 4.2 Test AC3: mock `time.monotonic` to return values simulating a total elapsed time > 40s (e.g. return sequence `[0, 1, 2, 43]`); assert `log.warning` is called (use `pytest` `caplog` fixture at WARNING level); assert the log message contains `"render slow"`
|
||
- [x] 4.3 Test AC5: patch `planemapper.provisioning.config.read` to return `{"provisioned": True}` and patch `planemapper.provisioning.wifi.start_ap`; call `planemapper.provision.main()`; assert `wifi.start_ap` was NOT called
|
||
|
||
- [x] Task 5: Run quality gates
|
||
- [x] 5.1 `python -m pytest tests/` — all tests pass
|
||
- [x] 5.2 `python -m ruff check .` — zero violations
|
||
- [x] 5.3 `python -m ruff format --check .` — no formatting issues
|
||
|
||
## Implementation Notes
|
||
|
||
### TID251 per-file-ignores
|
||
|
||
`pyproject.toml` currently lists:
|
||
```toml
|
||
[tool.ruff.lint.per-file-ignores]
|
||
"src/planemapper/provision.py" = ["TID251"]
|
||
"src/planemapper/provisioning/*.py" = ["TID251"]
|
||
"tests/provisioning/*.py" = ["TID251"]
|
||
"tests/conftest.py" = ["TID251"]
|
||
```
|
||
|
||
Add `"src/planemapper/main.py" = ["TID251"]` to this block. The banned-api rule (`TID251`) blocks any import from `planemapper.provisioning.*` in files not listed here. `main.py` must be added because it imports `planemapper.provisioning.config.read` to load the stored configuration.
|
||
|
||
### `_make_startup_screen()`
|
||
|
||
Returns a new `Image.new("RGB", (800, 480), "white")`. Draw `"planeMapper starting..."` at a central position using `ImageDraw.Draw(img)` and `ImageFont.load_default()`. Exact pixel position is not prescribed — somewhere near centre is sufficient.
|
||
|
||
### `_run_one_cycle()` timing
|
||
|
||
Uses `time.monotonic()` for wall-clock elapsed measurement (not affected by NTP adjustments during boot). Four timestamps: `t0` before fetch, `t1` after fetch, `t2` after render, `t3` after display. The INFO log line reports each phase independently and a `total`. The WARNING fires when `total > RENDER_WARN_S` (strict greater-than, not >=).
|
||
|
||
### Config schema (from story 1.2)
|
||
|
||
```json
|
||
{
|
||
"home_lat": float,
|
||
"home_lon": float,
|
||
"coverage_radius_nm": int,
|
||
"wifi_ssid": str,
|
||
"wifi_password": str,
|
||
"provisioned": bool
|
||
}
|
||
```
|
||
|
||
`read_config()` raises `FileNotFoundError` if the config file does not exist. `main()` lets this propagate — systemd `Restart=always` will retry on failure.
|
||
|
||
### `provision.py` early-exit pattern
|
||
|
||
The early-exit check must be at the very top of `main()`, before `wifi.start_ap()` is called. The `config` module is already accessible since `provision.py` is in the TID251 per-file-ignores. Import as `from planemapper.provisioning import config` (adding to the existing import line or adding a separate line).
|
||
|
||
### Systemd service files — current state
|
||
|
||
Both service files already exist at `systemd/planemapper-radar.service` and `systemd/planemapper-provision.service`. The radar service already has `Restart=always` and `After=planemapper-provision.service`. The provision service already has `Type=oneshot` and `RemainAfterExit=yes`. The only gap vs. the story spec is the absence of `User=root` in both files — add it under the `[Service]` section.
|
||
|
||
### `WaveshareDisplay` in `main()`
|
||
|
||
`WaveshareDisplay.show()` currently raises `NotImplementedError` (stub from story 2.6). When this story is implemented, `main()` will call `display.show(startup)` on startup — this will raise in a test context. Tests should therefore test `_run_one_cycle` directly with a `NullDisplay`, not by calling `main()` end-to-end.
|
||
|
||
### Test structure for AC3
|
||
|
||
```python
|
||
def test_run_one_cycle_warns_when_slow(caplog):
|
||
import unittest.mock as mock
|
||
from planemapper.main import _run_one_cycle
|
||
from planemapper.display import NullDisplay
|
||
|
||
fetcher = mock.MagicMock()
|
||
fetcher.fetch.return_value = []
|
||
renderer = mock.MagicMock()
|
||
renderer.render.return_value = Image.new("RGB", (800, 480), "white")
|
||
display = NullDisplay()
|
||
|
||
# Simulate t0=0, t1=1, t2=2, t3=43 → total=43s > RENDER_WARN_S
|
||
monotonic_values = iter([0.0, 1.0, 2.0, 43.0])
|
||
with mock.patch("planemapper.main.time.monotonic", side_effect=monotonic_values):
|
||
with caplog.at_level(logging.WARNING, logger="planemapper.main"):
|
||
_run_one_cycle(renderer, fetcher, display)
|
||
|
||
assert any("render slow" in r.message for r in caplog.records)
|
||
```
|
||
|
||
### Existing files affected
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| `src/planemapper/main.py` | Replace stub `main()` with full implementation; add `_run_one_cycle()` and `_make_startup_screen()` |
|
||
| `src/planemapper/provision.py` | Add early-exit check at top of `main()` for `provisioned: true` |
|
||
| `pyproject.toml` | Add `"src/planemapper/main.py" = ["TID251"]` to per-file-ignores |
|
||
| `systemd/planemapper-radar.service` | Add `User=root` under `[Service]` |
|
||
| `systemd/planemapper-provision.service` | Add `User=root` under `[Service]` |
|
||
| `tests/test_main.py` | New file: AC2, AC3, AC5 tests |
|