feat(2-7): operational radar loop, startup screen, and systemd wiring

Implements story 2-7: full main.py with _make_startup_screen(),
_run_one_cycle() with per-phase timing and slow-render warning, and
main() connecting config → bounds → fetcher → renderer → display.
Adds provision.py early-exit when already provisioned. Adds User=root
to both systemd service files. Adds tests/test_main.py (3 new tests,
99 total). Updates scaffold test to allow provisioning.config import
from main.py. All quality gates pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Edholm
2026-04-22 23:40:37 -04:00
parent cbe87d36f9
commit 9f6d442df8
9 changed files with 164 additions and 32 deletions
@@ -1,6 +1,6 @@
# Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring # Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring
Status: ready-for-dev Status: review
## Story ## Story
@@ -22,10 +22,10 @@ AC5: **Given** device reboots with `provisioned: true` in config **When** `plane
## Tasks / Subtasks ## Tasks / Subtasks
- [ ] Task 1: Update `src/planemapper/main.py` with full implementation (AC: #1, #2, #3) - [x] 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` - [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`
- [ ] 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.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: - [x] 1.3 Implement `_run_one_cycle(renderer, fetcher, display) -> None` with per-phase timing and slow-render warning:
```python ```python
def _run_one_cycle(renderer, fetcher, display): def _run_one_cycle(renderer, fetcher, display):
t0 = time.monotonic() t0 = time.monotonic()
@@ -41,7 +41,7 @@ AC5: **Given** device reboots with `provisioned: true` in config **When** `plane
if total > RENDER_WARN_S: if total > RENDER_WARN_S:
log.warning("render slow: %.1fs > %ds threshold", 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)`: - [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 ```python
def main() -> None: def main() -> None:
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -68,12 +68,12 @@ AC5: **Given** device reboots with `provisioned: true` in config **When** `plane
_run_one_cycle(renderer, fetcher, display) _run_one_cycle(renderer, fetcher, display)
time.sleep(REFRESH_INTERVAL_S) time.sleep(REFRESH_INTERVAL_S)
``` ```
- [ ] 1.5 Add module-level constants: `REFRESH_INTERVAL_S = 60` and `RENDER_WARN_S = 40` - [x] 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` - [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`
- [ ] Task 2: Update `src/planemapper/provision.py` to exit early if already provisioned (AC: #5) - [x] 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) - [x] 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: - [x] 2.2 At the top of `main()` in `provision.py`, before the provisioning loop, add:
```python ```python
try: try:
cfg = config.read() cfg = config.read()
@@ -84,19 +84,19 @@ AC5: **Given** device reboots with `provisioned: true` in config **When** `plane
pass # no config — proceed to portal pass # no config — proceed to portal
``` ```
- [ ] Task 3: Verify/update systemd service files (AC: #4) - [x] 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) - [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)
- [ ] 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] 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) - [x] 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 - [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
- [ ] 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.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 - [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
- [ ] Task 5: Run quality gates - [x] Task 5: Run quality gates
- [ ] 5.1 `python -m pytest tests/` — all tests pass - [x] 5.1 `python -m pytest tests/` — all tests pass
- [ ] 5.2 `python -m ruff check .` — zero violations - [x] 5.2 `python -m ruff check .` — zero violations
- [ ] 5.3 `python -m ruff format --check .` — no formatting issues - [x] 5.3 `python -m ruff format --check .` — no formatting issues
## Implementation Notes ## Implementation Notes
@@ -35,7 +35,7 @@
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
generated: 2026-04-22 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 ready-for-dev, epic-2 in-progress 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 review, epic-2 in-progress
project: planeMapper project: planeMapper
project_key: NOKEY project_key: NOKEY
tracking_system: file-system tracking_system: file-system
@@ -59,7 +59,7 @@ development_status:
2-4-altitude-colour-bands-and-aircraft-type-icons: done 2-4-altitude-colour-bands-and-aircraft-type-icons: done
2-5-per-aircraft-drawing: done 2-5-per-aircraft-drawing: done
2-6-stateful-renderer-and-display-interface: done 2-6-stateful-renderer-and-display-interface: done
2-7-operational-radar-loop-startup-screen-and-systemd-wiring: ready-for-dev 2-7-operational-radar-loop-startup-screen-and-systemd-wiring: review
epic-2-retrospective: optional epic-2-retrospective: optional
# Epic 3: Stale Data Resilience # Epic 3: Stale Data Resilience
+1
View File
@@ -36,6 +36,7 @@ select = ["E", "F", "I", "TID", "UP"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
# All non-main modules may import from provisioning freely # All non-main modules may import from provisioning freely
"src/planemapper/main.py" = ["TID251"]
"src/planemapper/provision.py" = ["TID251"] "src/planemapper/provision.py" = ["TID251"]
"src/planemapper/provisioning/*.py" = ["TID251"] "src/planemapper/provisioning/*.py" = ["TID251"]
# Tests may import from provisioning to test its public API # Tests may import from provisioning to test its public API
+66 -1
View File
@@ -1,8 +1,73 @@
from __future__ import annotations
import logging import logging
import time
from PIL import Image, ImageDraw, ImageFont
from planemapper.constants import (
DISPLAY_HEIGHT,
DISPLAY_WIDTH,
REFRESH_INTERVAL_S,
RENDER_WARN_S,
)
from planemapper.display import DisplayInterface, WaveshareDisplay
from planemapper.fetcher import HttpFetcher
from planemapper.provisioning.config import read as read_config
from planemapper.renderer.basemap import load as load_basemap
from planemapper.renderer.projection import MapBounds
from planemapper.renderer.renderer import Renderer
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _make_startup_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),
"planeMapper starting...",
fill=(0, 0, 0),
font=font,
)
return image
def _run_one_cycle(renderer: Renderer, fetcher: HttpFetcher, display: DisplayInterface) -> None:
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)
def main() -> None: def main() -> None:
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
log.info("not implemented") cfg = read_config()
bounds = MapBounds(
home_lat=cfg["home_lat"],
home_lon=cfg["home_lon"],
radius_nm=cfg["coverage_radius_nm"],
)
base_map = load_basemap()
fetcher = HttpFetcher()
renderer = Renderer(base_map, bounds)
display = WaveshareDisplay()
startup = _make_startup_screen()
display.show(startup)
while True:
_run_one_cycle(renderer, fetcher, display)
time.sleep(REFRESH_INTERVAL_S)
+8 -1
View File
@@ -1,6 +1,6 @@
import logging import logging
from planemapper.provisioning import ProvisioningError, wifi from planemapper.provisioning import ProvisioningError, config, wifi
from planemapper.provisioning.portal import app from planemapper.provisioning.portal import app
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -16,6 +16,13 @@ def _reset_to_portal_state() -> None:
def main() -> None: def main() -> None:
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
try:
cfg = config.read()
if cfg.get("provisioned"):
log.info("already provisioned — exiting")
return
except FileNotFoundError:
pass # no config — proceed to portal
provisioned = False provisioned = False
while not provisioned: while not provisioned:
try: try:
+1
View File
@@ -5,6 +5,7 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/bin/planemapper-provision ExecStart=/usr/local/bin/planemapper-provision
User=root
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
RemainAfterExit=yes RemainAfterExit=yes
+1
View File
@@ -8,6 +8,7 @@ Type=simple
ExecStart=/usr/local/bin/planemapper-radar ExecStart=/usr/local/bin/planemapper-radar
Restart=always Restart=always
RestartSec=5 RestartSec=5
User=root
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import logging
import pathlib
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image
from planemapper.display import NullDisplay
from planemapper.fetcher import FileFixtureFetcher
from planemapper.main import _run_one_cycle
from planemapper.renderer.projection import MapBounds
from planemapper.renderer.renderer import Renderer
FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures"
@pytest.fixture
def renderer(monkeypatch: pytest.MonkeyPatch) -> Renderer:
monkeypatch.setattr(
"planemapper.renderer.airspace.AIRSPACE_PATH",
FIXTURE_DIR / "airspace_sample.geojson",
)
base_map = Image.new("RGB", (800, 480), color=(255, 255, 255))
bounds = MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
return Renderer(base_map, bounds)
def test_run_one_cycle_calls_display_show(renderer: Renderer) -> None:
fetcher = FileFixtureFetcher(FIXTURE_DIR / "aircraft_sample.json")
display = NullDisplay()
display_mock = MagicMock(wraps=display)
_run_one_cycle(renderer, fetcher, display_mock)
display_mock.show.assert_called_once()
def test_run_one_cycle_logs_warning_when_slow(
renderer: Renderer, caplog: pytest.LogCaptureFixture
) -> None:
fetcher = FileFixtureFetcher(FIXTURE_DIR / "aircraft_sample.json")
display = NullDisplay()
# Simulate 43s total: t0=0, t1=1, t2=2, t3=43
with patch("planemapper.main.time.monotonic", side_effect=[0.0, 1.0, 2.0, 43.0]):
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)
def test_provision_main_exits_if_already_provisioned() -> None:
with patch("planemapper.provisioning.config.read", return_value={"provisioned": True}):
with patch("planemapper.provisioning.wifi.start_ap") as mock_ap:
from planemapper.provision import main as provision_main
provision_main()
mock_ap.assert_not_called()
+6 -5
View File
@@ -73,12 +73,13 @@ def test_constants_colours_complete() -> None:
assert constants.FETCH_TIMEOUT_S == 5 assert constants.FETCH_TIMEOUT_S == 5
def test_main_does_not_import_provisioning() -> None: def test_main_only_imports_config_from_provisioning() -> None:
"""main.py may import planemapper.provisioning.config (to read stored config)
but must not import other provisioning sub-modules (portal, wifi, tiles, etc.)."""
allowed = {"planemapper.provisioning.config"}
main_path = REPO_ROOT / "src" / "planemapper" / "main.py" main_path = REPO_ROOT / "src" / "planemapper" / "main.py"
tree = ast.parse(main_path.read_text()) tree = ast.parse(main_path.read_text())
for node in ast.walk(tree): for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
if isinstance(node, ast.ImportFrom) and node.module: if isinstance(node, ast.ImportFrom) and node.module:
assert not node.module.startswith("planemapper.provisioning"), ( if node.module.startswith("planemapper.provisioning"):
"main.py must not import from planemapper.provisioning" assert node.module in allowed, f"main.py must not import from {node.module}"
)