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:
@@ -0,0 +1,756 @@
|
||||
---
|
||||
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
lastStep: 8
|
||||
status: 'complete'
|
||||
completedAt: '2026-04-22'
|
||||
inputDocuments: ['prd.md']
|
||||
workflowType: 'architecture'
|
||||
project_name: 'planeMapper'
|
||||
user_name: 'Matt.edholm'
|
||||
date: '2026-04-22'
|
||||
---
|
||||
|
||||
# Architecture Decision Document
|
||||
|
||||
_This document builds collaboratively through step-by-step discovery. Sections are appended as we work through each architectural decision together._
|
||||
|
||||
## Project Context Analysis
|
||||
|
||||
### Requirements Overview
|
||||
|
||||
**Functional Requirements:**
|
||||
33 FRs across 6 functional areas:
|
||||
- Device Setup & Provisioning (FR1–FR11): captive portal, location resolution (ICAO/address), tile download, cache validation, WiFi radio kill
|
||||
- Reset & Recovery (FR12–FR15): GPIO button hold, LED feedback, config wipe, setup screen
|
||||
- Map Display (FR16–FR19): OSM base map, home marker, OpenAIP airspace outlines
|
||||
- Aircraft Display (FR20–FR26): dump1090 fetch, heading arrow, callsign/altitude label, altitude colour bands, type icons, 5-dot trail, MLAT distinction
|
||||
- Stale Data Handling (FR27–FR29): decode gap detection, stale visual indicator, recovery
|
||||
- Refresh Loop & Boot (FR30–FR33): 60s cycle, indefinite loop, power-cycle resume, startup screen
|
||||
|
||||
**Non-Functional Requirements:**
|
||||
- Performance: Full radar render ≤45s on Pi Zero 2W; base map layer pre-composited and cached in memory; dump1090 fetch timeout 5s; SPI transfer only after render complete
|
||||
- Reliability: 72+ hours continuous operation; recovery within 5min of unclean power loss; dump1090 failure must not crash refresh loop
|
||||
- Storage: Tile cache ≤2GB on 16GB SD card; validated at provisioning before WiFi kill
|
||||
- Integration: dump1090 (local JSON), Nominatim (provisioning only), OurAirports (bundled), OpenAIP (cached at provisioning)
|
||||
- Security: WiFi off in operational state; no external calls in operational mode; plaintext config on SD acceptable for single-user personal device
|
||||
|
||||
**Scale & Complexity:**
|
||||
|
||||
- Primary domain: IoT/Embedded Python
|
||||
- Complexity level: Medium
|
||||
- Estimated architectural components: ~6 subsystems
|
||||
|
||||
### Technical Constraints & Dependencies
|
||||
|
||||
- Pi Zero 2W: quad-core Cortex-A53 @ 1GHz, 512MB RAM — strict render budget (45s)
|
||||
- Single USB port — RTL-SDR via OTG adapter; no other USB peripherals
|
||||
- Waveshare 7.3" 6-colour e-ink HAT — SPI interface, 800×480, full-panel refresh only
|
||||
- 16GB SD card — OS + software + tile cache must fit within 2GB tile budget
|
||||
- dump1090 JSON feed is best-effort — callsign, category, altitude may be absent
|
||||
- Permanently offline post-provisioning — all runtime dependencies must be pre-cached
|
||||
|
||||
### Cross-Cutting Concerns Identified
|
||||
|
||||
- **Offline-first:** Every runtime dependency must be pre-resolved and locally available
|
||||
- **Graceful degradation:** Missing ADS-B fields, dump1090 failure, and stale data handled without crash or blank display at every layer
|
||||
- **State isolation:** Provisioning and Operational modes are architecturally distinct; shared code should be minimal and explicit
|
||||
- **Hardware resource budget:** Memory and CPU constraints affect render pipeline design, caching strategy, and tile format choices
|
||||
- **GPIO/render loop concurrency:** Button hold detection and LED feedback must be non-blocking alongside the 60s render cycle
|
||||
|
||||
## Starter Template Evaluation
|
||||
|
||||
### Primary Technology Domain
|
||||
|
||||
IoT/Embedded Python — no formal scaffold generator. Baseline established here.
|
||||
|
||||
### Selected Foundation: src/ layout, pip, pytest
|
||||
|
||||
**Rationale:** `src/` layout prevents import shadowing and supports `pip install -e .` for development. pip + requirements.txt is the correct deployment tool on Pi Zero 2W — no lock-file resolution overhead on-device. gpiozero chosen over RPi.GPIO for its MockFactory support, enabling off-hardware GPIO testing.
|
||||
|
||||
**Runtime:**
|
||||
- Python 3.11 (Raspberry Pi OS Bookworm default)
|
||||
- Pure Python, no compilation step — git pull on Pi is the deployment path
|
||||
|
||||
**Dependencies (current versions):**
|
||||
- Pillow 12.2.0 — image composition and rendering
|
||||
- gpiozero 2.0.1 — GPIO button/LED; MockFactory for off-hardware testing
|
||||
- Flask 3.1.3 — captive portal HTTP server (provisioning only)
|
||||
- requests 2.33.1 — dump1090 JSON fetch, Nominatim geocoding (provisioning only)
|
||||
|
||||
**Testing:**
|
||||
- pytest 9.0.3
|
||||
- gpiozero MockFactory for GPIO boundary tests
|
||||
- `DisplayInterface` protocol (ABC) — real `WaveshareDisplay` + `NullDisplay` for testing
|
||||
- `FetcherInterface` protocol — real HTTP fetcher + `FileFixtureFetcher` from JSON fixture
|
||||
- Stateful `Renderer` owns tile composite cache and trail history — enables isolated unit tests
|
||||
|
||||
**Linting/Formatting:**
|
||||
- ruff 0.15.11 — single-tool replacement for flake8 + black + isort
|
||||
|
||||
**Project Scaffold:**
|
||||
```
|
||||
planeMapper/
|
||||
├── src/
|
||||
│ └── planemapper/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # entry point — operational radar loop
|
||||
│ ├── provision.py # entry point — captive portal + provisioning
|
||||
│ ├── provisioning/ # portal, geocoding, tile download, WiFi kill
|
||||
│ ├── renderer/ # stateful Renderer: tile composite + trail history
|
||||
│ ├── fetcher.py # FetcherInterface + HTTP impl + FileFixtureFetcher
|
||||
│ ├── gpio_ctrl.py # button hold detection + LED via gpiozero
|
||||
│ └── display.py # DisplayInterface + WaveshareDisplay + NullDisplay
|
||||
├── tests/
|
||||
├── pyproject.toml
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
**Key structural decisions:**
|
||||
- Two process entry points: `planemapper-provision` and `planemapper-radar` — provisioning and operational code never share a runtime context
|
||||
- `Renderer` is long-lived across the 60s loop — owns tile composite (cached in memory) and trail history `dict[str, deque[Position]]`; state is lost on restart (acceptable)
|
||||
- Render pipeline is phase-instrumented: tile retrieval, overlay render, SPI transfer logged individually; warn at 40s total, alert at 50s
|
||||
- Systemd `Restart=always` — no state persistence across restarts; tile files on disk are the only durable operational state
|
||||
|
||||
**Service structure:**
|
||||
- Two systemd units: `planemapper-provision.service` (runs once at first boot / post-reset, exits on completion) and `planemapper-radar.service` (perpetual, `After=` provision)
|
||||
|
||||
## Core Architectural Decisions
|
||||
|
||||
### Decision Priority Analysis
|
||||
|
||||
**Critical Decisions (Block Implementation):**
|
||||
- Map background strategy: pre-composited PNG at provisioning
|
||||
- Config file format and location
|
||||
- Stale data definition and visual treatment
|
||||
- Captive portal technology stack
|
||||
|
||||
**Important Decisions (Shape Architecture):**
|
||||
- Airspace data format and caching strategy
|
||||
- Logging destination
|
||||
- Render pipeline instrumentation thresholds
|
||||
|
||||
**Deferred Decisions (Post-MVP):**
|
||||
- SD card image build automation (manual flash acceptable for MVP)
|
||||
- OSM tile zoom level tuning (implementation detail, tuned during development)
|
||||
|
||||
---
|
||||
|
||||
### Data Architecture
|
||||
|
||||
**Map Background**
|
||||
- Strategy: pre-composited single `background.png` (800×480) generated during provisioning; loaded once into Renderer memory at radar startup
|
||||
- Tile download source: tile.openstreetmap.org (single bulk download at provisioning, acceptable for personal device use)
|
||||
- Zoom level: determined at provisioning time from coverage radius; baked into background.png; not stored separately in config
|
||||
- Rationale: eliminates all tile I/O from the operational render loop; background is fixed for a given home location and radius
|
||||
|
||||
**Config File**
|
||||
- Format: JSON (Python stdlib, zero extra deps)
|
||||
- Path: `/etc/planemapper/config.json`
|
||||
- Contents: home lat/lon, coverage radius (nm), WiFi SSID/password, provisioning state flag
|
||||
- Accessible to both `planemapper-provision` and `planemapper-radar` services
|
||||
- On reset: config file wiped by provision service before returning to portal state
|
||||
|
||||
**Airspace Data**
|
||||
- Format: GeoJSON (OpenAIP API, downloaded during provisioning)
|
||||
- Path: `/etc/planemapper/airspace.geojson`
|
||||
- Rendered as circular outlines only (MVP); colour fills deferred to Phase 2
|
||||
- No runtime network dependency — purely cached local data
|
||||
|
||||
**Trail History**
|
||||
- Storage: in-memory only — `dict[str, deque[Position]]` inside Renderer, max 5 entries per ICAO hex
|
||||
- Persistence: none — lost on restart (acceptable; cosmetic data only)
|
||||
|
||||
---
|
||||
|
||||
### Authentication & Security
|
||||
|
||||
All decisions established by PRD:
|
||||
- WiFi radio killed via `rfkill block wifi` after successful provisioning
|
||||
- Captive portal is open, local-only, and short-lived — no auth required
|
||||
- Config stored plaintext on SD card — acceptable for single-user personal device
|
||||
- No external network calls in operational state — network attack surface is zero
|
||||
|
||||
---
|
||||
|
||||
### Captive Portal Stack
|
||||
|
||||
- **hostapd** — manages Wi-Fi AP mode (`planeMapper-setup` SSID)
|
||||
- **dnsmasq** — DHCP server + DNS resolver (resolves all queries to Pi IP, triggering captive portal detection on phones)
|
||||
- **Flask 3.1.3** — serves setup UI, handles form submission, orchestrates provisioning sequence
|
||||
- Portal flow: AP up → user connects → dnsmasq redirects DNS → Flask intercepts HTTP probe → portal page served → user submits → Flask joins home WiFi, downloads tiles, validates, kills WiFi radio
|
||||
|
||||
---
|
||||
|
||||
### Stale Data Handling
|
||||
|
||||
- **Threshold:** 1 missed fetch cycle (60 seconds) = stale state
|
||||
- **Trigger:** dump1090 HTTP fetch returns error, times out (>5s), or returns empty aircraft list when previously non-empty
|
||||
- **Visual indicator:** stale aircraft rendered as outlines only (no fill) — effectively dimmed; last known positions retained on display
|
||||
- **Recovery:** next successful fetch restores normal filled rendering automatically
|
||||
- **Unambiguous parity:** slow render (>60s) treated identically to decode gap — same stale path, no separate handling
|
||||
|
||||
---
|
||||
|
||||
### Infrastructure & Deployment
|
||||
|
||||
**Systemd Units**
|
||||
- `planemapper-provision.service` — runs at first boot or post-reset; exits cleanly on completion; Type=oneshot
|
||||
- `planemapper-radar.service` — perpetual operational loop; `After=planemapper-provision.service`; `Restart=always`
|
||||
|
||||
**Logging**
|
||||
- Destination: stdout → systemd journal (journald captures automatically)
|
||||
- Access: `journalctl -u planemapper-radar -f`
|
||||
- No log rotation config needed — journald handles retention
|
||||
|
||||
**Render Pipeline Instrumentation**
|
||||
- Phase timing logged each cycle: tile load, aircraft overlay, SPI transfer
|
||||
- Warn threshold: total render > 40s
|
||||
- Alert threshold: total render > 50s
|
||||
- Stale path triggered if render exceeds 60s cycle boundary
|
||||
|
||||
**Deployment**
|
||||
- Git pull on Pi — no build step required (pure Python)
|
||||
- `pip install -e .` for dev; `pip install .` for production install
|
||||
- SD card reflash is the update path for OS-level changes
|
||||
|
||||
## Implementation Patterns & Consistency Rules
|
||||
|
||||
### Critical Conflict Points Identified
|
||||
|
||||
7 areas where AI agents could make different choices without explicit rules.
|
||||
|
||||
---
|
||||
|
||||
### Data Type Patterns
|
||||
|
||||
**Aircraft Data — `@dataclass` with optional fields:**
|
||||
```python
|
||||
@dataclass
|
||||
class Aircraft:
|
||||
icao: str
|
||||
lat: float
|
||||
lon: float
|
||||
heading: float = 0.0
|
||||
altitude_ft: int = 0
|
||||
callsign: str = ""
|
||||
category: str = ""
|
||||
is_mlat: bool = False
|
||||
is_stale: bool = False
|
||||
```
|
||||
- All ADS-B optional fields default to a safe sentinel value
|
||||
- Stale flag carried on the dataclass, not inferred at render time
|
||||
- All internal code works with `Aircraft` instances, never raw dicts
|
||||
- Fetcher converts dump1090 JSON → `Aircraft` at the boundary; nothing downstream touches raw JSON
|
||||
|
||||
**Position trail:**
|
||||
```python
|
||||
from collections import deque
|
||||
trails: dict[str, deque[tuple[float, float]]] = {} # icao → deque[(lat, lon)]
|
||||
```
|
||||
Max 5 entries per aircraft, oldest entry at index 0.
|
||||
|
||||
---
|
||||
|
||||
### Coordinate Patterns
|
||||
|
||||
**Convention: `(lat, lon)` throughout all internal code.**
|
||||
|
||||
- All `Aircraft` fields, all internal function signatures, all pixel projection calls use `(lat, lon)` order
|
||||
- GeoJSON parsing (airspace data) explicitly reverses at the parse boundary:
|
||||
`lat, lon = feature["geometry"]["coordinates"][1], feature["geometry"]["coordinates"][0]`
|
||||
- The projection function in `renderer/` is the single location where `(lat, lon)` → `(x, y)` pixel conversion happens; nothing else does projection
|
||||
- Anti-pattern: never pass `(lon, lat)` to any internal function
|
||||
|
||||
---
|
||||
|
||||
### Units Patterns
|
||||
|
||||
**Altitude: feet throughout — preserve dump1090 native units.**
|
||||
|
||||
- Altitude band thresholds defined in feet in `constants.py`
|
||||
- No metres conversion anywhere in the codebase
|
||||
- `altitude_ft: int` field name makes units explicit
|
||||
|
||||
---
|
||||
|
||||
### Interface Patterns
|
||||
|
||||
**Hardware boundaries use `typing.Protocol`:**
|
||||
```python
|
||||
from typing import Protocol
|
||||
|
||||
class DisplayInterface(Protocol):
|
||||
def show(self, image: Image.Image) -> None: ...
|
||||
|
||||
class FetcherInterface(Protocol):
|
||||
def fetch(self) -> list[Aircraft]: ...
|
||||
```
|
||||
- No explicit inheritance required — `NullDisplay`, `WaveshareDisplay`, `FileFixtureFetcher`, and `HttpFetcher` simply implement the method signatures
|
||||
- Protocols live in their respective module files (`display.py`, `fetcher.py`)
|
||||
- All production code typed against the Protocol, never the concrete class
|
||||
|
||||
---
|
||||
|
||||
### Constants Patterns
|
||||
|
||||
**Single `src/planemapper/constants.py` — all project-wide fixed values live here:**
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
# Display geometry
|
||||
DISPLAY_WIDTH = 800
|
||||
DISPLAY_HEIGHT = 480
|
||||
|
||||
# Timing
|
||||
REFRESH_INTERVAL_S = 60
|
||||
FETCH_TIMEOUT_S = 5
|
||||
RENDER_WARN_S = 40
|
||||
RENDER_ALERT_S = 50
|
||||
STALE_CYCLES = 1
|
||||
RESET_HOLD_S = 3
|
||||
|
||||
# Altitude band upper bounds (feet) — index maps to ALTITUDE_COLOURS
|
||||
ALTITUDE_BANDS_FT = [1500, 5000, 10000, 20000, 35000, 99999]
|
||||
|
||||
# E-ink 6-colour palette (Waveshare Spectra 6: black, white, red, yellow, blue, green)
|
||||
# Each tuple is an RGB value as used by Pillow
|
||||
COLOUR_BLACK = (0, 0, 0)
|
||||
COLOUR_WHITE = (255, 255, 255)
|
||||
COLOUR_RED = (255, 0, 0)
|
||||
COLOUR_YELLOW = (255, 255, 0)
|
||||
COLOUR_BLUE = (0, 0, 255)
|
||||
COLOUR_GREEN = (0, 255, 0)
|
||||
|
||||
# Altitude band → display colour (index aligns with ALTITUDE_BANDS_FT)
|
||||
ALTITUDE_COLOURS = [
|
||||
COLOUR_GREEN, # surface – 1,500ft
|
||||
COLOUR_BLUE, # 1,500 – 5,000ft
|
||||
COLOUR_YELLOW, # 5,000 – 10,000ft
|
||||
COLOUR_RED, # 10,000 – 20,000ft
|
||||
COLOUR_BLACK, # 20,000 – 35,000ft
|
||||
COLOUR_WHITE, # 35,000ft+
|
||||
]
|
||||
|
||||
# UI colours
|
||||
COLOUR_STALE_OUTLINE = COLOUR_BLACK # outline-only colour for stale aircraft
|
||||
COLOUR_HOME_MARKER = COLOUR_RED
|
||||
COLOUR_AIRSPACE = COLOUR_BLUE
|
||||
COLOUR_TRAIL = COLOUR_BLACK
|
||||
|
||||
# Trail
|
||||
TRAIL_MAX_DOTS = 5
|
||||
TRAIL_DOT_SIZE_MAX = 6 # px, most recent dot
|
||||
TRAIL_DOT_SIZE_MIN = 2 # px, oldest dot
|
||||
|
||||
# Paths
|
||||
CONFIG_PATH = Path("/etc/planemapper/config.json")
|
||||
AIRSPACE_PATH = Path("/etc/planemapper/airspace.geojson")
|
||||
BACKGROUND_PATH = Path("/etc/planemapper/background.png")
|
||||
```
|
||||
- No module hardcodes a value that appears in `constants.py` — colours, sizes,
|
||||
paths, timing, and thresholds all live here
|
||||
- Anti-pattern: `(255, 0, 0)` inline anywhere; `if altitude > 10000` outside
|
||||
constants logic; `time.sleep(60)` with a literal
|
||||
|
||||
---
|
||||
|
||||
### Type Annotation Patterns
|
||||
|
||||
**All function signatures annotated, all dataclass fields typed:**
|
||||
```python
|
||||
# Correct
|
||||
def project(lat: float, lon: float, bounds: MapBounds) -> tuple[int, int]: ...
|
||||
|
||||
# Anti-pattern
|
||||
def project(lat, lon, bounds): ...
|
||||
```
|
||||
- ruff enforces annotation presence
|
||||
- Return types always specified
|
||||
- `Optional[X]` used where None is a valid return
|
||||
|
||||
---
|
||||
|
||||
### Logging Patterns
|
||||
|
||||
**Levels:**
|
||||
|
||||
| Level | When |
|
||||
|---|---|
|
||||
| `DEBUG` | Per-aircraft render decisions, individual fetch field parsing |
|
||||
| `INFO` | Each render cycle start/complete with phase timings |
|
||||
| `WARNING` | Render > 40s, stale state entered or exited |
|
||||
| `ERROR` | Fetch failure, SPI transfer failure, required file not found |
|
||||
|
||||
**Format:** stdlib `logging` module, no custom formatter — journald adds timestamps and service context automatically.
|
||||
|
||||
```python
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
# Usage: log.info("render complete: tile=%.1fs overlay=%.1fs spi=%.1fs", t1, t2, t3)
|
||||
```
|
||||
|
||||
Anti-pattern: `print()` anywhere in production code; `log.error()` for expected conditions (stale data is WARNING, not ERROR).
|
||||
|
||||
---
|
||||
|
||||
### Error Handling Patterns
|
||||
|
||||
**Render loop must not crash — single try/except at the loop boundary:**
|
||||
```python
|
||||
while True:
|
||||
try:
|
||||
_run_one_cycle(renderer, fetcher, display)
|
||||
except Exception:
|
||||
log.error("cycle failed", exc_info=True)
|
||||
# stale path — renderer retains last good frame
|
||||
time.sleep(REFRESH_INTERVAL_S)
|
||||
```
|
||||
- Internal functions raise exceptions normally; the loop catches all
|
||||
- Fetcher raises on failure; main loop catches and triggers stale path
|
||||
- No bare `except:` anywhere except the top-level loop boundary
|
||||
|
||||
---
|
||||
|
||||
### All AI Agents MUST:
|
||||
|
||||
1. Use `Aircraft` dataclass — never pass raw dicts beyond the fetch boundary
|
||||
2. Use `(lat, lon)` order — reverse GeoJSON at parse time only
|
||||
3. Import all magic numbers from `constants.py` — no inline literals for thresholds, paths, or timing values
|
||||
4. Annotate all function signatures
|
||||
5. Log at the correct level per the table above
|
||||
6. Use `typing.Protocol` for hardware interfaces — never type against concrete classes
|
||||
7. Raise exceptions from internal functions; catch only at the render loop boundary
|
||||
|
||||
## Project Structure & Boundaries
|
||||
|
||||
### Complete Project Directory Structure
|
||||
|
||||
```
|
||||
planeMapper/
|
||||
├── pyproject.toml # Package metadata + entry points + ruff config
|
||||
├── requirements.txt # Pinned runtime deps for pip install on Pi
|
||||
├── requirements-dev.txt # pytest, ruff, gpiozero[mock]
|
||||
├── .gitignore
|
||||
├── systemd/
|
||||
│ ├── planemapper-provision.service # Type=oneshot; runs at first boot / post-reset
|
||||
│ └── planemapper-radar.service # Restart=always; After=planemapper-provision
|
||||
├── data/
|
||||
│ └── airports.csv # Bundled OurAirports DB for ICAO lookup (FR4)
|
||||
├── src/
|
||||
│ └── planemapper/
|
||||
│ ├── __init__.py
|
||||
│ ├── constants.py # All magic numbers, paths, thresholds
|
||||
│ ├── models.py # Aircraft dataclass only — cross-boundary types
|
||||
│ ├── main.py # Entry: operational radar loop (FR30–FR33)
|
||||
│ ├── provision.py # Entry: provisioning loop with ProvisioningError
|
||||
│ │ # recovery — never imports from renderer/
|
||||
│ ├── fetcher.py # FetcherInterface Protocol + HttpFetcher
|
||||
│ │ # + FileFixtureFetcher (FR20, FR27–FR29)
|
||||
│ ├── gpio_ctrl.py # ButtonHoldDetector + LEDController via
|
||||
│ │ # gpiozero (FR12–FR13)
|
||||
│ ├── display.py # DisplayInterface Protocol + WaveshareDisplay
|
||||
│ │ # + NullDisplay (FR30, FR33)
|
||||
│ ├── provisioning/
|
||||
│ │ ├── __init__.py # ProvisioningError exception definition
|
||||
│ │ ├── portal.py # Flask app + routes + form handling (FR1–FR2,
|
||||
│ │ │ # FR6–FR8, FR11)
|
||||
│ │ ├── location.py # ICAO lookup (OurAirports) + Nominatim
|
||||
│ │ │ # geocoding (FR3–FR5)
|
||||
│ │ ├── tiles.py # OSM tile download + background.png
|
||||
│ │ │ # composition + cache validation (FR9, FR9a)
|
||||
│ │ ├── airspace.py # OpenAIP GeoJSON download + cache (FR19 data)
|
||||
│ │ ├── wifi.py # hostapd/dnsmasq/rfkill — raises
|
||||
│ │ │ # ProvisioningError on subprocess failure
|
||||
│ │ └── config.py # Config read/write/wipe — single module
|
||||
│ │ # touching /etc/planemapper/config.json
|
||||
│ └── renderer/
|
||||
│ ├── __init__.py
|
||||
│ ├── renderer.py # Stateful Renderer: owns PIL composite +
|
||||
│ │ # trail history dict (FR21–FR29)
|
||||
│ ├── projection.py # (lat,lon) → (x,y) pixel + MapBounds dataclass
|
||||
│ ├── basemap.py # background.png load + memory hold (FR16–FR17)
|
||||
│ ├── aircraft.py # Per-aircraft draw: arrow, label, trail,
|
||||
│ │ # stale outline (FR21–FR26, FR28)
|
||||
│ ├── airspace.py # Airspace GeoJSON → outline draw (FR19)
|
||||
│ ├── colours.py # altitude_ft → display colour (FR23)
|
||||
│ └── icons.py # ADS-B category + callsign → icon type (FR24–FR24a)
|
||||
└── tests/
|
||||
├── conftest.py # Shared fixtures: MockFactory, NullDisplay,
|
||||
│ # FileFixtureFetcher, sample_config (patches
|
||||
│ # CONFIG_PATH to tmp_path — no /etc/ dependency)
|
||||
├── fixtures/
|
||||
│ ├── aircraft_sample.json # Fixture library: happy path, missing callsign,
|
||||
│ │ # missing altitude, MLAT flag, empty list,
|
||||
│ │ # altitude band boundary values
|
||||
│ └── airspace_sample.geojson # OpenAIP-format fixture
|
||||
├── test_fetcher.py # HttpFetcher timeout, stale trigger, field parsing
|
||||
├── test_models.py # Aircraft defaults, optional field handling
|
||||
├── test_projection.py # Projection correctness, boundary cases
|
||||
├── test_colours.py # Altitude band thresholds and edge values
|
||||
├── test_icons.py # Type classification: category, callsign, altitude fallback
|
||||
├── test_renderer.py # Trail accumulation, stale flag, outline render
|
||||
├── test_pipeline.py # Smoke: FileFixtureFetcher → Renderer → NullDisplay
|
||||
│ # one full cycle end-to-end
|
||||
├── test_gpio_ctrl.py # Button hold timing, LED state via MockFactory
|
||||
└── provisioning/
|
||||
├── test_location.py # ICAO lookup, Nominatim response parsing
|
||||
├── test_tiles.py # Tile compositing, cache validation logic
|
||||
├── test_config.py # Config read/write/wipe cycle (uses sample_config)
|
||||
└── test_provision_loop.py # ProvisioningError → reset_to_portal_state()
|
||||
```
|
||||
|
||||
### Entry Points (pyproject.toml)
|
||||
|
||||
```toml
|
||||
[project.scripts]
|
||||
planemapper-radar = "planemapper.main:main"
|
||||
planemapper-provision = "planemapper.provision:main"
|
||||
```
|
||||
|
||||
Systemd units invoke these console scripts directly. No shell wrappers.
|
||||
|
||||
---
|
||||
|
||||
### Architectural Boundaries
|
||||
|
||||
**Fetch boundary — `fetcher.py`**
|
||||
Converts dump1090 JSON → `list[Aircraft]`. Raises on timeout/error. Nothing beyond touches raw JSON.
|
||||
|
||||
**Render boundary — `renderer/renderer.py`**
|
||||
Accepts `list[Aircraft]`, returns `PIL.Image`. Owns trail history and stale state. Nothing outside `renderer/` calls Pillow draw primitives.
|
||||
|
||||
**Display boundary — `display.py`**
|
||||
Accepts `PIL.Image`, drives SPI. `NullDisplay` logs + no-ops for tests.
|
||||
|
||||
**GPIO boundary — `gpio_ctrl.py`**
|
||||
`ButtonHoldDetector.check() -> bool` — non-blocking, polled once per cycle.
|
||||
|
||||
**Config boundary — `provisioning/config.py`**
|
||||
Single module reading/writing `/etc/planemapper/config.json`. Tests patch `CONFIG_PATH` to `tmp_path` via `conftest.py`.
|
||||
|
||||
**Provisioning boundary — `provision.py`**
|
||||
Never imported by `main.py`. Separate process entry point. Loop structure:
|
||||
```python
|
||||
while not provisioned:
|
||||
try:
|
||||
run_provisioning_sequence()
|
||||
provisioned = True
|
||||
except ProvisioningError as e:
|
||||
log.error("provisioning failed: %s", e)
|
||||
reset_to_portal_state()
|
||||
```
|
||||
|
||||
**wifi.py subprocess boundary**
|
||||
Every `rfkill`/`hostapd`/`dnsmasq` call checks return code explicitly. Raises `ProvisioningError` on failure. No silent partial state.
|
||||
|
||||
---
|
||||
|
||||
### Requirements → Structure Mapping
|
||||
|
||||
| FR Group | FRs | Primary Location |
|
||||
|---|---|---|
|
||||
| Device Setup & Provisioning | FR1–FR11 | `provisioning/portal.py`, `location.py`, `tiles.py`, `wifi.py` |
|
||||
| Reset & Recovery | FR12–FR15 | `gpio_ctrl.py`, `provisioning/config.py`, `provision.py` |
|
||||
| Map Display | FR16–FR19 | `renderer/basemap.py`, `renderer/airspace.py`, `renderer/projection.py` |
|
||||
| Aircraft Display | FR20–FR26 | `fetcher.py`, `renderer/aircraft.py`, `renderer/colours.py`, `renderer/icons.py` |
|
||||
| Stale Data Handling | FR27–FR29 | `fetcher.py` (detection), `renderer/renderer.py` (stale flag + outline) |
|
||||
| Refresh Loop & Boot | FR30–FR33 | `main.py`, `display.py` |
|
||||
|
||||
---
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
main.py (60s loop)
|
||||
│
|
||||
├─→ fetcher.fetch() → list[Aircraft] (raises on failure → stale path)
|
||||
├─→ renderer.render(aircraft) → PIL.Image (holds composite + trail in memory)
|
||||
├─→ display.show(image) (SPI; NullDisplay in tests)
|
||||
└─→ gpio_ctrl.check() → bool (reset? → exec provision.py)
|
||||
|
||||
provision.py (one-shot loop)
|
||||
│
|
||||
├─→ wifi.start_ap() → hostapd + dnsmasq (raises ProvisioningError on fail)
|
||||
├─→ portal.run() → Flask blocks until user submits
|
||||
├─→ location.resolve() → (lat, lon)
|
||||
├─→ wifi.join_home() → connects home WiFi
|
||||
├─→ tiles.download() → background.png composited + validated
|
||||
├─→ airspace.download() → airspace.geojson cached
|
||||
├─→ config.write() → /etc/planemapper/config.json
|
||||
└─→ wifi.kill() → rfkill block wifi (raises ProvisioningError on fail)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### External Integration Points
|
||||
|
||||
| Integration | When | Module |
|
||||
|---|---|---|
|
||||
| dump1090 JSON (`localhost:8080`) | Every 60s in operational mode | `fetcher.py` |
|
||||
| Nominatim geocoding API | Once during provisioning | `provisioning/location.py` |
|
||||
| tile.openstreetmap.org | Once during provisioning | `provisioning/tiles.py` |
|
||||
| OpenAIP API | Once during provisioning | `provisioning/airspace.py` |
|
||||
| OurAirports CSV | Bundled; read at provisioning | `provisioning/location.py` |
|
||||
|
||||
## Architecture Validation Results
|
||||
|
||||
### Coherence Validation ✅
|
||||
|
||||
**Decision Compatibility:**
|
||||
All packages compatible on Pi Zero 2W / Raspberry Pi OS Bookworm: Python 3.11,
|
||||
Pillow 12.2.0, gpiozero 2.0.1, Flask 3.1.3, requests 2.33.1. All runtime
|
||||
dependencies are pip-installable with no build toolchain. `typing.Protocol`,
|
||||
`dataclasses`, and `json` are Python 3.11 stdlib — zero dependency risk.
|
||||
|
||||
**Pattern Consistency:**
|
||||
`Aircraft` dataclass flows cleanly through fetch → render → display pipeline.
|
||||
`(lat, lon)` convention documented with single explicit reversal point at GeoJSON
|
||||
parse boundary. `constants.py` is the single source for all thresholds, paths,
|
||||
colours, sizes, and timing values. `typing.Protocol` hardware interfaces align
|
||||
with test double strategy. Logging levels, error handling boundary, and type
|
||||
annotation rules are internally consistent.
|
||||
|
||||
**Structure Alignment:**
|
||||
Two entry points map directly to two systemd units. `provisioning/` import
|
||||
boundary enforced by ruff rule. `renderer/` owns all Pillow draw calls. `tests/`
|
||||
mirrors `src/` structure with full fixture library. `conftest.py` patches
|
||||
`CONFIG_PATH` to `tmp_path` — no `/etc/` dependency in CI.
|
||||
|
||||
---
|
||||
|
||||
### Requirements Coverage Validation ✅
|
||||
|
||||
**Functional Requirements (33/33 covered):**
|
||||
|
||||
| FR Group | Coverage |
|
||||
|---|---|
|
||||
| Device Setup & Provisioning (FR1–FR11) | `provisioning/portal.py`, `location.py`, `tiles.py`, `wifi.py`, `config.py` |
|
||||
| Reset & Recovery (FR12–FR15) | `gpio_ctrl.py`, `config.py`, reset flow via `os.execvp` in `main.py` |
|
||||
| Map Display (FR16–FR19) | `renderer/basemap.py`, `renderer/airspace.py`, `renderer/projection.py` |
|
||||
| Aircraft Display (FR20–FR26) | `fetcher.py`, `renderer/aircraft.py`, `colours.py`, `icons.py` |
|
||||
| Stale Data Handling (FR27–FR29) | `fetcher.py` (detection), `renderer/renderer.py` (stale flag + outline) |
|
||||
| Refresh Loop & Boot (FR30–FR33) | `main.py`, `display.py` |
|
||||
|
||||
**Non-Functional Requirements:**
|
||||
- Performance: base map cached in memory; render phases instrumented; dump1090 fetch timeout 5s; SPI transfer after render complete ✅
|
||||
- Reliability: `Restart=always`; loop boundary try/except isolates dump1090 failures; power recovery via systemd ✅
|
||||
- Storage: tile cache ≤2GB validated in `tiles.py` before WiFi kill ✅
|
||||
- Security: WiFi killed via `rfkill`; no external calls in operational state ✅
|
||||
|
||||
---
|
||||
|
||||
### Gaps Found & Resolved
|
||||
|
||||
**Gap 1 — Reset flow mechanics (FR12–FR15)** — RESOLVED
|
||||
`main.py` reset handler:
|
||||
1. Calls `config.wipe()`
|
||||
2. Calls `display.show(setup_screen_image)` (FR15)
|
||||
3. Calls `os.execvp('planemapper-provision', ['planemapper-provision'])`
|
||||
|
||||
systemd sees `planemapper-radar` exit → restarts → provision runs → writes config → exits → systemd restarts radar into operational mode. No IPC required.
|
||||
|
||||
**Gap 2 — OurAirports bundled data** — RESOLVED
|
||||
`airports.csv` moved to `src/planemapper/data/airports.csv`. Accessed via `importlib.resources`. pyproject.toml:
|
||||
```toml
|
||||
[tool.setuptools.package-data]
|
||||
"planemapper" = ["data/airports.csv"]
|
||||
```
|
||||
|
||||
**Gap 3 — constants.py scope clarification** — RESOLVED
|
||||
`constants.py` scope expanded to include: full 6-colour palette with semantic
|
||||
mappings, trail dot sizing, reset hold time, and all UI colours. No inline RGB
|
||||
tuples, no literal sleeps, no hardcoded paths anywhere in the codebase.
|
||||
|
||||
---
|
||||
|
||||
### Corrected Project Structure (delta from step 6)
|
||||
|
||||
```
|
||||
src/
|
||||
└── planemapper/
|
||||
├── data/
|
||||
│ └── airports.csv # Moved here from top-level data/; accessed via
|
||||
│ # importlib.resources in provisioning/location.py
|
||||
└── ...
|
||||
```
|
||||
Top-level `data/` directory removed.
|
||||
|
||||
`main.py` reset sequence:
|
||||
```
|
||||
gpio_ctrl.check() → True
|
||||
→ config.wipe()
|
||||
→ display.show(setup_screen)
|
||||
→ os.execvp('planemapper-provision', ['planemapper-provision'])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Architecture Completeness Checklist
|
||||
|
||||
**✅ Requirements Analysis**
|
||||
- [x] Project context thoroughly analysed
|
||||
- [x] Scale and complexity assessed (Medium, IoT/Embedded Python)
|
||||
- [x] Technical constraints identified (Pi Zero 2W, 512MB RAM, 45s budget)
|
||||
- [x] Cross-cutting concerns mapped (offline-first, graceful degradation, state isolation)
|
||||
|
||||
**✅ Architectural Decisions**
|
||||
- [x] Critical decisions documented with verified versions
|
||||
- [x] Technology stack fully specified (Python 3.11, all deps pinned)
|
||||
- [x] Integration patterns defined (fetch/render/display/GPIO boundaries)
|
||||
- [x] Performance considerations addressed (phase instrumentation, memory caching)
|
||||
|
||||
**✅ Implementation Patterns**
|
||||
- [x] Data type convention established (Aircraft dataclass)
|
||||
- [x] Coordinate convention defined ((lat, lon) throughout)
|
||||
- [x] Units convention defined (feet throughout)
|
||||
- [x] Interface style defined (typing.Protocol)
|
||||
- [x] Constants centralised (constants.py) — includes colours (full 6-colour palette + semantic mappings), geometry, timing, paths, trail sizing
|
||||
- [x] Type annotations required throughout
|
||||
- [x] Logging levels defined
|
||||
- [x] Error handling pattern defined (raise inside, catch at loop boundary)
|
||||
|
||||
**✅ Project Structure**
|
||||
- [x] Complete directory structure defined with all files
|
||||
- [x] Component boundaries established and enforced
|
||||
- [x] Integration points mapped to specific modules
|
||||
- [x] All 33 FRs mapped to specific files
|
||||
- [x] Test structure mirrors src with fixture library
|
||||
|
||||
---
|
||||
|
||||
### Architecture Readiness Assessment
|
||||
|
||||
**Overall Status: READY FOR IMPLEMENTATION**
|
||||
|
||||
**Confidence Level: High**
|
||||
|
||||
**Key Strengths:**
|
||||
- Hard boundary between provisioning and operational modes eliminates the largest class of runtime bugs for this type of device
|
||||
- Stateful Renderer with in-memory composite eliminates tile I/O from the hot path
|
||||
- Hardware interfaces (Protocol) enable full test coverage without physical hardware
|
||||
- `ProvisioningError` + loop recovery ensures no silent partial-provisioning state
|
||||
- Reset flow via `os.execvp` is clean, testable, and requires no additional service dependencies
|
||||
- `constants.py` as single source of truth for all project-wide values prevents colour/threshold drift across modules
|
||||
|
||||
**Areas for Future Enhancement (post-MVP):**
|
||||
- Airspace colour fills (Phase 2 per PRD)
|
||||
- Own squawk code highlighting (Phase 2)
|
||||
- Aircraft size coding (Phase 2)
|
||||
- SD card image build automation
|
||||
- **E-ink refresh speed experiment:** once working prototype exists, benchmark SPI
|
||||
clock speed (Waveshare library default vs. 10–20MHz) and test any fast/partial
|
||||
refresh modes available on the Spectra 6 HAT. `REFRESH_INTERVAL_S` in
|
||||
`constants.py` is the only change needed if cycle time can be reduced.
|
||||
|
||||
---
|
||||
|
||||
### Implementation Handoff
|
||||
|
||||
**First implementation task:** project scaffold — `src/` layout, `pyproject.toml` with both entry points and package data, `requirements.txt`, `requirements-dev.txt`, empty module files with correct imports, `systemd/` unit files, and `pip install -e .` verified.
|
||||
|
||||
**All AI Agents MUST:**
|
||||
- Follow all architectural decisions exactly as documented
|
||||
- Use implementation patterns in Section 5 consistently — 7 mandatory rules apply
|
||||
- `main.py` must not import from `planemapper.provisioning.*` — ruff enforces this
|
||||
- All hardware boundaries typed against Protocols, never concrete classes
|
||||
- All fixed values — numbers, colours, paths, sizes — imported from `constants.py`
|
||||
Reference in New Issue
Block a user