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>
34 KiB
stepsCompleted, inputDocuments
| stepsCompleted | inputDocuments | ||||||
|---|---|---|---|---|---|---|---|
|
|
planeMapper - Epic Breakdown
Overview
This document provides the complete epic and story breakdown for planeMapper, decomposing the requirements from the PRD, UX Design if it exists, and Architecture requirements into implementable stories.
Requirements Inventory
Functional Requirements
FR1: The device broadcasts a WiFi hotspot on first boot and after reset FR2: The user can connect to the device hotspot and be served a setup interface automatically (captive portal) FR3: The user can enter a location as an ICAO code or address/postcode FR4: The device resolves an ICAO code to coordinates using a bundled airport database FR5: The device resolves an address or postcode to coordinates using a geocoding service FR6: The device displays the resolved location for user confirmation before proceeding FR7: The user can set a coverage radius FR8: The user can enter home WiFi credentials during setup FR9: The device connects to the user's home WiFi and downloads and caches map tiles for the configured area FR9a: After tile download, the device validates cache completeness and size before killing the WiFi radio; on failure, the device remains in provisioning state and prompts retry FR10: The device kills the WiFi radio after successful provisioning FR11: The setup interface confirms provisioning status to the user before the WiFi hotspot is dropped FR12: The user can trigger a device reset by holding the reset button for 3 seconds FR13: The device provides immediate visual feedback via LED when a reset hold is detected FR14: A confirmed reset wipes device configuration and returns to provisioning state FR15: The device displays a setup screen on the e-ink display after reset FR16: The device renders an OpenStreetMap base map centred on the configured home location FR17: The map covers the configured coverage radius FR18: The home location is marked as a distinct point on the map FR19: Airspace circular boundaries are rendered as outlines on the map (OpenAIP data) FR20: The device fetches live aircraft data from the dump1090 JSON feed FR21: Each aircraft is rendered at its current position with a heading arrow aligned to direction of travel FR22: Each aircraft displays its callsign and altitude as a label FR23: Each aircraft is colour-coded by altitude band FR24: Each aircraft is rendered with a type-specific icon determined from ADS-B category data or callsign pattern matching (GA/light, commercial/large, helicopter, private jet) FR24a: When aircraft type cannot be determined, icon is assigned by altitude — GA below 10,000ft, private jet 10,000–30,000ft, airliner above 30,000ft FR25: Each aircraft displays a trail of up to 5 previous positions as dots, oldest dot smallest FR26: Aircraft transmitted via MLAT are visually distinguished from directly received aircraft FR27: The device detects when the dump1090 feed has not produced a fresh decode FR28: Aircraft from the last successful decode are retained on display and visually marked as stale FR29: Aircraft positions are restored to normal display state when fresh decode data is received FR30: The display refreshes on a 60-second cycle FR31: The device continues the refresh loop indefinitely without manual intervention FR32: The device resumes the refresh loop automatically after power cycling FR33: The device displays a defined startup screen during boot, before the first radar render is complete
NonFunctional Requirements
NFR1: Full radar render must complete within 45 seconds on Pi Zero 2W hardware NFR2: Base map tile layer is pre-composited and cached in memory between refresh cycles — only the aircraft overlay is re-rendered each cycle NFR3: dump1090 JSON fetch must complete within 5 seconds; timeout triggers stale data path NFR4: E-ink SPI transfer initiates only after render pipeline is complete NFR5: Refresh loop must sustain 72+ hours of continuous operation without restart or intervention NFR6: Device must recover to operational state within 5 minutes of unclean power loss, without manual intervention NFR7: dump1090 decode failure must not crash the refresh loop NFR8: OSM tile cache must not exceed 2GB for any supported coverage radius (16GB SD card) NFR9: Cache size validated during provisioning before WiFi radio is killed NFR10: dump1090 JSON feed at http://localhost:8080/data/aircraft.json — local, no authentication NFR11: Nominatim geocoding API called once during provisioning only; internet required at that point only NFR12: OurAirports database bundled with software, no runtime dependency NFR13: OpenAIP airspace data fetched and cached during provisioning alongside OSM tiles NFR14: WiFi radio off in operational state — network attack surface is zero NFR15: No external network calls in operational state NFR16: Config stored plaintext on SD card — acceptable for personal single-user device
Additional Requirements
- Project scaffold (Architecture):
src/layout,pyproject.tomlwith two entry points (planemapper-radar,planemapper-provision) andplanemapperpackage data config,requirements.txt,requirements-dev.txt, empty module stubs,pip install -e .verified working - Two process entry points (Architecture):
planemapper-provisionandplanemapper-radarare separate processes and systemd units — they must never share a runtime context;main.pymust not import fromplanemapper.provisioning.* - Python 3.11 (Architecture): Raspberry Pi OS Bookworm default; pure Python, no compilation step; deployment via git pull +
pip install . - Pinned runtime deps (Architecture): Pillow 12.2.0, gpiozero 2.0.1, Flask 3.1.3, requests 2.33.1; ruff 0.15.11 for linting/formatting
- Config file (Architecture): JSON at
/etc/planemapper/config.json— home lat/lon, coverage radius (nm), WiFi SSID/password, provisioning state flag; single module (provisioning/config.py) reads/writes/wipes it - Background map (Architecture): Pre-composited
background.png(800×480) generated at provisioning; loaded once into Renderer memory at radar startup — eliminates all tile I/O from the operational render loop - Airspace cache (Architecture): GeoJSON at
/etc/planemapper/airspace.geojson, downloaded during provisioning; no runtime network dependency - Stale data visual (Architecture): Stale aircraft rendered as outlines only (no fill); threshold = 1 missed fetch cycle; recovery on next successful fetch restores normal rendering automatically
- Systemd units (Architecture):
planemapper-provision.service(Type=oneshot, runs at first boot/post-reset) andplanemapper-radar.service(Restart=always, After=planemapper-provision) - Logging (Architecture): stdout → systemd journal; stdlib
loggingmodule; levels: DEBUG (per-aircraft), INFO (cycle start/complete with phase timings), WARNING (render >40s, stale state change), ERROR (fetch failure, SPI failure, required file not found) - Render pipeline instrumentation (Architecture): Phase timings logged each cycle (tile load, aircraft overlay, SPI transfer); warn threshold 40s total; alert threshold 50s; stale path triggered if render exceeds 60s boundary
- Aircraft dataclass (Architecture):
@dataclass Aircraftwith typed optional fields defaulting to safe sentinels;is_stalecarried on dataclass; nothing beyondfetcher.pytouches raw JSON - Coordinate convention (Architecture):
(lat, lon)throughout all internal code; GeoJSON parsed with explicit reversal at parse boundary only; single projection function inrenderer/projection.py - Units convention (Architecture): Altitude in feet throughout; thresholds in
constants.py; no metres conversion anywhere - Interface protocols (Architecture):
DisplayInterfaceandFetcherInterfaceastyping.Protocol; all production code typed against Protocol, never concrete class - Constants (Architecture): Single
src/planemapper/constants.pyfor all project-wide values — colours (full 6-colour palette + semantic mappings), geometry, timing, paths, trail sizing; no inline literals anywhere - Error handling (Architecture): Single try/except at render loop boundary; internal functions raise normally; no bare
except:except at top-level loop - Reset flow (Architecture):
config.wipe()→display.show(setup_screen)→os.execvp('planemapper-provision', ...)— no IPC required; systemd handles restart sequencing - OurAirports data (Architecture):
airports.csvbundled insrc/planemapper/data/airports.csv; accessed viaimportlib.resources; configured inpyproject.tomlpackage-data - GPIO non-blocking (Architecture):
ButtonHoldDetector.check() -> boolis non-blocking, polled once per cycle alongside render loop - Test infrastructure (Architecture): pytest; gpiozero MockFactory for GPIO boundary tests;
NullDisplay+FileFixtureFetcherfor hardware-free testing;conftest.pypatchesCONFIG_PATHtotmp_path— no/etc/dependency in CI
FR Coverage Map
FR1: Epic 1 — WiFi hotspot broadcast on first boot / post-reset
FR2: Epic 1 — Captive portal served to connecting user
FR3: Epic 1 — Location entry: ICAO code or address/postcode
FR4: Epic 1 — ICAO code → coordinates (bundled OurAirports DB)
FR5: Epic 1 — Address/postcode → coordinates (Nominatim)
FR6: Epic 1 — Resolved location displayed for user confirmation
FR7: Epic 1 — Coverage radius selection
FR8: Epic 1 — Home WiFi credential entry
FR9: Epic 1 — Tile download and caching for configured area
FR9a: Epic 1 — Cache completeness/size validation before WiFi kill; retry on failure
FR10: Epic 1 — WiFi radio killed (rfkill) after successful provisioning
FR11: Epic 1 — Portal confirms provisioning success before hotspot dropped
FR12: Epic 4 — Reset button 3-second hold detection
FR13: Epic 4 — Immediate LED feedback on reset hold
FR14: Epic 4 — Config wipe + return to provisioning state
FR15: Epic 4 — Setup screen shown on e-ink after reset
FR16: Epic 2 — OSM base map rendered, centred on home location
FR17: Epic 2 — Map covers configured coverage radius
FR18: Epic 2 — Home location marked on map
FR19: Epic 2 — Airspace circular boundaries rendered as outlines (OpenAIP)
FR20: Epic 2 — Live aircraft data fetched from dump1090 JSON feed
FR21: Epic 2 — Per-aircraft heading arrow aligned to direction of travel
FR22: Epic 2 — Per-aircraft callsign + altitude label
FR23: Epic 2 — Per-aircraft colour coding by altitude band
FR24: Epic 2 — Per-aircraft type icon (GA, commercial, helicopter, private jet)
FR24a: Epic 2 — Altitude-based icon fallback when type unknown
FR25: Epic 2 — 5-dot position trail, oldest dot smallest
FR26: Epic 2 — MLAT positions visually distinguished from direct positions
FR27: Epic 3 — Stale data detection (missed dump1090 decode)
FR28: Epic 3 — Stale aircraft retained on display, visually marked (outline-only)
FR29: Epic 3 — Normal display restored on next fresh decode
FR30: Epic 2 — 60-second refresh cycle
FR31: Epic 2 — Refresh loop runs indefinitely without intervention
FR32: Epic 2 — Refresh loop resumes automatically after power cycling
FR33: Epic 2 — Startup screen shown during boot before first radar render
Epic List
Epic 1: Device Setup & Provisioning
A user can take a freshly flashed SD card, power on the device, connect via their phone, enter their location and home WiFi credentials, and have the device provision itself fully — downloading and validating map tiles, killing the WiFi radio — and confirm success on the portal. FRs covered: FR1, FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR9a, FR10, FR11
Epic 2: Live Radar Display
A user can glance at the e-ink display and see live aircraft positions with heading arrows, callsigns, altitude labels, colour-coded altitude bands, type icons, and position trails — refreshing automatically every 60 seconds indefinitely, including after power cycling. FRs covered: FR16, FR17, FR18, FR19, FR20, FR21, FR22, FR23, FR24, FR24a, FR25, FR26, FR30, FR31, FR32, FR33
Epic 3: Stale Data Resilience
When dump1090 decoding fails or times out, the device continues displaying the last known aircraft positions with a visual stale indicator and recovers automatically when decoding resumes — no crash, no blank screen, no intervention needed. FRs covered: FR27, FR28, FR29
Epic 4: Reset & Reconfiguration
A user can hold the reset button for 3 seconds, receive immediate LED confirmation, and have the device wipe its configuration and return to provisioning state — enabling full re-setup from any location. FRs covered: FR12, FR13, FR14, FR15
Epic 1: Device Setup & Provisioning
A user can take a freshly flashed SD card, power on the device, connect via their phone, enter their location and home WiFi credentials, and have the device provision itself fully — downloading and validating map tiles, killing the WiFi radio — and confirm success on the portal.
Story 1.1: Project Scaffold & Verified Entry Points
As a developer,
I want a verified project scaffold with the src/planemapper/ layout, both console entry points installable, all module stubs in place, systemd unit files, and pytest running without error,
So that every subsequent story has a consistent, working foundation to build on.
Acceptance Criteria:
Given the repository is cloned on a Pi Zero 2W running Raspberry Pi OS Bookworm
When pip install -e . is run
Then it completes without errors and both planemapper-provision and planemapper-radar commands are available on PATH
And running either command logs "not implemented" and exits with code 0
Given the project is installed
When pytest is run
Then the test suite discovers tests and exits with 0 failures (empty stubs acceptable)
Given the project structure
When a developer inspects the repository
Then all files from the Architecture directory structure exist: src/planemapper/ with __init__.py, constants.py, models.py, main.py, provision.py, fetcher.py, gpio_ctrl.py, display.py, provisioning/ (7 modules), renderer/ (8 modules), data/airports.csv; systemd/ with both .service files; pyproject.toml, requirements.txt, requirements-dev.txt
And src/planemapper/data/airports.csv is accessible via importlib.resources
And ruff check . passes with zero violations
Story 1.2: Configuration Read/Write/Wipe
As a provisioning system,
I want a single config module that reads, writes, and wipes /etc/planemapper/config.json,
So that all components share one reliable config boundary with no direct filesystem access elsewhere.
Acceptance Criteria:
Given no config file exists at CONFIG_PATH
When config.read() is called
Then it raises FileNotFoundError
Given a valid config dict with home lat/lon, coverage radius, WiFi SSID/password, and provisioned flag
When config.write(data) is called
Then the file is created at CONFIG_PATH with correct JSON content and all expected keys present
Given an existing config file
When config.wipe() is called
Then the config file is deleted and a subsequent config.read() raises FileNotFoundError
Given a test using conftest.py
When CONFIG_PATH is patched to tmp_path
Then all config operations work without touching /etc/planemapper/
Story 1.3: WiFi Hotspot & Captive Portal Form
As a user setting up the device for the first time,
I want to connect my phone to the planeMapper-setup hotspot and be automatically redirected to a setup page where I can enter my location, coverage radius, and home WiFi credentials,
So that I can configure the device without a keyboard or monitor.
Acceptance Criteria:
Given the device boots with no config file present
When planemapper-provision starts
Then hostapd and dnsmasq are started and the planeMapper-setup SSID is broadcast
And any DNS query from a connected client resolves to the Pi's IP (triggering captive portal detection)
Given a phone connected to planeMapper-setup
When the phone attempts to load any URL
Then the Flask portal page is served (captive portal detection triggers automatically)
Given the portal page is displayed When the user views the form Then the form contains: location field (ICAO code or address/postcode), coverage radius field (default 100nm), WiFi SSID field, WiFi password field, and a "Find location" button separate from the final submit
Given wifi.start_ap() fails (e.g. hostapd not installed or subprocess returns non-zero)
When the failure occurs
Then a ProvisioningError is raised, an ERROR is logged, and the provisioning loop resets to portal state
Story 1.4: Location Resolution (ICAO & Address)
As a user setting up the device, I want to type my home airfield ICAO code or my home address/postcode and have the device resolve it to coordinates and show the result for confirmation, So that I can verify the device is centred on the correct location before committing.
Acceptance Criteria:
Given the user enters a valid ICAO code (e.g. EGLL)
When "Find location" is pressed
Then the bundled airports.csv is queried via importlib.resources and the matching lat/lon is returned
And the resolved location name and coordinates are displayed on the portal for confirmation
Given the user enters an address or postcode (e.g. OX1 1AA)
When "Find location" is pressed
Then the Nominatim API is called once with the input and the resolved lat/lon is displayed for confirmation
Given the user enters an ICAO code not present in airports.csv
When "Find location" is pressed
Then the portal displays: "ICAO code not found — try an address instead"
Given Nominatim returns no results When "Find location" is pressed Then the portal displays: "Location not found — try a different search term"
Given tests run in CI When location tests execute Then Nominatim calls are mocked — no real network calls required in the test suite
Story 1.5: Provisioning Execution — Tile Download, Cache Validation & WiFi Kill
As a user who has confirmed their location and entered WiFi credentials, I want the device to automatically join my home WiFi, download all map tiles and airspace data, validate the cache, confirm success on screen, and kill the WiFi radio without further interaction, So that the device is fully provisioned and permanently offline from that point.
Acceptance Criteria:
Given the user submits the portal form with valid location, radius, and WiFi credentials When the form is submitted Then the portal updates to show: "Downloading map data — this may take a few minutes. Do not power off." And the device joins the user's home WiFi network
Given the device has joined home WiFi
When tile download runs
Then all OSM tiles for the configured area and zoom level are downloaded and composited into background.png (800×480) saved at /etc/planemapper/background.png
And OpenAIP airspace GeoJSON is downloaded and saved to /etc/planemapper/airspace.geojson
Given tile download is complete
When cache validation runs
Then background.png is confirmed non-zero size and readable as a valid PNG
And total tile data is confirmed within 2GB (NFR8, NFR9)
And if validation fails, the device remains in provisioning state and the portal displays a retry prompt
Given cache validation passes
When provisioning completes
Then config.write() saves home lat/lon, coverage radius, WiFi credentials, and provisioned: true
And rfkill block wifi is called and returns exit code 0
And the portal displays: "Setup complete. The device will now start displaying radar."
And if rfkill fails, a ProvisioningError is raised and the provisioning loop resets
Epic 2: Live Radar Display
A user can glance at the e-ink display and see live aircraft positions with heading arrows, callsigns, altitude labels, colour-coded altitude bands, type icons, and position trails — refreshing automatically every 60 seconds indefinitely, including after power cycling.
Story 2.1: Aircraft Data Model & Fetcher
As the radar system,
I want an Aircraft dataclass with safe-default optional fields and a FetcherInterface with both an HttpFetcher (live dump1090) and a FileFixtureFetcher (for testing),
So that all downstream rendering code works with typed Aircraft objects and the fetch boundary is cleanly isolated from raw JSON.
Acceptance Criteria:
Given a valid dump1090 JSON response with all fields present
When HttpFetcher.fetch() is called
Then it returns a list[Aircraft] with all fields populated correctly
Given the dump1090 response contains aircraft with missing callsign, altitude, or category
When HttpFetcher.fetch() is called
Then the corresponding fields use safe defaults (callsign="", altitude_ft=0, category="") and no exception is raised
Given the dump1090 HTTP request exceeds FETCH_TIMEOUT_S (5 seconds)
When HttpFetcher.fetch() is called
Then a requests.Timeout is raised (not caught here — the loop boundary handles it)
Given an aircraft entry has the MLAT flag set in the JSON
When HttpFetcher.fetch() is called
Then the resulting Aircraft has is_mlat=True
Given a FileFixtureFetcher pointed at tests/fixtures/aircraft_sample.json
When .fetch() is called
Then it returns the equivalent list[Aircraft] with no network call made
Story 2.2: Coordinate Projection & Base Map Loading
As the renderer,
I want a MapBounds dataclass and a project() function converting (lat, lon) to pixel (x, y), and a basemap module that loads background.png into memory once,
So that all rendering uses consistent coordinates and the base map is always available without disk I/O in the loop.
Acceptance Criteria:
Given a MapBounds from home lat/lon and coverage radius
When project(lat, lon, bounds) is called with the home location
Then it returns pixel coordinates at the centre of the 800×480 display (±2px)
Given project() is called with a position outside the map bounds
When the result is used
Then the returned pixel coordinate is outside display dimensions — no clamping, callers handle clipping
Given background.png exists at BACKGROUND_PATH
When basemap.load() is called
Then it returns a PIL.Image (800×480) loaded into memory
Given background.png does not exist at BACKGROUND_PATH
When basemap.load() is called
Then it raises FileNotFoundError (logged as ERROR by the caller)
Story 2.3: Home Marker & Airspace Outlines
As a user glancing at the display, I want to see my home location marked on the map and published airspace boundaries shown as outlines, So that I have immediate spatial context for all aircraft positions.
Acceptance Criteria:
Given a loaded base map image and home lat/lon from config
When the home marker is drawn
Then a distinct COLOUR_HOME_MARKER (red) marker is drawn at the projected pixel position of the home location
Given a valid airspace.geojson at AIRSPACE_PATH
When airspace outlines are drawn
Then each circular boundary in the GeoJSON is drawn as an outline in COLOUR_AIRSPACE on the image
And GeoJSON [lon, lat] coordinates are reversed to (lat, lon) at the parse boundary before any projection
Given airspace.geojson does not exist at AIRSPACE_PATH
When airspace draw is called
Then no exception is raised — the map renders without airspace outlines and a WARNING is logged
Story 2.4: Altitude Colour Bands & Aircraft Type Icons
As the renderer, I want pure functions mapping an aircraft's altitude to a display colour and its ADS-B category/callsign to an icon type, So that every aircraft is consistently colour-coded and type-classified with all logic centralised.
Acceptance Criteria:
Given altitude_ft values at the exact boundaries in ALTITUDE_BANDS_FT
When altitude_to_colour(altitude_ft) is called
Then the correct ALTITUDE_COLOURS entry is returned for each boundary and above/below it
And all 6 Waveshare Spectra 6 palette colours are reachable
Given an Aircraft with category="A1" (light aircraft)
When classify_aircraft_type(aircraft) is called
Then it returns the GA/light icon type
Given an Aircraft with a BA callsign pattern and no category
When classify_aircraft_type(aircraft) is called
Then it returns the commercial/large icon type
Given an Aircraft with category="A7" (helicopter)
When classify_aircraft_type(aircraft) is called
Then it returns the helicopter icon type
Given an Aircraft with no category, no recognised callsign, at altitude_ft=5000
When classify_aircraft_type(aircraft) is called
Then it returns GA/light (altitude <10,000ft — FR24a fallback)
Given an Aircraft with no category, at altitude_ft=18000
When classify_aircraft_type(aircraft) is called
Then it returns private jet (10,000–30,000ft — FR24a)
Given an Aircraft with no category, at altitude_ft=38000
When classify_aircraft_type(aircraft) is called
Then it returns airliner (>30,000ft — FR24a)
Story 2.5: Per-Aircraft Drawing (Arrow, Label, Trail, MLAT)
As a user looking at the display, I want each aircraft drawn with a heading arrow, callsign/altitude label, a 5-dot position trail with the oldest dot smallest, and MLAT aircraft visually distinct, So that I can read direction, identity, altitude, recent path, and data confidence at a glance.
Acceptance Criteria:
Given an Aircraft with heading=90.0 (due east)
When the heading arrow is drawn
Then the arrow points east on the display, correctly rotated from north-up reference
Given an Aircraft with callsign="BAW1" and altitude_ft=28000
When the label is drawn
Then callsign and altitude are rendered near the aircraft position
And the label colour matches the aircraft's altitude colour band
Given a trail deque with 3 entries
When the trail is drawn
Then 3 dots are rendered with decreasing size from most-recent to oldest (interpolated between TRAIL_DOT_SIZE_MAX and TRAIL_DOT_SIZE_MIN)
And dot colour is COLOUR_TRAIL
Given an Aircraft with is_mlat=True
When the aircraft is drawn
Then it is rendered in a visually distinct style from directly-received aircraft
Given an Aircraft with callsign=""
When the label is drawn
Then altitude only is rendered with no blank callsign prefix, and no exception is raised
Story 2.6: Stateful Renderer & Display Interface
As the radar loop,
I want a stateful Renderer owning the in-memory tile composite and per-aircraft trail history, and a DisplayInterface protocol with WaveshareDisplay (SPI) and NullDisplay (tests),
So that the render pipeline is fully isolated, testable without hardware, and trail history persists across cycles.
Acceptance Criteria:
Given a Renderer initialised with a loaded base map
When renderer.render(aircraft_list) is called
Then it returns a PIL.Image (800×480) with base map, airspace outlines, home marker, and all aircraft drawn
Given an aircraft appears in two consecutive calls to renderer.render()
When the second call is made
Then its previous position appears as a trail dot in the output
And trail length never exceeds TRAIL_MAX_DOTS (5)
Given an aircraft was present last cycle but is absent from the current list
When renderer.render() is called
Then the aircraft does not appear on the display
And its trail history is retained in dict[str, deque] for when it reappears
Given a NullDisplay
When display.show(image) is called
Then it logs image dimensions at DEBUG level and returns without error — no SPI call made
Given the test_pipeline.py smoke test (FileFixtureFetcher → Renderer → NullDisplay)
When one full cycle runs
Then it completes without exception and the returned image is 800×480
Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring
As a device operator, I want the device to show a startup screen during boot, then enter a 60-second radar refresh loop that runs indefinitely and resumes automatically after power cycling, So that the display is always current with zero manual intervention.
Acceptance Criteria:
Given the device boots with a valid config file
When planemapper-radar starts
Then a startup screen is displayed on the e-ink before the first radar render begins (FR33)
And once the first radar render completes, the live display replaces the startup screen
Given the radar loop is running
When each 60-second cycle completes
Then fetcher.fetch() → renderer.render() → display.show() executes in sequence
And render phase timings (tile load, overlay, SPI) are logged at INFO level each cycle
Given total render time exceeds 40 seconds When the cycle completes Then a WARNING is logged with the total render time
Given planemapper-radar.service
When the service file is inspected
Then it has Restart=always and After=planemapper-provision.service
Given the device loses mains power and is restored
When the Pi reboots
Then planemapper-provision.service detects provisioned: true in config and exits immediately
And planemapper-radar.service starts and resumes the loop within 5 minutes (NFR6, FR32)
Epic 3: Stale Data Resilience
When dump1090 decoding fails or times out, the device continues displaying the last known aircraft positions with a visual stale indicator and recovers automatically when decoding resumes — no crash, no blank screen, no intervention needed.
Story 3.1: Stale State Detection & Dimmed Display
As a user whose RTL-SDR has temporarily lost signal, I want the display to retain the last known aircraft positions shown as outlines when dump1090 stops delivering fresh data, So that I know the display is stale without a crash or blank screen.
Acceptance Criteria:
Given the radar loop is running with a previous successful fetch
When HttpFetcher.fetch() raises requests.Timeout (>5s)
Then the exception propagates to the loop boundary, which catches it and marks all retained aircraft as is_stale=True
Given the dump1090 response returns an empty aircraft list when the previous cycle had aircraft
When the fetcher processes the response
Then the previous aircraft list is retained with is_stale=True on each entry (not replaced with an empty list)
Given aircraft with is_stale=True are passed to the renderer
When renderer.render() is called
Then each stale aircraft is drawn as an outline only (no fill) using COLOUR_STALE_OUTLINE
And heading arrow, label, and trail are still rendered at their last known positions
Given a stale render cycle When the render loop timing is measured Then the loop does not crash and completes within normal bounds — stale path is not a crash path (NFR7)
Story 3.2: Automatic Recovery on Fresh Decode
As a user whose RTL-SDR has recovered, I want the display to automatically return to normal filled aircraft rendering on the next successful fetch, So that recovery requires no manual intervention.
Acceptance Criteria:
Given the display is in stale state (aircraft rendered as outlines)
When HttpFetcher.fetch() returns a non-empty aircraft list successfully
Then all newly fetched aircraft have is_stale=False
And the renderer draws them with normal filled icons in their altitude colour band
Given the display has recovered from stale state When the next render cycle runs Then no stale outline rendering occurs for the recovered aircraft
Given a stale-then-recovery sequence in test_pipeline.py
When FileFixtureFetcher returns an empty list followed by a populated list
Then the first cycle produces outline-only aircraft and the second produces normal filled aircraft
Epic 4: Reset & Reconfiguration
A user can hold the reset button for 3 seconds, receive immediate LED confirmation, and have the device wipe its configuration and return to provisioning state — enabling full re-setup from any location.
Story 4.1: GPIO Button Hold Detection & LED Feedback
As a user wanting to reconfigure the device, I want to hold the reset button for 3 seconds and receive immediate LED confirmation, So that I know the reset was registered before anything else changes.
Acceptance Criteria:
Given the reset button GPIO is configured via gpiozero
When the button is held for RESET_HOLD_S (3 seconds)
Then ButtonHoldDetector.check() returns True
Given the button is held for less than 3 seconds
When ButtonHoldDetector.check() is called
Then it returns False — no reset triggered
Given ButtonHoldDetector.check() returns True
When the main loop processes the result
Then LEDController.on() is called immediately (FR13 — immediate feedback before any config change)
Given gpiozero MockFactory is active in tests
When button hold and LED tests run
Then they pass without physical GPIO hardware
Given ButtonHoldDetector.check() is called once per render cycle
When the render loop runs
Then the call is non-blocking and adds no perceptible delay to the render pipeline
Story 4.2: Config Wipe, Setup Screen & Return to Provisioning
As a user who has triggered a reset, I want the device to wipe its configuration, show a setup screen on the e-ink display, and restart into the provisioning flow, So that I can re-configure the device from scratch for a new location or home network.
Acceptance Criteria:
Given ButtonHoldDetector.check() returns True in the main loop
When the reset handler runs
Then config.wipe() is called and the config file is deleted (FR14)
Given the config has been wiped
When the reset handler continues
Then display.show(setup_screen_image) is called, displaying the setup screen on the e-ink (FR15)
Given the setup screen is shown
When the reset handler completes
Then os.execvp('planemapper-provision', ['planemapper-provision']) is called, replacing the current process
And systemd restarts planemapper-radar → detects no config → runs provisioning flow from scratch
Given config.wipe() raises an unexpected error
When the reset handler encounters it
Then an ERROR is logged and os.execvp is not called — no partial reset leaves the device in an inconsistent state