Files
planeMapper/_bmad-output/implementation-artifacts/2-7-operational-radar-loop-startup-screen-and-systemd-wiring.md
Matt Edholm d759f4e575 review(2-7): story 2-7 passes all ACs — mark done, epic-2 complete
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>
2026-04-22 23:42:36 -04:00

10 KiB
Raw Permalink Blame History

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

  • 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:
      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):
      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:
      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:

[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