Files
planeMapper/_bmad-output/planning-artifacts/architecture.md
T
Matt Edholm b2afa7fb4b 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>
2026-04-22 22:20:13 -04:00

33 KiB
Raw Blame History

stepsCompleted, lastStep, status, completedAt, inputDocuments, workflowType, project_name, user_name, date
stepsCompleted lastStep status completedAt inputDocuments workflowType project_name user_name date
1
2
3
4
5
6
7
8
8 complete 2026-04-22
prd.md
architecture planeMapper Matt.edholm 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:

@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:

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:

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:

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:

# 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.

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:

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)

[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:

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:

[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

  • Project context thoroughly analysed
  • Scale and complexity assessed (Medium, IoT/Embedded Python)
  • Technical constraints identified (Pi Zero 2W, 512MB RAM, 45s budget)
  • Cross-cutting concerns mapped (offline-first, graceful degradation, state isolation)

Architectural Decisions

  • Critical decisions documented with verified versions
  • Technology stack fully specified (Python 3.11, all deps pinned)
  • Integration patterns defined (fetch/render/display/GPIO boundaries)
  • Performance considerations addressed (phase instrumentation, memory caching)

Implementation Patterns

  • Data type convention established (Aircraft dataclass)
  • Coordinate convention defined ((lat, lon) throughout)
  • Units convention defined (feet throughout)
  • Interface style defined (typing.Protocol)
  • Constants centralised (constants.py) — includes colours (full 6-colour palette + semantic mappings), geometry, timing, paths, trail sizing
  • Type annotations required throughout
  • Logging levels defined
  • Error handling pattern defined (raise inside, catch at loop boundary)

Project Structure

  • Complete directory structure defined with all files
  • Component boundaries established and enforced
  • Integration points mapped to specific modules
  • All 33 FRs mapped to specific files
  • 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