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 <noreply@anthropic.com>
This commit is contained in:
Matt Edholm
2026-04-22 22:20:13 -04:00
parent 9b670fa98f
commit b2afa7fb4b
3 changed files with 1440 additions and 0 deletions
@@ -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 (FR1FR11): captive portal, location resolution (ICAO/address), tile download, cache validation, WiFi radio kill
- Reset & Recovery (FR12FR15): GPIO button hold, LED feedback, config wipe, setup screen
- Map Display (FR16FR19): OSM base map, home marker, OpenAIP airspace outlines
- Aircraft Display (FR20FR26): dump1090 fetch, heading arrow, callsign/altitude label, altitude colour bands, type icons, 5-dot trail, MLAT distinction
- Stale Data Handling (FR27FR29): decode gap detection, stale visual indicator, recovery
- Refresh Loop & Boot (FR30FR33): 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 (FR30FR33)
│ ├── provision.py # Entry: provisioning loop with ProvisioningError
│ │ # recovery — never imports from renderer/
│ ├── fetcher.py # FetcherInterface Protocol + HttpFetcher
│ │ # + FileFixtureFetcher (FR20, FR27FR29)
│ ├── gpio_ctrl.py # ButtonHoldDetector + LEDController via
│ │ # gpiozero (FR12FR13)
│ ├── display.py # DisplayInterface Protocol + WaveshareDisplay
│ │ # + NullDisplay (FR30, FR33)
│ ├── provisioning/
│ │ ├── __init__.py # ProvisioningError exception definition
│ │ ├── portal.py # Flask app + routes + form handling (FR1FR2,
│ │ │ # FR6FR8, FR11)
│ │ ├── location.py # ICAO lookup (OurAirports) + Nominatim
│ │ │ # geocoding (FR3FR5)
│ │ ├── 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 (FR21FR29)
│ ├── projection.py # (lat,lon) → (x,y) pixel + MapBounds dataclass
│ ├── basemap.py # background.png load + memory hold (FR16FR17)
│ ├── aircraft.py # Per-aircraft draw: arrow, label, trail,
│ │ # stale outline (FR21FR26, FR28)
│ ├── airspace.py # Airspace GeoJSON → outline draw (FR19)
│ ├── colours.py # altitude_ft → display colour (FR23)
│ └── icons.py # ADS-B category + callsign → icon type (FR24FR24a)
└── 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 | FR1FR11 | `provisioning/portal.py`, `location.py`, `tiles.py`, `wifi.py` |
| Reset & Recovery | FR12FR15 | `gpio_ctrl.py`, `provisioning/config.py`, `provision.py` |
| Map Display | FR16FR19 | `renderer/basemap.py`, `renderer/airspace.py`, `renderer/projection.py` |
| Aircraft Display | FR20FR26 | `fetcher.py`, `renderer/aircraft.py`, `renderer/colours.py`, `renderer/icons.py` |
| Stale Data Handling | FR27FR29 | `fetcher.py` (detection), `renderer/renderer.py` (stale flag + outline) |
| Refresh Loop & Boot | FR30FR33 | `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 (FR1FR11) | `provisioning/portal.py`, `location.py`, `tiles.py`, `wifi.py`, `config.py` |
| Reset & Recovery (FR12FR15) | `gpio_ctrl.py`, `config.py`, reset flow via `os.execvp` in `main.py` |
| Map Display (FR16FR19) | `renderer/basemap.py`, `renderer/airspace.py`, `renderer/projection.py` |
| Aircraft Display (FR20FR26) | `fetcher.py`, `renderer/aircraft.py`, `colours.py`, `icons.py` |
| Stale Data Handling (FR27FR29) | `fetcher.py` (detection), `renderer/renderer.py` (stale flag + outline) |
| Refresh Loop & Boot (FR30FR33) | `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 (FR12FR15)** — 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. 1020MHz) 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`