From b2afa7fb4be42673cd0844627b2290ab5611cad8 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 22 Apr 2026 22:20:13 -0400 Subject: [PATCH] Add sprint-status.yaml and promote planning artifacts to sprint branch Initialises implementation-artifacts/ with sprint-status.yaml covering all 14 stories across 4 epics. Promotes architecture.md and epics.md from untracked state. Sprint 1 branch is now ready for autopilot execution. Co-Authored-By: Claude Sonnet 4.6 --- .../sprint-status.yaml | 75 ++ .../planning-artifacts/architecture.md | 756 ++++++++++++++++++ _bmad-output/planning-artifacts/epics.md | 609 ++++++++++++++ 3 files changed, 1440 insertions(+) create mode 100644 _bmad-output/implementation-artifacts/sprint-status.yaml create mode 100644 _bmad-output/planning-artifacts/architecture.md create mode 100644 _bmad-output/planning-artifacts/epics.md diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml new file mode 100644 index 0000000..93eb2ad --- /dev/null +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -0,0 +1,75 @@ +# generated: 2026-04-22 +# last_updated: 2026-04-22 +# project: planeMapper +# project_key: NOKEY +# tracking_system: file-system +# story_location: _bmad-output/implementation-artifacts + +# STATUS DEFINITIONS: +# ================== +# Epic Status: +# - backlog: Epic not yet started +# - in-progress: Epic actively being worked on +# - done: All stories in epic completed +# +# Epic Status Transitions: +# - backlog → in-progress: Automatically when first story is created (via create-story) +# - in-progress → done: Manually when all stories reach 'done' status +# +# Story Status: +# - backlog: Story only exists in epic file +# - ready-for-dev: Story file created in stories folder +# - in-progress: Developer actively working on implementation +# - review: Ready for code review (via Dev's code-review workflow) +# - done: Story completed +# +# Retrospective Status: +# - optional: Can be completed but not required +# - done: Retrospective has been completed +# +# WORKFLOW NOTES: +# =============== +# - Epic transitions to 'in-progress' automatically when first story is created +# - Stories can be worked in parallel if team capacity allows +# - SM typically creates next story after previous one is 'done' to incorporate learnings +# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) + +generated: 2026-04-22 +last_updated: 2026-04-22 +project: planeMapper +project_key: NOKEY +tracking_system: file-system +story_location: _bmad-output/implementation-artifacts + +development_status: + # Epic 1: Device Setup & Provisioning + epic-1: backlog + 1-1-project-scaffold-and-verified-entry-points: backlog + 1-2-configuration-read-write-wipe: backlog + 1-3-wifi-hotspot-and-captive-portal-form: backlog + 1-4-location-resolution-icao-and-address: backlog + 1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill: backlog + epic-1-retrospective: optional + + # Epic 2: Live Radar Display + epic-2: backlog + 2-1-aircraft-data-model-and-fetcher: backlog + 2-2-coordinate-projection-and-base-map-loading: backlog + 2-3-home-marker-and-airspace-outlines: backlog + 2-4-altitude-colour-bands-and-aircraft-type-icons: backlog + 2-5-per-aircraft-drawing: backlog + 2-6-stateful-renderer-and-display-interface: backlog + 2-7-operational-radar-loop-startup-screen-and-systemd-wiring: backlog + epic-2-retrospective: optional + + # Epic 3: Stale Data Resilience + epic-3: backlog + 3-1-stale-state-detection-and-dimmed-display: backlog + 3-2-automatic-recovery-on-fresh-decode: backlog + epic-3-retrospective: optional + + # Epic 4: Reset & Reconfiguration + epic-4: backlog + 4-1-gpio-button-hold-detection-and-led-feedback: backlog + 4-2-config-wipe-setup-screen-and-return-to-provisioning: backlog + epic-4-retrospective: optional diff --git a/_bmad-output/planning-artifacts/architecture.md b/_bmad-output/planning-artifacts/architecture.md new file mode 100644 index 0000000..b8b6f03 --- /dev/null +++ b/_bmad-output/planning-artifacts/architecture.md @@ -0,0 +1,756 @@ +--- +stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8] +lastStep: 8 +status: 'complete' +completedAt: '2026-04-22' +inputDocuments: ['prd.md'] +workflowType: 'architecture' +project_name: 'planeMapper' +user_name: 'Matt.edholm' +date: '2026-04-22' +--- + +# Architecture Decision Document + +_This document builds collaboratively through step-by-step discovery. Sections are appended as we work through each architectural decision together._ + +## Project Context Analysis + +### Requirements Overview + +**Functional Requirements:** +33 FRs across 6 functional areas: +- Device Setup & Provisioning (FR1–FR11): captive portal, location resolution (ICAO/address), tile download, cache validation, WiFi radio kill +- Reset & Recovery (FR12–FR15): GPIO button hold, LED feedback, config wipe, setup screen +- Map Display (FR16–FR19): OSM base map, home marker, OpenAIP airspace outlines +- Aircraft Display (FR20–FR26): dump1090 fetch, heading arrow, callsign/altitude label, altitude colour bands, type icons, 5-dot trail, MLAT distinction +- Stale Data Handling (FR27–FR29): decode gap detection, stale visual indicator, recovery +- Refresh Loop & Boot (FR30–FR33): 60s cycle, indefinite loop, power-cycle resume, startup screen + +**Non-Functional Requirements:** +- Performance: Full radar render ≤45s on Pi Zero 2W; base map layer pre-composited and cached in memory; dump1090 fetch timeout 5s; SPI transfer only after render complete +- Reliability: 72+ hours continuous operation; recovery within 5min of unclean power loss; dump1090 failure must not crash refresh loop +- Storage: Tile cache ≤2GB on 16GB SD card; validated at provisioning before WiFi kill +- Integration: dump1090 (local JSON), Nominatim (provisioning only), OurAirports (bundled), OpenAIP (cached at provisioning) +- Security: WiFi off in operational state; no external calls in operational mode; plaintext config on SD acceptable for single-user personal device + +**Scale & Complexity:** + +- Primary domain: IoT/Embedded Python +- Complexity level: Medium +- Estimated architectural components: ~6 subsystems + +### Technical Constraints & Dependencies + +- Pi Zero 2W: quad-core Cortex-A53 @ 1GHz, 512MB RAM — strict render budget (45s) +- Single USB port — RTL-SDR via OTG adapter; no other USB peripherals +- Waveshare 7.3" 6-colour e-ink HAT — SPI interface, 800×480, full-panel refresh only +- 16GB SD card — OS + software + tile cache must fit within 2GB tile budget +- dump1090 JSON feed is best-effort — callsign, category, altitude may be absent +- Permanently offline post-provisioning — all runtime dependencies must be pre-cached + +### Cross-Cutting Concerns Identified + +- **Offline-first:** Every runtime dependency must be pre-resolved and locally available +- **Graceful degradation:** Missing ADS-B fields, dump1090 failure, and stale data handled without crash or blank display at every layer +- **State isolation:** Provisioning and Operational modes are architecturally distinct; shared code should be minimal and explicit +- **Hardware resource budget:** Memory and CPU constraints affect render pipeline design, caching strategy, and tile format choices +- **GPIO/render loop concurrency:** Button hold detection and LED feedback must be non-blocking alongside the 60s render cycle + +## Starter Template Evaluation + +### Primary Technology Domain + +IoT/Embedded Python — no formal scaffold generator. Baseline established here. + +### Selected Foundation: src/ layout, pip, pytest + +**Rationale:** `src/` layout prevents import shadowing and supports `pip install -e .` for development. pip + requirements.txt is the correct deployment tool on Pi Zero 2W — no lock-file resolution overhead on-device. gpiozero chosen over RPi.GPIO for its MockFactory support, enabling off-hardware GPIO testing. + +**Runtime:** +- Python 3.11 (Raspberry Pi OS Bookworm default) +- Pure Python, no compilation step — git pull on Pi is the deployment path + +**Dependencies (current versions):** +- Pillow 12.2.0 — image composition and rendering +- gpiozero 2.0.1 — GPIO button/LED; MockFactory for off-hardware testing +- Flask 3.1.3 — captive portal HTTP server (provisioning only) +- requests 2.33.1 — dump1090 JSON fetch, Nominatim geocoding (provisioning only) + +**Testing:** +- pytest 9.0.3 +- gpiozero MockFactory for GPIO boundary tests +- `DisplayInterface` protocol (ABC) — real `WaveshareDisplay` + `NullDisplay` for testing +- `FetcherInterface` protocol — real HTTP fetcher + `FileFixtureFetcher` from JSON fixture +- Stateful `Renderer` owns tile composite cache and trail history — enables isolated unit tests + +**Linting/Formatting:** +- ruff 0.15.11 — single-tool replacement for flake8 + black + isort + +**Project Scaffold:** +``` +planeMapper/ +├── src/ +│ └── planemapper/ +│ ├── __init__.py +│ ├── main.py # entry point — operational radar loop +│ ├── provision.py # entry point — captive portal + provisioning +│ ├── provisioning/ # portal, geocoding, tile download, WiFi kill +│ ├── renderer/ # stateful Renderer: tile composite + trail history +│ ├── fetcher.py # FetcherInterface + HTTP impl + FileFixtureFetcher +│ ├── gpio_ctrl.py # button hold detection + LED via gpiozero +│ └── display.py # DisplayInterface + WaveshareDisplay + NullDisplay +├── tests/ +├── pyproject.toml +└── requirements.txt +``` + +**Key structural decisions:** +- Two process entry points: `planemapper-provision` and `planemapper-radar` — provisioning and operational code never share a runtime context +- `Renderer` is long-lived across the 60s loop — owns tile composite (cached in memory) and trail history `dict[str, deque[Position]]`; state is lost on restart (acceptable) +- Render pipeline is phase-instrumented: tile retrieval, overlay render, SPI transfer logged individually; warn at 40s total, alert at 50s +- Systemd `Restart=always` — no state persistence across restarts; tile files on disk are the only durable operational state + +**Service structure:** +- Two systemd units: `planemapper-provision.service` (runs once at first boot / post-reset, exits on completion) and `planemapper-radar.service` (perpetual, `After=` provision) + +## Core Architectural Decisions + +### Decision Priority Analysis + +**Critical Decisions (Block Implementation):** +- Map background strategy: pre-composited PNG at provisioning +- Config file format and location +- Stale data definition and visual treatment +- Captive portal technology stack + +**Important Decisions (Shape Architecture):** +- Airspace data format and caching strategy +- Logging destination +- Render pipeline instrumentation thresholds + +**Deferred Decisions (Post-MVP):** +- SD card image build automation (manual flash acceptable for MVP) +- OSM tile zoom level tuning (implementation detail, tuned during development) + +--- + +### Data Architecture + +**Map Background** +- Strategy: pre-composited single `background.png` (800×480) generated during provisioning; loaded once into Renderer memory at radar startup +- Tile download source: tile.openstreetmap.org (single bulk download at provisioning, acceptable for personal device use) +- Zoom level: determined at provisioning time from coverage radius; baked into background.png; not stored separately in config +- Rationale: eliminates all tile I/O from the operational render loop; background is fixed for a given home location and radius + +**Config File** +- Format: JSON (Python stdlib, zero extra deps) +- Path: `/etc/planemapper/config.json` +- Contents: home lat/lon, coverage radius (nm), WiFi SSID/password, provisioning state flag +- Accessible to both `planemapper-provision` and `planemapper-radar` services +- On reset: config file wiped by provision service before returning to portal state + +**Airspace Data** +- Format: GeoJSON (OpenAIP API, downloaded during provisioning) +- Path: `/etc/planemapper/airspace.geojson` +- Rendered as circular outlines only (MVP); colour fills deferred to Phase 2 +- No runtime network dependency — purely cached local data + +**Trail History** +- Storage: in-memory only — `dict[str, deque[Position]]` inside Renderer, max 5 entries per ICAO hex +- Persistence: none — lost on restart (acceptable; cosmetic data only) + +--- + +### Authentication & Security + +All decisions established by PRD: +- WiFi radio killed via `rfkill block wifi` after successful provisioning +- Captive portal is open, local-only, and short-lived — no auth required +- Config stored plaintext on SD card — acceptable for single-user personal device +- No external network calls in operational state — network attack surface is zero + +--- + +### Captive Portal Stack + +- **hostapd** — manages Wi-Fi AP mode (`planeMapper-setup` SSID) +- **dnsmasq** — DHCP server + DNS resolver (resolves all queries to Pi IP, triggering captive portal detection on phones) +- **Flask 3.1.3** — serves setup UI, handles form submission, orchestrates provisioning sequence +- Portal flow: AP up → user connects → dnsmasq redirects DNS → Flask intercepts HTTP probe → portal page served → user submits → Flask joins home WiFi, downloads tiles, validates, kills WiFi radio + +--- + +### Stale Data Handling + +- **Threshold:** 1 missed fetch cycle (60 seconds) = stale state +- **Trigger:** dump1090 HTTP fetch returns error, times out (>5s), or returns empty aircraft list when previously non-empty +- **Visual indicator:** stale aircraft rendered as outlines only (no fill) — effectively dimmed; last known positions retained on display +- **Recovery:** next successful fetch restores normal filled rendering automatically +- **Unambiguous parity:** slow render (>60s) treated identically to decode gap — same stale path, no separate handling + +--- + +### Infrastructure & Deployment + +**Systemd Units** +- `planemapper-provision.service` — runs at first boot or post-reset; exits cleanly on completion; Type=oneshot +- `planemapper-radar.service` — perpetual operational loop; `After=planemapper-provision.service`; `Restart=always` + +**Logging** +- Destination: stdout → systemd journal (journald captures automatically) +- Access: `journalctl -u planemapper-radar -f` +- No log rotation config needed — journald handles retention + +**Render Pipeline Instrumentation** +- Phase timing logged each cycle: tile load, aircraft overlay, SPI transfer +- Warn threshold: total render > 40s +- Alert threshold: total render > 50s +- Stale path triggered if render exceeds 60s cycle boundary + +**Deployment** +- Git pull on Pi — no build step required (pure Python) +- `pip install -e .` for dev; `pip install .` for production install +- SD card reflash is the update path for OS-level changes + +## Implementation Patterns & Consistency Rules + +### Critical Conflict Points Identified + +7 areas where AI agents could make different choices without explicit rules. + +--- + +### Data Type Patterns + +**Aircraft Data — `@dataclass` with optional fields:** +```python +@dataclass +class Aircraft: + icao: str + lat: float + lon: float + heading: float = 0.0 + altitude_ft: int = 0 + callsign: str = "" + category: str = "" + is_mlat: bool = False + is_stale: bool = False +``` +- All ADS-B optional fields default to a safe sentinel value +- Stale flag carried on the dataclass, not inferred at render time +- All internal code works with `Aircraft` instances, never raw dicts +- Fetcher converts dump1090 JSON → `Aircraft` at the boundary; nothing downstream touches raw JSON + +**Position trail:** +```python +from collections import deque +trails: dict[str, deque[tuple[float, float]]] = {} # icao → deque[(lat, lon)] +``` +Max 5 entries per aircraft, oldest entry at index 0. + +--- + +### Coordinate Patterns + +**Convention: `(lat, lon)` throughout all internal code.** + +- All `Aircraft` fields, all internal function signatures, all pixel projection calls use `(lat, lon)` order +- GeoJSON parsing (airspace data) explicitly reverses at the parse boundary: + `lat, lon = feature["geometry"]["coordinates"][1], feature["geometry"]["coordinates"][0]` +- The projection function in `renderer/` is the single location where `(lat, lon)` → `(x, y)` pixel conversion happens; nothing else does projection +- Anti-pattern: never pass `(lon, lat)` to any internal function + +--- + +### Units Patterns + +**Altitude: feet throughout — preserve dump1090 native units.** + +- Altitude band thresholds defined in feet in `constants.py` +- No metres conversion anywhere in the codebase +- `altitude_ft: int` field name makes units explicit + +--- + +### Interface Patterns + +**Hardware boundaries use `typing.Protocol`:** +```python +from typing import Protocol + +class DisplayInterface(Protocol): + def show(self, image: Image.Image) -> None: ... + +class FetcherInterface(Protocol): + def fetch(self) -> list[Aircraft]: ... +``` +- No explicit inheritance required — `NullDisplay`, `WaveshareDisplay`, `FileFixtureFetcher`, and `HttpFetcher` simply implement the method signatures +- Protocols live in their respective module files (`display.py`, `fetcher.py`) +- All production code typed against the Protocol, never the concrete class + +--- + +### Constants Patterns + +**Single `src/planemapper/constants.py` — all project-wide fixed values live here:** +```python +from pathlib import Path + +# Display geometry +DISPLAY_WIDTH = 800 +DISPLAY_HEIGHT = 480 + +# Timing +REFRESH_INTERVAL_S = 60 +FETCH_TIMEOUT_S = 5 +RENDER_WARN_S = 40 +RENDER_ALERT_S = 50 +STALE_CYCLES = 1 +RESET_HOLD_S = 3 + +# Altitude band upper bounds (feet) — index maps to ALTITUDE_COLOURS +ALTITUDE_BANDS_FT = [1500, 5000, 10000, 20000, 35000, 99999] + +# E-ink 6-colour palette (Waveshare Spectra 6: black, white, red, yellow, blue, green) +# Each tuple is an RGB value as used by Pillow +COLOUR_BLACK = (0, 0, 0) +COLOUR_WHITE = (255, 255, 255) +COLOUR_RED = (255, 0, 0) +COLOUR_YELLOW = (255, 255, 0) +COLOUR_BLUE = (0, 0, 255) +COLOUR_GREEN = (0, 255, 0) + +# Altitude band → display colour (index aligns with ALTITUDE_BANDS_FT) +ALTITUDE_COLOURS = [ + COLOUR_GREEN, # surface – 1,500ft + COLOUR_BLUE, # 1,500 – 5,000ft + COLOUR_YELLOW, # 5,000 – 10,000ft + COLOUR_RED, # 10,000 – 20,000ft + COLOUR_BLACK, # 20,000 – 35,000ft + COLOUR_WHITE, # 35,000ft+ +] + +# UI colours +COLOUR_STALE_OUTLINE = COLOUR_BLACK # outline-only colour for stale aircraft +COLOUR_HOME_MARKER = COLOUR_RED +COLOUR_AIRSPACE = COLOUR_BLUE +COLOUR_TRAIL = COLOUR_BLACK + +# Trail +TRAIL_MAX_DOTS = 5 +TRAIL_DOT_SIZE_MAX = 6 # px, most recent dot +TRAIL_DOT_SIZE_MIN = 2 # px, oldest dot + +# Paths +CONFIG_PATH = Path("/etc/planemapper/config.json") +AIRSPACE_PATH = Path("/etc/planemapper/airspace.geojson") +BACKGROUND_PATH = Path("/etc/planemapper/background.png") +``` +- No module hardcodes a value that appears in `constants.py` — colours, sizes, + paths, timing, and thresholds all live here +- Anti-pattern: `(255, 0, 0)` inline anywhere; `if altitude > 10000` outside + constants logic; `time.sleep(60)` with a literal + +--- + +### Type Annotation Patterns + +**All function signatures annotated, all dataclass fields typed:** +```python +# Correct +def project(lat: float, lon: float, bounds: MapBounds) -> tuple[int, int]: ... + +# Anti-pattern +def project(lat, lon, bounds): ... +``` +- ruff enforces annotation presence +- Return types always specified +- `Optional[X]` used where None is a valid return + +--- + +### Logging Patterns + +**Levels:** + +| Level | When | +|---|---| +| `DEBUG` | Per-aircraft render decisions, individual fetch field parsing | +| `INFO` | Each render cycle start/complete with phase timings | +| `WARNING` | Render > 40s, stale state entered or exited | +| `ERROR` | Fetch failure, SPI transfer failure, required file not found | + +**Format:** stdlib `logging` module, no custom formatter — journald adds timestamps and service context automatically. + +```python +import logging +log = logging.getLogger(__name__) +# Usage: log.info("render complete: tile=%.1fs overlay=%.1fs spi=%.1fs", t1, t2, t3) +``` + +Anti-pattern: `print()` anywhere in production code; `log.error()` for expected conditions (stale data is WARNING, not ERROR). + +--- + +### Error Handling Patterns + +**Render loop must not crash — single try/except at the loop boundary:** +```python +while True: + try: + _run_one_cycle(renderer, fetcher, display) + except Exception: + log.error("cycle failed", exc_info=True) + # stale path — renderer retains last good frame + time.sleep(REFRESH_INTERVAL_S) +``` +- Internal functions raise exceptions normally; the loop catches all +- Fetcher raises on failure; main loop catches and triggers stale path +- No bare `except:` anywhere except the top-level loop boundary + +--- + +### All AI Agents MUST: + +1. Use `Aircraft` dataclass — never pass raw dicts beyond the fetch boundary +2. Use `(lat, lon)` order — reverse GeoJSON at parse time only +3. Import all magic numbers from `constants.py` — no inline literals for thresholds, paths, or timing values +4. Annotate all function signatures +5. Log at the correct level per the table above +6. Use `typing.Protocol` for hardware interfaces — never type against concrete classes +7. Raise exceptions from internal functions; catch only at the render loop boundary + +## Project Structure & Boundaries + +### Complete Project Directory Structure + +``` +planeMapper/ +├── pyproject.toml # Package metadata + entry points + ruff config +├── requirements.txt # Pinned runtime deps for pip install on Pi +├── requirements-dev.txt # pytest, ruff, gpiozero[mock] +├── .gitignore +├── systemd/ +│ ├── planemapper-provision.service # Type=oneshot; runs at first boot / post-reset +│ └── planemapper-radar.service # Restart=always; After=planemapper-provision +├── data/ +│ └── airports.csv # Bundled OurAirports DB for ICAO lookup (FR4) +├── src/ +│ └── planemapper/ +│ ├── __init__.py +│ ├── constants.py # All magic numbers, paths, thresholds +│ ├── models.py # Aircraft dataclass only — cross-boundary types +│ ├── main.py # Entry: operational radar loop (FR30–FR33) +│ ├── provision.py # Entry: provisioning loop with ProvisioningError +│ │ # recovery — never imports from renderer/ +│ ├── fetcher.py # FetcherInterface Protocol + HttpFetcher +│ │ # + FileFixtureFetcher (FR20, FR27–FR29) +│ ├── gpio_ctrl.py # ButtonHoldDetector + LEDController via +│ │ # gpiozero (FR12–FR13) +│ ├── display.py # DisplayInterface Protocol + WaveshareDisplay +│ │ # + NullDisplay (FR30, FR33) +│ ├── provisioning/ +│ │ ├── __init__.py # ProvisioningError exception definition +│ │ ├── portal.py # Flask app + routes + form handling (FR1–FR2, +│ │ │ # FR6–FR8, FR11) +│ │ ├── location.py # ICAO lookup (OurAirports) + Nominatim +│ │ │ # geocoding (FR3–FR5) +│ │ ├── tiles.py # OSM tile download + background.png +│ │ │ # composition + cache validation (FR9, FR9a) +│ │ ├── airspace.py # OpenAIP GeoJSON download + cache (FR19 data) +│ │ ├── wifi.py # hostapd/dnsmasq/rfkill — raises +│ │ │ # ProvisioningError on subprocess failure +│ │ └── config.py # Config read/write/wipe — single module +│ │ # touching /etc/planemapper/config.json +│ └── renderer/ +│ ├── __init__.py +│ ├── renderer.py # Stateful Renderer: owns PIL composite + +│ │ # trail history dict (FR21–FR29) +│ ├── projection.py # (lat,lon) → (x,y) pixel + MapBounds dataclass +│ ├── basemap.py # background.png load + memory hold (FR16–FR17) +│ ├── aircraft.py # Per-aircraft draw: arrow, label, trail, +│ │ # stale outline (FR21–FR26, FR28) +│ ├── airspace.py # Airspace GeoJSON → outline draw (FR19) +│ ├── colours.py # altitude_ft → display colour (FR23) +│ └── icons.py # ADS-B category + callsign → icon type (FR24–FR24a) +└── tests/ + ├── conftest.py # Shared fixtures: MockFactory, NullDisplay, + │ # FileFixtureFetcher, sample_config (patches + │ # CONFIG_PATH to tmp_path — no /etc/ dependency) + ├── fixtures/ + │ ├── aircraft_sample.json # Fixture library: happy path, missing callsign, + │ │ # missing altitude, MLAT flag, empty list, + │ │ # altitude band boundary values + │ └── airspace_sample.geojson # OpenAIP-format fixture + ├── test_fetcher.py # HttpFetcher timeout, stale trigger, field parsing + ├── test_models.py # Aircraft defaults, optional field handling + ├── test_projection.py # Projection correctness, boundary cases + ├── test_colours.py # Altitude band thresholds and edge values + ├── test_icons.py # Type classification: category, callsign, altitude fallback + ├── test_renderer.py # Trail accumulation, stale flag, outline render + ├── test_pipeline.py # Smoke: FileFixtureFetcher → Renderer → NullDisplay + │ # one full cycle end-to-end + ├── test_gpio_ctrl.py # Button hold timing, LED state via MockFactory + └── provisioning/ + ├── test_location.py # ICAO lookup, Nominatim response parsing + ├── test_tiles.py # Tile compositing, cache validation logic + ├── test_config.py # Config read/write/wipe cycle (uses sample_config) + └── test_provision_loop.py # ProvisioningError → reset_to_portal_state() +``` + +### Entry Points (pyproject.toml) + +```toml +[project.scripts] +planemapper-radar = "planemapper.main:main" +planemapper-provision = "planemapper.provision:main" +``` + +Systemd units invoke these console scripts directly. No shell wrappers. + +--- + +### Architectural Boundaries + +**Fetch boundary — `fetcher.py`** +Converts dump1090 JSON → `list[Aircraft]`. Raises on timeout/error. Nothing beyond touches raw JSON. + +**Render boundary — `renderer/renderer.py`** +Accepts `list[Aircraft]`, returns `PIL.Image`. Owns trail history and stale state. Nothing outside `renderer/` calls Pillow draw primitives. + +**Display boundary — `display.py`** +Accepts `PIL.Image`, drives SPI. `NullDisplay` logs + no-ops for tests. + +**GPIO boundary — `gpio_ctrl.py`** +`ButtonHoldDetector.check() -> bool` — non-blocking, polled once per cycle. + +**Config boundary — `provisioning/config.py`** +Single module reading/writing `/etc/planemapper/config.json`. Tests patch `CONFIG_PATH` to `tmp_path` via `conftest.py`. + +**Provisioning boundary — `provision.py`** +Never imported by `main.py`. Separate process entry point. Loop structure: +```python +while not provisioned: + try: + run_provisioning_sequence() + provisioned = True + except ProvisioningError as e: + log.error("provisioning failed: %s", e) + reset_to_portal_state() +``` + +**wifi.py subprocess boundary** +Every `rfkill`/`hostapd`/`dnsmasq` call checks return code explicitly. Raises `ProvisioningError` on failure. No silent partial state. + +--- + +### Requirements → Structure Mapping + +| FR Group | FRs | Primary Location | +|---|---|---| +| Device Setup & Provisioning | FR1–FR11 | `provisioning/portal.py`, `location.py`, `tiles.py`, `wifi.py` | +| Reset & Recovery | FR12–FR15 | `gpio_ctrl.py`, `provisioning/config.py`, `provision.py` | +| Map Display | FR16–FR19 | `renderer/basemap.py`, `renderer/airspace.py`, `renderer/projection.py` | +| Aircraft Display | FR20–FR26 | `fetcher.py`, `renderer/aircraft.py`, `renderer/colours.py`, `renderer/icons.py` | +| Stale Data Handling | FR27–FR29 | `fetcher.py` (detection), `renderer/renderer.py` (stale flag + outline) | +| Refresh Loop & Boot | FR30–FR33 | `main.py`, `display.py` | + +--- + +### Data Flow + +``` +main.py (60s loop) + │ + ├─→ fetcher.fetch() → list[Aircraft] (raises on failure → stale path) + ├─→ renderer.render(aircraft) → PIL.Image (holds composite + trail in memory) + ├─→ display.show(image) (SPI; NullDisplay in tests) + └─→ gpio_ctrl.check() → bool (reset? → exec provision.py) + +provision.py (one-shot loop) + │ + ├─→ wifi.start_ap() → hostapd + dnsmasq (raises ProvisioningError on fail) + ├─→ portal.run() → Flask blocks until user submits + ├─→ location.resolve() → (lat, lon) + ├─→ wifi.join_home() → connects home WiFi + ├─→ tiles.download() → background.png composited + validated + ├─→ airspace.download() → airspace.geojson cached + ├─→ config.write() → /etc/planemapper/config.json + └─→ wifi.kill() → rfkill block wifi (raises ProvisioningError on fail) +``` + +--- + +### External Integration Points + +| Integration | When | Module | +|---|---|---| +| dump1090 JSON (`localhost:8080`) | Every 60s in operational mode | `fetcher.py` | +| Nominatim geocoding API | Once during provisioning | `provisioning/location.py` | +| tile.openstreetmap.org | Once during provisioning | `provisioning/tiles.py` | +| OpenAIP API | Once during provisioning | `provisioning/airspace.py` | +| OurAirports CSV | Bundled; read at provisioning | `provisioning/location.py` | + +## Architecture Validation Results + +### Coherence Validation ✅ + +**Decision Compatibility:** +All packages compatible on Pi Zero 2W / Raspberry Pi OS Bookworm: Python 3.11, +Pillow 12.2.0, gpiozero 2.0.1, Flask 3.1.3, requests 2.33.1. All runtime +dependencies are pip-installable with no build toolchain. `typing.Protocol`, +`dataclasses`, and `json` are Python 3.11 stdlib — zero dependency risk. + +**Pattern Consistency:** +`Aircraft` dataclass flows cleanly through fetch → render → display pipeline. +`(lat, lon)` convention documented with single explicit reversal point at GeoJSON +parse boundary. `constants.py` is the single source for all thresholds, paths, +colours, sizes, and timing values. `typing.Protocol` hardware interfaces align +with test double strategy. Logging levels, error handling boundary, and type +annotation rules are internally consistent. + +**Structure Alignment:** +Two entry points map directly to two systemd units. `provisioning/` import +boundary enforced by ruff rule. `renderer/` owns all Pillow draw calls. `tests/` +mirrors `src/` structure with full fixture library. `conftest.py` patches +`CONFIG_PATH` to `tmp_path` — no `/etc/` dependency in CI. + +--- + +### Requirements Coverage Validation ✅ + +**Functional Requirements (33/33 covered):** + +| FR Group | Coverage | +|---|---| +| Device Setup & Provisioning (FR1–FR11) | `provisioning/portal.py`, `location.py`, `tiles.py`, `wifi.py`, `config.py` | +| Reset & Recovery (FR12–FR15) | `gpio_ctrl.py`, `config.py`, reset flow via `os.execvp` in `main.py` | +| Map Display (FR16–FR19) | `renderer/basemap.py`, `renderer/airspace.py`, `renderer/projection.py` | +| Aircraft Display (FR20–FR26) | `fetcher.py`, `renderer/aircraft.py`, `colours.py`, `icons.py` | +| Stale Data Handling (FR27–FR29) | `fetcher.py` (detection), `renderer/renderer.py` (stale flag + outline) | +| Refresh Loop & Boot (FR30–FR33) | `main.py`, `display.py` | + +**Non-Functional Requirements:** +- Performance: base map cached in memory; render phases instrumented; dump1090 fetch timeout 5s; SPI transfer after render complete ✅ +- Reliability: `Restart=always`; loop boundary try/except isolates dump1090 failures; power recovery via systemd ✅ +- Storage: tile cache ≤2GB validated in `tiles.py` before WiFi kill ✅ +- Security: WiFi killed via `rfkill`; no external calls in operational state ✅ + +--- + +### Gaps Found & Resolved + +**Gap 1 — Reset flow mechanics (FR12–FR15)** — RESOLVED +`main.py` reset handler: +1. Calls `config.wipe()` +2. Calls `display.show(setup_screen_image)` (FR15) +3. Calls `os.execvp('planemapper-provision', ['planemapper-provision'])` + +systemd sees `planemapper-radar` exit → restarts → provision runs → writes config → exits → systemd restarts radar into operational mode. No IPC required. + +**Gap 2 — OurAirports bundled data** — RESOLVED +`airports.csv` moved to `src/planemapper/data/airports.csv`. Accessed via `importlib.resources`. pyproject.toml: +```toml +[tool.setuptools.package-data] +"planemapper" = ["data/airports.csv"] +``` + +**Gap 3 — constants.py scope clarification** — RESOLVED +`constants.py` scope expanded to include: full 6-colour palette with semantic +mappings, trail dot sizing, reset hold time, and all UI colours. No inline RGB +tuples, no literal sleeps, no hardcoded paths anywhere in the codebase. + +--- + +### Corrected Project Structure (delta from step 6) + +``` +src/ +└── planemapper/ + ├── data/ + │ └── airports.csv # Moved here from top-level data/; accessed via + │ # importlib.resources in provisioning/location.py + └── ... +``` +Top-level `data/` directory removed. + +`main.py` reset sequence: +``` +gpio_ctrl.check() → True + → config.wipe() + → display.show(setup_screen) + → os.execvp('planemapper-provision', ['planemapper-provision']) +``` + +--- + +### Architecture Completeness Checklist + +**✅ Requirements Analysis** +- [x] Project context thoroughly analysed +- [x] Scale and complexity assessed (Medium, IoT/Embedded Python) +- [x] Technical constraints identified (Pi Zero 2W, 512MB RAM, 45s budget) +- [x] Cross-cutting concerns mapped (offline-first, graceful degradation, state isolation) + +**✅ Architectural Decisions** +- [x] Critical decisions documented with verified versions +- [x] Technology stack fully specified (Python 3.11, all deps pinned) +- [x] Integration patterns defined (fetch/render/display/GPIO boundaries) +- [x] Performance considerations addressed (phase instrumentation, memory caching) + +**✅ Implementation Patterns** +- [x] Data type convention established (Aircraft dataclass) +- [x] Coordinate convention defined ((lat, lon) throughout) +- [x] Units convention defined (feet throughout) +- [x] Interface style defined (typing.Protocol) +- [x] Constants centralised (constants.py) — includes colours (full 6-colour palette + semantic mappings), geometry, timing, paths, trail sizing +- [x] Type annotations required throughout +- [x] Logging levels defined +- [x] Error handling pattern defined (raise inside, catch at loop boundary) + +**✅ Project Structure** +- [x] Complete directory structure defined with all files +- [x] Component boundaries established and enforced +- [x] Integration points mapped to specific modules +- [x] All 33 FRs mapped to specific files +- [x] Test structure mirrors src with fixture library + +--- + +### Architecture Readiness Assessment + +**Overall Status: READY FOR IMPLEMENTATION** + +**Confidence Level: High** + +**Key Strengths:** +- Hard boundary between provisioning and operational modes eliminates the largest class of runtime bugs for this type of device +- Stateful Renderer with in-memory composite eliminates tile I/O from the hot path +- Hardware interfaces (Protocol) enable full test coverage without physical hardware +- `ProvisioningError` + loop recovery ensures no silent partial-provisioning state +- Reset flow via `os.execvp` is clean, testable, and requires no additional service dependencies +- `constants.py` as single source of truth for all project-wide values prevents colour/threshold drift across modules + +**Areas for Future Enhancement (post-MVP):** +- Airspace colour fills (Phase 2 per PRD) +- Own squawk code highlighting (Phase 2) +- Aircraft size coding (Phase 2) +- SD card image build automation +- **E-ink refresh speed experiment:** once working prototype exists, benchmark SPI + clock speed (Waveshare library default vs. 10–20MHz) and test any fast/partial + refresh modes available on the Spectra 6 HAT. `REFRESH_INTERVAL_S` in + `constants.py` is the only change needed if cycle time can be reduced. + +--- + +### Implementation Handoff + +**First implementation task:** project scaffold — `src/` layout, `pyproject.toml` with both entry points and package data, `requirements.txt`, `requirements-dev.txt`, empty module files with correct imports, `systemd/` unit files, and `pip install -e .` verified. + +**All AI Agents MUST:** +- Follow all architectural decisions exactly as documented +- Use implementation patterns in Section 5 consistently — 7 mandatory rules apply +- `main.py` must not import from `planemapper.provisioning.*` — ruff enforces this +- All hardware boundaries typed against Protocols, never concrete classes +- All fixed values — numbers, colours, paths, sizes — imported from `constants.py` diff --git a/_bmad-output/planning-artifacts/epics.md b/_bmad-output/planning-artifacts/epics.md new file mode 100644 index 0000000..13733a2 --- /dev/null +++ b/_bmad-output/planning-artifacts/epics.md @@ -0,0 +1,609 @@ +--- +stepsCompleted: [step-01-validate-prerequisites, step-02-design-epics, step-03-create-stories, step-04-final-validation] +inputDocuments: + - _bmad-output/planning-artifacts/prd.md + - _bmad-output/planning-artifacts/architecture.md +--- + +# planeMapper - Epic Breakdown + +## Overview + +This document provides the complete epic and story breakdown for planeMapper, decomposing the requirements from the PRD, UX Design if it exists, and Architecture requirements into implementable stories. + +## Requirements Inventory + +### Functional Requirements + +FR1: The device broadcasts a WiFi hotspot on first boot and after reset +FR2: The user can connect to the device hotspot and be served a setup interface automatically (captive portal) +FR3: The user can enter a location as an ICAO code or address/postcode +FR4: The device resolves an ICAO code to coordinates using a bundled airport database +FR5: The device resolves an address or postcode to coordinates using a geocoding service +FR6: The device displays the resolved location for user confirmation before proceeding +FR7: The user can set a coverage radius +FR8: The user can enter home WiFi credentials during setup +FR9: The device connects to the user's home WiFi and downloads and caches map tiles for the configured area +FR9a: After tile download, the device validates cache completeness and size before killing the WiFi radio; on failure, the device remains in provisioning state and prompts retry +FR10: The device kills the WiFi radio after successful provisioning +FR11: The setup interface confirms provisioning status to the user before the WiFi hotspot is dropped +FR12: The user can trigger a device reset by holding the reset button for 3 seconds +FR13: The device provides immediate visual feedback via LED when a reset hold is detected +FR14: A confirmed reset wipes device configuration and returns to provisioning state +FR15: The device displays a setup screen on the e-ink display after reset +FR16: The device renders an OpenStreetMap base map centred on the configured home location +FR17: The map covers the configured coverage radius +FR18: The home location is marked as a distinct point on the map +FR19: Airspace circular boundaries are rendered as outlines on the map (OpenAIP data) +FR20: The device fetches live aircraft data from the dump1090 JSON feed +FR21: Each aircraft is rendered at its current position with a heading arrow aligned to direction of travel +FR22: Each aircraft displays its callsign and altitude as a label +FR23: Each aircraft is colour-coded by altitude band +FR24: Each aircraft is rendered with a type-specific icon determined from ADS-B category data or callsign pattern matching (GA/light, commercial/large, helicopter, private jet) +FR24a: When aircraft type cannot be determined, icon is assigned by altitude — GA below 10,000ft, private jet 10,000–30,000ft, airliner above 30,000ft +FR25: Each aircraft displays a trail of up to 5 previous positions as dots, oldest dot smallest +FR26: Aircraft transmitted via MLAT are visually distinguished from directly received aircraft +FR27: The device detects when the dump1090 feed has not produced a fresh decode +FR28: Aircraft from the last successful decode are retained on display and visually marked as stale +FR29: Aircraft positions are restored to normal display state when fresh decode data is received +FR30: The display refreshes on a 60-second cycle +FR31: The device continues the refresh loop indefinitely without manual intervention +FR32: The device resumes the refresh loop automatically after power cycling +FR33: The device displays a defined startup screen during boot, before the first radar render is complete + +### NonFunctional Requirements + +NFR1: Full radar render must complete within 45 seconds on Pi Zero 2W hardware +NFR2: Base map tile layer is pre-composited and cached in memory between refresh cycles — only the aircraft overlay is re-rendered each cycle +NFR3: dump1090 JSON fetch must complete within 5 seconds; timeout triggers stale data path +NFR4: E-ink SPI transfer initiates only after render pipeline is complete +NFR5: Refresh loop must sustain 72+ hours of continuous operation without restart or intervention +NFR6: Device must recover to operational state within 5 minutes of unclean power loss, without manual intervention +NFR7: dump1090 decode failure must not crash the refresh loop +NFR8: OSM tile cache must not exceed 2GB for any supported coverage radius (16GB SD card) +NFR9: Cache size validated during provisioning before WiFi radio is killed +NFR10: dump1090 JSON feed at http://localhost:8080/data/aircraft.json — local, no authentication +NFR11: Nominatim geocoding API called once during provisioning only; internet required at that point only +NFR12: OurAirports database bundled with software, no runtime dependency +NFR13: OpenAIP airspace data fetched and cached during provisioning alongside OSM tiles +NFR14: WiFi radio off in operational state — network attack surface is zero +NFR15: No external network calls in operational state +NFR16: Config stored plaintext on SD card — acceptable for personal single-user device + +### Additional Requirements + +- **Project scaffold (Architecture):** `src/` layout, `pyproject.toml` with two entry points (`planemapper-radar`, `planemapper-provision`) and `planemapper` package data config, `requirements.txt`, `requirements-dev.txt`, empty module stubs, `pip install -e .` verified working +- **Two process entry points (Architecture):** `planemapper-provision` and `planemapper-radar` are separate processes and systemd units — they must never share a runtime context; `main.py` must not import from `planemapper.provisioning.*` +- **Python 3.11 (Architecture):** Raspberry Pi OS Bookworm default; pure Python, no compilation step; deployment via git pull + `pip install .` +- **Pinned runtime deps (Architecture):** Pillow 12.2.0, gpiozero 2.0.1, Flask 3.1.3, requests 2.33.1; ruff 0.15.11 for linting/formatting +- **Config file (Architecture):** JSON at `/etc/planemapper/config.json` — home lat/lon, coverage radius (nm), WiFi SSID/password, provisioning state flag; single module (`provisioning/config.py`) reads/writes/wipes it +- **Background map (Architecture):** Pre-composited `background.png` (800×480) generated at provisioning; loaded once into Renderer memory at radar startup — eliminates all tile I/O from the operational render loop +- **Airspace cache (Architecture):** GeoJSON at `/etc/planemapper/airspace.geojson`, downloaded during provisioning; no runtime network dependency +- **Stale data visual (Architecture):** Stale aircraft rendered as outlines only (no fill); threshold = 1 missed fetch cycle; recovery on next successful fetch restores normal rendering automatically +- **Systemd units (Architecture):** `planemapper-provision.service` (Type=oneshot, runs at first boot/post-reset) and `planemapper-radar.service` (Restart=always, After=planemapper-provision) +- **Logging (Architecture):** stdout → systemd journal; stdlib `logging` module; levels: DEBUG (per-aircraft), INFO (cycle start/complete with phase timings), WARNING (render >40s, stale state change), ERROR (fetch failure, SPI failure, required file not found) +- **Render pipeline instrumentation (Architecture):** Phase timings logged each cycle (tile load, aircraft overlay, SPI transfer); warn threshold 40s total; alert threshold 50s; stale path triggered if render exceeds 60s boundary +- **Aircraft dataclass (Architecture):** `@dataclass Aircraft` with typed optional fields defaulting to safe sentinels; `is_stale` carried on dataclass; nothing beyond `fetcher.py` touches raw JSON +- **Coordinate convention (Architecture):** `(lat, lon)` throughout all internal code; GeoJSON parsed with explicit reversal at parse boundary only; single projection function in `renderer/projection.py` +- **Units convention (Architecture):** Altitude in feet throughout; thresholds in `constants.py`; no metres conversion anywhere +- **Interface protocols (Architecture):** `DisplayInterface` and `FetcherInterface` as `typing.Protocol`; all production code typed against Protocol, never concrete class +- **Constants (Architecture):** Single `src/planemapper/constants.py` for all project-wide values — colours (full 6-colour palette + semantic mappings), geometry, timing, paths, trail sizing; no inline literals anywhere +- **Error handling (Architecture):** Single try/except at render loop boundary; internal functions raise normally; no bare `except:` except at top-level loop +- **Reset flow (Architecture):** `config.wipe()` → `display.show(setup_screen)` → `os.execvp('planemapper-provision', ...)` — no IPC required; systemd handles restart sequencing +- **OurAirports data (Architecture):** `airports.csv` bundled in `src/planemapper/data/airports.csv`; accessed via `importlib.resources`; configured in `pyproject.toml` package-data +- **GPIO non-blocking (Architecture):** `ButtonHoldDetector.check() -> bool` is non-blocking, polled once per cycle alongside render loop +- **Test infrastructure (Architecture):** pytest; gpiozero MockFactory for GPIO boundary tests; `NullDisplay` + `FileFixtureFetcher` for hardware-free testing; `conftest.py` patches `CONFIG_PATH` to `tmp_path` — no `/etc/` dependency in CI + +### FR Coverage Map + +``` +FR1: Epic 1 — WiFi hotspot broadcast on first boot / post-reset +FR2: Epic 1 — Captive portal served to connecting user +FR3: Epic 1 — Location entry: ICAO code or address/postcode +FR4: Epic 1 — ICAO code → coordinates (bundled OurAirports DB) +FR5: Epic 1 — Address/postcode → coordinates (Nominatim) +FR6: Epic 1 — Resolved location displayed for user confirmation +FR7: Epic 1 — Coverage radius selection +FR8: Epic 1 — Home WiFi credential entry +FR9: Epic 1 — Tile download and caching for configured area +FR9a: Epic 1 — Cache completeness/size validation before WiFi kill; retry on failure +FR10: Epic 1 — WiFi radio killed (rfkill) after successful provisioning +FR11: Epic 1 — Portal confirms provisioning success before hotspot dropped +FR12: Epic 4 — Reset button 3-second hold detection +FR13: Epic 4 — Immediate LED feedback on reset hold +FR14: Epic 4 — Config wipe + return to provisioning state +FR15: Epic 4 — Setup screen shown on e-ink after reset +FR16: Epic 2 — OSM base map rendered, centred on home location +FR17: Epic 2 — Map covers configured coverage radius +FR18: Epic 2 — Home location marked on map +FR19: Epic 2 — Airspace circular boundaries rendered as outlines (OpenAIP) +FR20: Epic 2 — Live aircraft data fetched from dump1090 JSON feed +FR21: Epic 2 — Per-aircraft heading arrow aligned to direction of travel +FR22: Epic 2 — Per-aircraft callsign + altitude label +FR23: Epic 2 — Per-aircraft colour coding by altitude band +FR24: Epic 2 — Per-aircraft type icon (GA, commercial, helicopter, private jet) +FR24a: Epic 2 — Altitude-based icon fallback when type unknown +FR25: Epic 2 — 5-dot position trail, oldest dot smallest +FR26: Epic 2 — MLAT positions visually distinguished from direct positions +FR27: Epic 3 — Stale data detection (missed dump1090 decode) +FR28: Epic 3 — Stale aircraft retained on display, visually marked (outline-only) +FR29: Epic 3 — Normal display restored on next fresh decode +FR30: Epic 2 — 60-second refresh cycle +FR31: Epic 2 — Refresh loop runs indefinitely without intervention +FR32: Epic 2 — Refresh loop resumes automatically after power cycling +FR33: Epic 2 — Startup screen shown during boot before first radar render +``` + +## Epic List + +### Epic 1: Device Setup & Provisioning +A user can take a freshly flashed SD card, power on the device, connect via their phone, enter their location and home WiFi credentials, and have the device provision itself fully — downloading and validating map tiles, killing the WiFi radio — and confirm success on the portal. +**FRs covered:** FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR9a, FR10, FR11 + +### Epic 2: Live Radar Display +A user can glance at the e-ink display and see live aircraft positions with heading arrows, callsigns, altitude labels, colour-coded altitude bands, type icons, and position trails — refreshing automatically every 60 seconds indefinitely, including after power cycling. +**FRs covered:** FR16, FR17, FR18, FR19, FR20, FR21, FR22, FR23, FR24, FR24a, FR25, FR26, FR30, FR31, FR32, FR33 + +### Epic 3: Stale Data Resilience +When dump1090 decoding fails or times out, the device continues displaying the last known aircraft positions with a visual stale indicator and recovers automatically when decoding resumes — no crash, no blank screen, no intervention needed. +**FRs covered:** FR27, FR28, FR29 + +### Epic 4: Reset & Reconfiguration +A user can hold the reset button for 3 seconds, receive immediate LED confirmation, and have the device wipe its configuration and return to provisioning state — enabling full re-setup from any location. +**FRs covered:** FR12, FR13, FR14, FR15 + +--- + +## Epic 1: Device Setup & Provisioning + +A user can take a freshly flashed SD card, power on the device, connect via their phone, enter their location and home WiFi credentials, and have the device provision itself fully — downloading and validating map tiles, killing the WiFi radio — and confirm success on the portal. + +### Story 1.1: Project Scaffold & Verified Entry Points + +As a developer, +I want a verified project scaffold with the `src/planemapper/` layout, both console entry points installable, all module stubs in place, systemd unit files, and `pytest` running without error, +So that every subsequent story has a consistent, working foundation to build on. + +**Acceptance Criteria:** + +**Given** the repository is cloned on a Pi Zero 2W running Raspberry Pi OS Bookworm +**When** `pip install -e .` is run +**Then** it completes without errors and both `planemapper-provision` and `planemapper-radar` commands are available on PATH +**And** running either command logs "not implemented" and exits with code 0 + +**Given** the project is installed +**When** `pytest` is run +**Then** the test suite discovers tests and exits with 0 failures (empty stubs acceptable) + +**Given** the project structure +**When** a developer inspects the repository +**Then** all files from the Architecture directory structure exist: `src/planemapper/` with `__init__.py`, `constants.py`, `models.py`, `main.py`, `provision.py`, `fetcher.py`, `gpio_ctrl.py`, `display.py`, `provisioning/` (7 modules), `renderer/` (8 modules), `data/airports.csv`; `systemd/` with both `.service` files; `pyproject.toml`, `requirements.txt`, `requirements-dev.txt` +**And** `src/planemapper/data/airports.csv` is accessible via `importlib.resources` +**And** `ruff check .` passes with zero violations + +### Story 1.2: Configuration Read/Write/Wipe + +As a provisioning system, +I want a single config module that reads, writes, and wipes `/etc/planemapper/config.json`, +So that all components share one reliable config boundary with no direct filesystem access elsewhere. + +**Acceptance Criteria:** + +**Given** no config file exists at `CONFIG_PATH` +**When** `config.read()` is called +**Then** it raises `FileNotFoundError` + +**Given** a valid config dict with home lat/lon, coverage radius, WiFi SSID/password, and `provisioned` flag +**When** `config.write(data)` is called +**Then** the file is created at `CONFIG_PATH` with correct JSON content and all expected keys present + +**Given** an existing config file +**When** `config.wipe()` is called +**Then** the config file is deleted and a subsequent `config.read()` raises `FileNotFoundError` + +**Given** a test using `conftest.py` +**When** `CONFIG_PATH` is patched to `tmp_path` +**Then** all config operations work without touching `/etc/planemapper/` + +### Story 1.3: WiFi Hotspot & Captive Portal Form + +As a user setting up the device for the first time, +I want to connect my phone to the `planeMapper-setup` hotspot and be automatically redirected to a setup page where I can enter my location, coverage radius, and home WiFi credentials, +So that I can configure the device without a keyboard or monitor. + +**Acceptance Criteria:** + +**Given** the device boots with no config file present +**When** `planemapper-provision` starts +**Then** `hostapd` and `dnsmasq` are started and the `planeMapper-setup` SSID is broadcast +**And** any DNS query from a connected client resolves to the Pi's IP (triggering captive portal detection) + +**Given** a phone connected to `planeMapper-setup` +**When** the phone attempts to load any URL +**Then** the Flask portal page is served (captive portal detection triggers automatically) + +**Given** the portal page is displayed +**When** the user views the form +**Then** the form contains: location field (ICAO code or address/postcode), coverage radius field (default 100nm), WiFi SSID field, WiFi password field, and a "Find location" button separate from the final submit + +**Given** `wifi.start_ap()` fails (e.g. hostapd not installed or subprocess returns non-zero) +**When** the failure occurs +**Then** a `ProvisioningError` is raised, an ERROR is logged, and the provisioning loop resets to portal state + +### Story 1.4: Location Resolution (ICAO & Address) + +As a user setting up the device, +I want to type my home airfield ICAO code or my home address/postcode and have the device resolve it to coordinates and show the result for confirmation, +So that I can verify the device is centred on the correct location before committing. + +**Acceptance Criteria:** + +**Given** the user enters a valid ICAO code (e.g. `EGLL`) +**When** "Find location" is pressed +**Then** the bundled `airports.csv` is queried via `importlib.resources` and the matching lat/lon is returned +**And** the resolved location name and coordinates are displayed on the portal for confirmation + +**Given** the user enters an address or postcode (e.g. `OX1 1AA`) +**When** "Find location" is pressed +**Then** the Nominatim API is called once with the input and the resolved lat/lon is displayed for confirmation + +**Given** the user enters an ICAO code not present in `airports.csv` +**When** "Find location" is pressed +**Then** the portal displays: "ICAO code not found — try an address instead" + +**Given** Nominatim returns no results +**When** "Find location" is pressed +**Then** the portal displays: "Location not found — try a different search term" + +**Given** tests run in CI +**When** location tests execute +**Then** Nominatim calls are mocked — no real network calls required in the test suite + +### Story 1.5: Provisioning Execution — Tile Download, Cache Validation & WiFi Kill + +As a user who has confirmed their location and entered WiFi credentials, +I want the device to automatically join my home WiFi, download all map tiles and airspace data, validate the cache, confirm success on screen, and kill the WiFi radio without further interaction, +So that the device is fully provisioned and permanently offline from that point. + +**Acceptance Criteria:** + +**Given** the user submits the portal form with valid location, radius, and WiFi credentials +**When** the form is submitted +**Then** the portal updates to show: "Downloading map data — this may take a few minutes. Do not power off." +**And** the device joins the user's home WiFi network + +**Given** the device has joined home WiFi +**When** tile download runs +**Then** all OSM tiles for the configured area and zoom level are downloaded and composited into `background.png` (800×480) saved at `/etc/planemapper/background.png` +**And** OpenAIP airspace GeoJSON is downloaded and saved to `/etc/planemapper/airspace.geojson` + +**Given** tile download is complete +**When** cache validation runs +**Then** `background.png` is confirmed non-zero size and readable as a valid PNG +**And** total tile data is confirmed within 2GB (NFR8, NFR9) +**And** if validation fails, the device remains in provisioning state and the portal displays a retry prompt + +**Given** cache validation passes +**When** provisioning completes +**Then** `config.write()` saves home lat/lon, coverage radius, WiFi credentials, and `provisioned: true` +**And** `rfkill block wifi` is called and returns exit code 0 +**And** the portal displays: "Setup complete. The device will now start displaying radar." +**And** if `rfkill` fails, a `ProvisioningError` is raised and the provisioning loop resets + +--- + +## Epic 2: Live Radar Display + +A user can glance at the e-ink display and see live aircraft positions with heading arrows, callsigns, altitude labels, colour-coded altitude bands, type icons, and position trails — refreshing automatically every 60 seconds indefinitely, including after power cycling. + +### Story 2.1: Aircraft Data Model & Fetcher + +As the radar system, +I want an `Aircraft` dataclass with safe-default optional fields and a `FetcherInterface` with both an `HttpFetcher` (live dump1090) and a `FileFixtureFetcher` (for testing), +So that all downstream rendering code works with typed `Aircraft` objects and the fetch boundary is cleanly isolated from raw JSON. + +**Acceptance Criteria:** + +**Given** a valid dump1090 JSON response with all fields present +**When** `HttpFetcher.fetch()` is called +**Then** it returns a `list[Aircraft]` with all fields populated correctly + +**Given** the dump1090 response contains aircraft with missing `callsign`, `altitude`, or `category` +**When** `HttpFetcher.fetch()` is called +**Then** the corresponding fields use safe defaults (`callsign=""`, `altitude_ft=0`, `category=""`) and no exception is raised + +**Given** the dump1090 HTTP request exceeds `FETCH_TIMEOUT_S` (5 seconds) +**When** `HttpFetcher.fetch()` is called +**Then** a `requests.Timeout` is raised (not caught here — the loop boundary handles it) + +**Given** an aircraft entry has the MLAT flag set in the JSON +**When** `HttpFetcher.fetch()` is called +**Then** the resulting `Aircraft` has `is_mlat=True` + +**Given** a `FileFixtureFetcher` pointed at `tests/fixtures/aircraft_sample.json` +**When** `.fetch()` is called +**Then** it returns the equivalent `list[Aircraft]` with no network call made + +### Story 2.2: Coordinate Projection & Base Map Loading + +As the renderer, +I want a `MapBounds` dataclass and a `project()` function converting `(lat, lon)` to pixel `(x, y)`, and a basemap module that loads `background.png` into memory once, +So that all rendering uses consistent coordinates and the base map is always available without disk I/O in the loop. + +**Acceptance Criteria:** + +**Given** a `MapBounds` from home lat/lon and coverage radius +**When** `project(lat, lon, bounds)` is called with the home location +**Then** it returns pixel coordinates at the centre of the 800×480 display (±2px) + +**Given** `project()` is called with a position outside the map bounds +**When** the result is used +**Then** the returned pixel coordinate is outside display dimensions — no clamping, callers handle clipping + +**Given** `background.png` exists at `BACKGROUND_PATH` +**When** `basemap.load()` is called +**Then** it returns a `PIL.Image` (800×480) loaded into memory + +**Given** `background.png` does not exist at `BACKGROUND_PATH` +**When** `basemap.load()` is called +**Then** it raises `FileNotFoundError` (logged as ERROR by the caller) + +### Story 2.3: Home Marker & Airspace Outlines + +As a user glancing at the display, +I want to see my home location marked on the map and published airspace boundaries shown as outlines, +So that I have immediate spatial context for all aircraft positions. + +**Acceptance Criteria:** + +**Given** a loaded base map image and home lat/lon from config +**When** the home marker is drawn +**Then** a distinct `COLOUR_HOME_MARKER` (red) marker is drawn at the projected pixel position of the home location + +**Given** a valid `airspace.geojson` at `AIRSPACE_PATH` +**When** airspace outlines are drawn +**Then** each circular boundary in the GeoJSON is drawn as an outline in `COLOUR_AIRSPACE` on the image +**And** GeoJSON `[lon, lat]` coordinates are reversed to `(lat, lon)` at the parse boundary before any projection + +**Given** `airspace.geojson` does not exist at `AIRSPACE_PATH` +**When** airspace draw is called +**Then** no exception is raised — the map renders without airspace outlines and a WARNING is logged + +### Story 2.4: Altitude Colour Bands & Aircraft Type Icons + +As the renderer, +I want pure functions mapping an aircraft's altitude to a display colour and its ADS-B category/callsign to an icon type, +So that every aircraft is consistently colour-coded and type-classified with all logic centralised. + +**Acceptance Criteria:** + +**Given** `altitude_ft` values at the exact boundaries in `ALTITUDE_BANDS_FT` +**When** `altitude_to_colour(altitude_ft)` is called +**Then** the correct `ALTITUDE_COLOURS` entry is returned for each boundary and above/below it +**And** all 6 Waveshare Spectra 6 palette colours are reachable + +**Given** an `Aircraft` with `category="A1"` (light aircraft) +**When** `classify_aircraft_type(aircraft)` is called +**Then** it returns the GA/light icon type + +**Given** an `Aircraft` with a BA callsign pattern and no category +**When** `classify_aircraft_type(aircraft)` is called +**Then** it returns the commercial/large icon type + +**Given** an `Aircraft` with `category="A7"` (helicopter) +**When** `classify_aircraft_type(aircraft)` is called +**Then** it returns the helicopter icon type + +**Given** an `Aircraft` with no category, no recognised callsign, at `altitude_ft=5000` +**When** `classify_aircraft_type(aircraft)` is called +**Then** it returns GA/light (altitude <10,000ft — FR24a fallback) + +**Given** an `Aircraft` with no category, at `altitude_ft=18000` +**When** `classify_aircraft_type(aircraft)` is called +**Then** it returns private jet (10,000–30,000ft — FR24a) + +**Given** an `Aircraft` with no category, at `altitude_ft=38000` +**When** `classify_aircraft_type(aircraft)` is called +**Then** it returns airliner (>30,000ft — FR24a) + +### Story 2.5: Per-Aircraft Drawing (Arrow, Label, Trail, MLAT) + +As a user looking at the display, +I want each aircraft drawn with a heading arrow, callsign/altitude label, a 5-dot position trail with the oldest dot smallest, and MLAT aircraft visually distinct, +So that I can read direction, identity, altitude, recent path, and data confidence at a glance. + +**Acceptance Criteria:** + +**Given** an `Aircraft` with `heading=90.0` (due east) +**When** the heading arrow is drawn +**Then** the arrow points east on the display, correctly rotated from north-up reference + +**Given** an `Aircraft` with `callsign="BAW1"` and `altitude_ft=28000` +**When** the label is drawn +**Then** callsign and altitude are rendered near the aircraft position +**And** the label colour matches the aircraft's altitude colour band + +**Given** a trail `deque` with 3 entries +**When** the trail is drawn +**Then** 3 dots are rendered with decreasing size from most-recent to oldest (interpolated between `TRAIL_DOT_SIZE_MAX` and `TRAIL_DOT_SIZE_MIN`) +**And** dot colour is `COLOUR_TRAIL` + +**Given** an `Aircraft` with `is_mlat=True` +**When** the aircraft is drawn +**Then** it is rendered in a visually distinct style from directly-received aircraft + +**Given** an `Aircraft` with `callsign=""` +**When** the label is drawn +**Then** altitude only is rendered with no blank callsign prefix, and no exception is raised + +### Story 2.6: Stateful Renderer & Display Interface + +As the radar loop, +I want a stateful `Renderer` owning the in-memory tile composite and per-aircraft trail history, and a `DisplayInterface` protocol with `WaveshareDisplay` (SPI) and `NullDisplay` (tests), +So that the render pipeline is fully isolated, testable without hardware, and trail history persists across cycles. + +**Acceptance Criteria:** + +**Given** a `Renderer` initialised with a loaded base map +**When** `renderer.render(aircraft_list)` is called +**Then** it returns a `PIL.Image` (800×480) with base map, airspace outlines, home marker, and all aircraft drawn + +**Given** an aircraft appears in two consecutive calls to `renderer.render()` +**When** the second call is made +**Then** its previous position appears as a trail dot in the output +**And** trail length never exceeds `TRAIL_MAX_DOTS` (5) + +**Given** an aircraft was present last cycle but is absent from the current list +**When** `renderer.render()` is called +**Then** the aircraft does not appear on the display +**And** its trail history is retained in `dict[str, deque]` for when it reappears + +**Given** a `NullDisplay` +**When** `display.show(image)` is called +**Then** it logs image dimensions at DEBUG level and returns without error — no SPI call made + +**Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Renderer → NullDisplay`) +**When** one full cycle runs +**Then** it completes without exception and the returned image is 800×480 + +### Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring + +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:** + +**Given** the device boots with a valid config file +**When** `planemapper-radar` starts +**Then** a startup screen is displayed on the e-ink before the first radar render begins (FR33) +**And** once the first radar render completes, the live display replaces the startup screen + +**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 (tile load, overlay, SPI) are logged at INFO level each cycle + +**Given** total render time exceeds 40 seconds +**When** the cycle completes +**Then** a WARNING is logged with the total render time + +**Given** `planemapper-radar.service` +**When** the service file is inspected +**Then** it has `Restart=always` and `After=planemapper-provision.service` + +**Given** the device loses mains power and is restored +**When** the Pi reboots +**Then** `planemapper-provision.service` detects `provisioned: true` in config and exits immediately +**And** `planemapper-radar.service` starts and resumes the loop within 5 minutes (NFR6, FR32) + +--- + +## Epic 3: Stale Data Resilience + +When dump1090 decoding fails or times out, the device continues displaying the last known aircraft positions with a visual stale indicator and recovers automatically when decoding resumes — no crash, no blank screen, no intervention needed. + +### Story 3.1: Stale State Detection & Dimmed Display + +As a user whose RTL-SDR has temporarily lost signal, +I want the display to retain the last known aircraft positions shown as outlines when dump1090 stops delivering fresh data, +So that I know the display is stale without a crash or blank screen. + +**Acceptance Criteria:** + +**Given** the radar loop is running with a previous successful fetch +**When** `HttpFetcher.fetch()` raises `requests.Timeout` (>5s) +**Then** the exception propagates to the loop boundary, which catches it and marks all retained aircraft as `is_stale=True` + +**Given** the dump1090 response returns an empty aircraft list when the previous cycle had aircraft +**When** the fetcher processes the response +**Then** the previous aircraft list is retained with `is_stale=True` on each entry (not replaced with an empty list) + +**Given** aircraft with `is_stale=True` are passed to the renderer +**When** `renderer.render()` is called +**Then** each stale aircraft is drawn as an outline only (no fill) using `COLOUR_STALE_OUTLINE` +**And** heading arrow, label, and trail are still rendered at their last known positions + +**Given** a stale render cycle +**When** the render loop timing is measured +**Then** the loop does not crash and completes within normal bounds — stale path is not a crash path (NFR7) + +### Story 3.2: Automatic Recovery on Fresh Decode + +As a user whose RTL-SDR has recovered, +I want the display to automatically return to normal filled aircraft rendering on the next successful fetch, +So that recovery requires no manual intervention. + +**Acceptance Criteria:** + +**Given** the display is in stale state (aircraft rendered as outlines) +**When** `HttpFetcher.fetch()` returns a non-empty aircraft list successfully +**Then** all newly fetched aircraft have `is_stale=False` +**And** the renderer draws them with normal filled icons in their altitude colour band + +**Given** the display has recovered from stale state +**When** the next render cycle runs +**Then** no stale outline rendering occurs for the recovered aircraft + +**Given** a stale-then-recovery sequence in `test_pipeline.py` +**When** `FileFixtureFetcher` returns an empty list followed by a populated list +**Then** the first cycle produces outline-only aircraft and the second produces normal filled aircraft + +--- + +## Epic 4: Reset & Reconfiguration + +A user can hold the reset button for 3 seconds, receive immediate LED confirmation, and have the device wipe its configuration and return to provisioning state — enabling full re-setup from any location. + +### Story 4.1: GPIO Button Hold Detection & LED Feedback + +As a user wanting to reconfigure the device, +I want to hold the reset button for 3 seconds and receive immediate LED confirmation, +So that I know the reset was registered before anything else changes. + +**Acceptance Criteria:** + +**Given** the reset button GPIO is configured via gpiozero +**When** the button is held for `RESET_HOLD_S` (3 seconds) +**Then** `ButtonHoldDetector.check()` returns `True` + +**Given** the button is held for less than 3 seconds +**When** `ButtonHoldDetector.check()` is called +**Then** it returns `False` — no reset triggered + +**Given** `ButtonHoldDetector.check()` returns `True` +**When** the main loop processes the result +**Then** `LEDController.on()` is called immediately (FR13 — immediate feedback before any config change) + +**Given** gpiozero `MockFactory` is active in tests +**When** button hold and LED tests run +**Then** they pass without physical GPIO hardware + +**Given** `ButtonHoldDetector.check()` is called once per render cycle +**When** the render loop runs +**Then** the call is non-blocking and adds no perceptible delay to the render pipeline + +### Story 4.2: Config Wipe, Setup Screen & Return to Provisioning + +As a user who has triggered a reset, +I want the device to wipe its configuration, show a setup screen on the e-ink display, and restart into the provisioning flow, +So that I can re-configure the device from scratch for a new location or home network. + +**Acceptance Criteria:** + +**Given** `ButtonHoldDetector.check()` returns `True` in the main loop +**When** the reset handler runs +**Then** `config.wipe()` is called and the config file is deleted (FR14) + +**Given** the config has been wiped +**When** the reset handler continues +**Then** `display.show(setup_screen_image)` is called, displaying the setup screen on the e-ink (FR15) + +**Given** the setup screen is shown +**When** the reset handler completes +**Then** `os.execvp('planemapper-provision', ['planemapper-provision'])` is called, replacing the current process +**And** systemd restarts `planemapper-radar` → detects no config → runs provisioning flow from scratch + +**Given** `config.wipe()` raises an unexpected error +**When** the reset handler encounters it +**Then** an ERROR is logged and `os.execvp` is not called — no partial reset leaves the device in an inconsistent state