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