Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 KiB
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.pywith full implementation (AC: #1, #2, #3)- 1.1 Add
"src/planemapper/main.py"to TID251 per-file-ignores inpyproject.toml(alongside the existingprovision.pyentry) so thatmain.pymay import fromplanemapper.provisioning.config - 1.2 Implement
_make_startup_screen() -> Image.Image— returns a white 800×480 RGBPIL.Imagewith"planeMapper starting..."drawn usingImageFont.load_default() - 1.3 Implement
_run_one_cycle(renderer, fetcher, display) -> Nonewith per-phase timing and slow-render warning: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 → buildMapBounds+ load base map → initHttpFetcher,Renderer,WaveshareDisplay→ show startup screen → enterwhile Trueloop calling_run_one_cyclethentime.sleep(REFRESH_INTERVAL_S):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 = 60andRENDER_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
- 1.1 Add
-
Task 2: Update
src/planemapper/provision.pyto exit early if already provisioned (AC: #5)- 2.1 Add import for
planemapper.provisioning.configat the top ofprovision.py(already in TID251 per-file-ignores) - 2.2 At the top of
main()inprovision.py, before the provisioning loop, add:try: cfg = config.read() if cfg.get("provisioned"): log.info("already provisioned — exiting") return except FileNotFoundError: pass # no config — proceed to portal
- 2.1 Add import for
-
Task 3: Verify/update systemd service files (AC: #4)
- 3.1 Verify
systemd/planemapper-radar.servicehasRestart=alwaysandAfter=planemapper-provision.service— these already exist in the file; confirmUser=rootis present (currently absent — add it) - 3.2 Verify
systemd/planemapper-provision.servicehasType=oneshotandRemainAfterExit=yes— these already exist in the file; confirmUser=rootis present (currently absent — add it)
- 3.1 Verify
-
Task 4: Write tests in
tests/test_main.py(AC: #2, #3, #5)- 4.1 Test AC2: call
_run_one_cyclewithNullDisplay, aFileFixtureFetcher(orunittest.mock.MagicMockreturning a list), and aRendererbacked by a fake 800×480 white base map; assertdisplay.show()was called exactly once - 4.2 Test AC3: mock
time.monotonicto return values simulating a total elapsed time > 40s (e.g. return sequence[0, 1, 2, 43]); assertlog.warningis called (usepytestcaplogfixture at WARNING level); assert the log message contains"render slow" - 4.3 Test AC5: patch
planemapper.provisioning.config.readto return{"provisioned": True}and patchplanemapper.provisioning.wifi.start_ap; callplanemapper.provision.main(); assertwifi.start_apwas NOT called
- 4.1 Test AC2: call
-
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
- 5.1
Implementation Notes
TID251 per-file-ignores
pyproject.toml currently lists:
[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)
{
"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
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 |