Files
planeMapper/_bmad-output/implementation-artifacts/2-7-operational-radar-loop-startup-screen-and-systemd-wiring.md
T
2026-04-22 23:37:24 -04:00

184 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring
Status: ready-for-dev
## 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
- [ ] Task 1: Update `src/planemapper/main.py` with full implementation (AC: #1, #2, #3)
- [ ] 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`
- [ ] 1.2 Implement `_make_startup_screen() -> Image.Image` — returns a white 800×480 RGB `PIL.Image` with `"planeMapper starting..."` drawn using `ImageFont.load_default()`
- [ ] 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)
```
- [ ] 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)
```
- [ ] 1.5 Add module-level constants: `REFRESH_INTERVAL_S = 60` and `RENDER_WARN_S = 40`
- [ ] 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`
- [ ] Task 2: Update `src/planemapper/provision.py` to exit early if already provisioned (AC: #5)
- [ ] 2.1 Add import for `planemapper.provisioning.config` at the top of `provision.py` (already in TID251 per-file-ignores)
- [ ] 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
```
- [ ] Task 3: Verify/update systemd service files (AC: #4)
- [ ] 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)
- [ ] 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)
- [ ] Task 4: Write tests in `tests/test_main.py` (AC: #2, #3, #5)
- [ ] 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
- [ ] 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"`
- [ ] 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
- [ ] Task 5: Run quality gates
- [ ] 5.1 `python -m pytest tests/` — all tests pass
- [ ] 5.2 `python -m ruff check .` — zero violations
- [ ] 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 |