b2afa7fb4b
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>
757 lines
33 KiB
Markdown
757 lines
33 KiB
Markdown
---
|
||
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`
|