--- 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`