From 9f6d442df82310f6935f24949f649437249ce4f9 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 22 Apr 2026 23:40:37 -0400 Subject: [PATCH] feat(2-7): operational radar loop, startup screen, and systemd wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...-loop-startup-screen-and-systemd-wiring.md | 44 ++++++------ .../sprint-status.yaml | 4 +- pyproject.toml | 1 + src/planemapper/main.py | 67 ++++++++++++++++++- src/planemapper/provision.py | 9 ++- systemd/planemapper-provision.service | 1 + systemd/planemapper-radar.service | 1 + tests/test_main.py | 56 ++++++++++++++++ tests/test_scaffold.py | 13 ++-- 9 files changed, 164 insertions(+), 32 deletions(-) create mode 100644 tests/test_main.py diff --git a/_bmad-output/implementation-artifacts/2-7-operational-radar-loop-startup-screen-and-systemd-wiring.md b/_bmad-output/implementation-artifacts/2-7-operational-radar-loop-startup-screen-and-systemd-wiring.md index a8f9349..ad06916 100644 --- a/_bmad-output/implementation-artifacts/2-7-operational-radar-loop-startup-screen-and-systemd-wiring.md +++ b/_bmad-output/implementation-artifacts/2-7-operational-radar-loop-startup-screen-and-systemd-wiring.md @@ -1,6 +1,6 @@ # Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring -Status: ready-for-dev +Status: review ## Story @@ -22,10 +22,10 @@ AC5: **Given** device reboots with `provisioned: true` in config **When** `plane ## 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: +- [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() @@ -41,7 +41,7 @@ AC5: **Given** device reboots with `provisioned: true` in config **When** `plane 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)`: + - [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) @@ -68,12 +68,12 @@ AC5: **Given** device reboots with `provisioned: true` in config **When** `plane _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` + - [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` -- [ ] 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: +- [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() @@ -84,19 +84,19 @@ AC5: **Given** device reboots with `provisioned: true` in config **When** `plane 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) +- [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) -- [ ] 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 +- [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 -- [ ] 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 +- [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 diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 2228e23..dab722e 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -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 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_key: NOKEY tracking_system: file-system @@ -59,7 +59,7 @@ development_status: 2-4-altitude-colour-bands-and-aircraft-type-icons: done 2-5-per-aircraft-drawing: 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 3: Stale Data Resilience diff --git a/pyproject.toml b/pyproject.toml index c537662..8554d85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ select = ["E", "F", "I", "TID", "UP"] [tool.ruff.lint.per-file-ignores] # All non-main modules may import from provisioning freely +"src/planemapper/main.py" = ["TID251"] "src/planemapper/provision.py" = ["TID251"] "src/planemapper/provisioning/*.py" = ["TID251"] # Tests may import from provisioning to test its public API diff --git a/src/planemapper/main.py b/src/planemapper/main.py index 783c610..a752cbe 100644 --- a/src/planemapper/main.py +++ b/src/planemapper/main.py @@ -1,8 +1,73 @@ +from __future__ import annotations + 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__) +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: 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) diff --git a/src/planemapper/provision.py b/src/planemapper/provision.py index e5308cf..a6934d9 100644 --- a/src/planemapper/provision.py +++ b/src/planemapper/provision.py @@ -1,6 +1,6 @@ import logging -from planemapper.provisioning import ProvisioningError, wifi +from planemapper.provisioning import ProvisioningError, config, wifi from planemapper.provisioning.portal import app log = logging.getLogger(__name__) @@ -16,6 +16,13 @@ def _reset_to_portal_state() -> None: def main() -> None: 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 while not provisioned: try: diff --git a/systemd/planemapper-provision.service b/systemd/planemapper-provision.service index cdc5f14..092204f 100644 --- a/systemd/planemapper-provision.service +++ b/systemd/planemapper-provision.service @@ -5,6 +5,7 @@ After=network.target [Service] Type=oneshot ExecStart=/usr/local/bin/planemapper-provision +User=root StandardOutput=journal StandardError=journal RemainAfterExit=yes diff --git a/systemd/planemapper-radar.service b/systemd/planemapper-radar.service index 92a4fcd..9ff0e07 100644 --- a/systemd/planemapper-radar.service +++ b/systemd/planemapper-radar.service @@ -8,6 +8,7 @@ Type=simple ExecStart=/usr/local/bin/planemapper-radar Restart=always RestartSec=5 +User=root StandardOutput=journal StandardError=journal diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..b9a5e6e --- /dev/null +++ b/tests/test_main.py @@ -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() diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index 3ce3da5..96c1aaa 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -73,12 +73,13 @@ def test_constants_colours_complete() -> None: 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" tree = ast.parse(main_path.read_text()) for node in ast.walk(tree): - if isinstance(node, (ast.Import, ast.ImportFrom)): - if isinstance(node, ast.ImportFrom) and node.module: - assert not node.module.startswith("planemapper.provisioning"), ( - "main.py must not import from planemapper.provisioning" - ) + if isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith("planemapper.provisioning"): + assert node.module in allowed, f"main.py must not import from {node.module}"