# Deferred Work Manifest Tracks blocked, deferred, and tech-debt items across sprints. --- ## Infrastructure / environment setup ### [1-1] systemd unit installation Story: `1-1-project-scaffold-and-verified-entry-points` Task: 7.1, 7.2 Description: Unit files created at `systemd/`. Must be symlinked or copied to `/etc/systemd/system/` on the Pi and `systemctl daemon-reload` run before they take effect. Cannot be automated without root access to target device. ### [1-1] Pi Zero 2W runtime verification Story: `1-1-project-scaffold-and-verified-entry-points` Task: 9.1, 9.2 Description: Entry points verified on host (Pi 5, Linux). Full AC1 verification on Pi Zero 2W hardware requires physical deployment. --- ## Story 1.2 review — no new deferred items Story `1-2-configuration-read-write-wipe` reviewed 2026-04-22. All 4 ACs pass, all 7 tests pass, ruff check and format clean. No deferred items required: `config.write()` already handles directory creation via `mkdir(parents=True, exist_ok=True)`, so deployment to a fresh device with no `/etc/planemapper/` directory is covered at runtime. --- ## Story 1.3: WiFi Hotspot & Captive Portal Form ### [1-3] hostapd and dnsmasq system packages Story: `1-3-wifi-hotspot-and-captive-portal-form` Category: Infrastructure/environment Description: `hostapd` and `dnsmasq` require system packages installed on the Pi; AP mode requires `wlan0` in AP-capable state. Cannot be verified without hardware. ### [1-3] Captive portal device testing Story: `1-3-wifi-hotspot-and-captive-portal-form` Category: Runtime verification Description: Actual captive portal detection behaviour (iOS/Android/Windows triggering) requires physical device testing. Automated tests confirm redirect routes are correct but cannot simulate OS-level captive portal probe behaviour. ### [1-3] Provisioning loop placeholder Story: `1-3-wifi-hotspot-and-captive-portal-form` Category: Infrastructure/environment Description: `provision.py` provisioning loop currently exits after one iteration (placeholder `provisioned = True`) — full sequence wired in Story 1.5. --- ## Story 1.4: Location Resolution (ICAO & Address) ### [1-4] Nominatim geocoding runtime verification Story: `1-4-location-resolution-icao-and-address` Category: Runtime verification Description: Nominatim geocoding verified in tests with mocks only; real geocoding requires internet access and can only be confirmed on device at provisioning time. No automated test covers live HTTP to Nominatim. ### [1-4] ICAO heuristic false-positive risk Story: `1-4-location-resolution-icao-and-address` Category: Technical debt Description: ICAO heuristic (`len(query) == 4 and query.isalpha()`) may misclassify 4-letter words (e.g. "BATH", "YORK") as ICAO codes, causing them to be looked up in `airports.csv` before falling back to Nominatim. Acceptable for MVP given the provisioning context, but noted for future hardening (e.g. validate against a known ICAO prefix list). --- ## Story 1.5: Provisioning Execution — Tile Download, Cache Validation & WiFi Kill ### [1-5] nmcli / NetworkManager dependency Story: `1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill` Category: Infrastructure/environment Description: `nmcli` requires NetworkManager to be installed and running on the Pi; the `wlan0` interface must support managed mode. Raspberry Pi OS Lite uses `dhcpcd` by default — NetworkManager must be installed and enabled before `join_home_wifi()` will work. ### [1-5] rfkill permission requirement Story: `1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill` Category: Infrastructure/environment Description: `rfkill block wifi` requires the process to have permission to block the WiFi interface. The user running the provisioning service must be root or have the `CAP_NET_ADMIN` capability. The systemd unit must be configured accordingly. ### [1-5] OSM tile download and OpenAIP API runtime verification Story: `1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill` Category: Runtime verification Description: OSM tile download, OpenAIP API call, and the full provisioning sequence (WiFi join → tile download → airspace download → validate → write config → rfkill) can only be end-to-end verified on device with real network access. All tests use mocks only. ### [1-5] provision.py port 80 requires root Story: `1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill` Category: Infrastructure/environment Description: `provision.py` calls `app.run(port=80)` which requires root privileges or the `CAP_NET_BIND_SERVICE` capability to bind to a port below 1024. The systemd unit for the provisioning service must run as root or be granted the appropriate capability. ### [1-5] Synchronous POST /submit — browser waits during provisioning Story: `1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill` Category: Technical debt Description: The `POST /submit` handler is fully synchronous — the browser connection stays open while tile download, airspace download, and cache validation complete (potentially 2–5 minutes). This is acceptable for MVP but a streaming response (using `flask.stream_with_context` or a background thread with server-sent events) would improve UX by allowing the browser to render progress feedback without holding an open connection. --- ## Story 2.1: Aircraft Data Model & Fetcher ### [2-1] HttpFetcher live dump1090 runtime verification Story: `2-1-aircraft-data-model-and-fetcher` Category: Runtime verification Description: `HttpFetcher` is tested with mocks only. Live feed at `http://localhost:8080/data/aircraft.json` can only be verified on device with an RTL-SDR dongle connected and dump1090 running. No automated test covers the real HTTP path to dump1090. --- ## Story 2.2: Coordinate Projection & Base Map Loading ### [2-2] Equirectangular projection distortion at high latitudes or large radius Story: `2-2-coordinate-projection-and-base-map-loading` Category: Technical debt Description: The equirectangular projection corrects only for longitude convergence at the home latitude (`cos(home_lat)`). For large radius values (e.g. >150nm) or locations above ~60°N, distortion accumulates toward the display edges. Aircraft positions at the map boundary can be displaced by several pixels from their true screen location. Acceptable for a ~100nm display centred on a UK airfield, but worth revisiting if radius or latitude range is extended. ### [2-2] basemap.load() does not verify image dimensions Story: `2-2-coordinate-projection-and-base-map-loading` Category: Technical debt Description: `basemap.load()` opens and returns whatever image is at `BACKGROUND_PATH` without asserting it is 800×480. A mismatched tile composite (e.g. from a re-provisioning at a different zoom level) will be silently accepted and the rendered output will be corrupted. Future hardening: add a dimension assertion and raise `ValueError` if the image does not match `DISPLAY_WIDTH × DISPLAY_HEIGHT`. --- ## Story 2.3: Home Marker & Airspace Outlines ### [2-3] draw_airspace() silently skips non-Polygon geometry types Story: `2-3-home-marker-and-airspace-outlines` Category: Technical debt Description: `draw_airspace()` skips any GeoJSON feature whose `geometry.type` is not `"Polygon"` (e.g. Point, LineString, MultiPolygon). This is intentional for MVP per spec, but MultiPolygon airspace features from OpenAIP will be silently ignored. Future hardening: add support for MultiPolygon by iterating each ring, and log a debug message when an unsupported geometry type is encountered. ### [2-3] draw_airspace() does not handle null geometry features Story: `2-3-home-marker-and-airspace-outlines` Category: Technical debt Description: If a GeoJSON feature has `"geometry": null` (valid per GeoJSON spec for featureless features), `feature.get("geometry", {})` returns `None` rather than `{}`, and the subsequent `.get("type")` call raises `AttributeError`. Acceptable for MVP given controlled OpenAIP input, but real-world GeoJSON files can contain null geometry. Future hardening: guard with `if not geom or not isinstance(geom, dict): continue`. --- ## Story 2.4: Altitude Colour Bands & Aircraft Type Icons ### [2-4] _AIRLINE_PREFIXES is a hardcoded subset of ICAO airline codes Story: `2-4-altitude-colour-bands-and-aircraft-type-icons` Category: Technical debt Description: `_AIRLINE_PREFIXES` contains 23 hand-picked ICAO 3-letter designators. Any airline callsign not in this set (e.g. "SXS", "WJA", "FDX") will fall through to the altitude fallback and may be misclassified as GA_LIGHT, PRIVATE_JET, or AIRLINER depending on altitude rather than as COMMERCIAL. Acceptable for MVP display purposes, but notable for any use case that relies on accurate airline/GA distinction. Future hardening: source the full ICAO airline prefix list from a static CSV bundled with the package. ### [2-4] Military aircraft with A-category ADS-B codes are misclassified Story: `2-4-altitude-colour-bands-and-aircraft-type-icons` Category: Technical debt Description: `_CATEGORY_MAP` maps only ADS-B B-categories (B1–B4) to `AircraftType.MILITARY`. Military aircraft that transmit A-category ADS-B codes (e.g. training jets advertising as A3) or no category at all will fall through to the callsign/altitude fallback and be misclassified. A military callsign prefix list (e.g. "RRR", "GAF", "USAF") would improve detection but is not required by any story AC. --- ## Story 2.5: Per-Aircraft Drawing ### [2-5] Default font is 8px — may be unreadable on physical hardware Story: `2-5-per-aircraft-drawing` Category: Technical debt Description: `_draw_label` uses `ImageFont.load_default()` which renders at approximately 8px on the 800×480 display. Callsign and altitude labels may be too small to read at arm's length on the physical e-ink panel. Future hardening: load a bundled bitmap or TrueType font at 12–14px (e.g. Pillow's built-in `ImageFont.load_default(size=14)` on Pillow ≥10, or a small `.ttf` bundled under `src/planemapper/assets/`). ### [2-5] Arrow geometry constants are hardcoded inline Story: `2-5-per-aircraft-drawing` Category: Technical debt Description: Arrow tip distance (12px), base half-width (6px), and base offset (8px) are hardcoded inline in `_draw_arrow`. These control icon size and aspect ratio. For Pi Zero 2W or larger displays these values may need tuning. Future hardening: extract to named constants in `constants.py` (e.g. `ARROW_TIP`, `ARROW_BASE_HALF`, `ARROW_BASE_OFFSET`) so they can be adjusted without touching drawing logic. --- ## Story 2.6: Stateful Renderer & Display Interface ### [2-6] WaveshareDisplay.show() raises NotImplementedError Story: `2-6-stateful-renderer-and-display-interface` Category: Infrastructure/environment Description: `WaveshareDisplay.show()` is a stub that raises `NotImplementedError`. The real SPI driver implementation (using the Waveshare Python library) is deferred to story 2-7. The stub satisfies the `DisplayInterface` protocol structurally but cannot be used in production or tests until story 2-7 wires in the hardware driver. ### [2-6] Trail positions are in pixel space — stale after map re-provisioning Story: `2-6-stateful-renderer-and-display-interface` Category: Technical debt Description: Trail entries are `(x, y)` pixel coordinates computed against the `MapBounds` in use at render time. If map bounds change (e.g. after re-provisioning at a different home location or radius), any trails accumulated before the change will plot at incorrect pixel positions on the new map. At runtime this is unlikely — bounds are fixed at startup — but a future enhancement that supports live re-provisioning without restart would need to flush `Renderer._trails` whenever bounds change. --- ## Story 3.1: Stale State Detection & Dimmed Display ### [3-1] Only requests.Timeout is caught — other fetch errors propagate without stale marking Story: `3-1-stale-state-detection-and-dimmed-display` Category: Technical debt Description: `_run_one_cycle` catches only `requests.Timeout`. Other fetch failures — `requests.ConnectionError`, `requests.HTTPError`, and JSON decode errors from dump1090 — propagate to the outer loop boundary and trigger the `except Exception: log.error(...)` handler. That handler retains the last rendered frame but does NOT mark aircraft as `is_stale=True`. As a result, if dump1090 is unreachable (connection refused) rather than slow (timeout), the display will silently show the previous frame without any staleness indication. Intentional limitation for MVP; future hardening would broaden the except clause or add a separate ConnectionError stale path. ### [3-1] _run_one_cycle parameter count will grow — consider RendererState dataclass Story: `3-1-stale-state-detection-and-dimmed-display` Category: Technical debt Description: `_run_one_cycle` now takes 4 parameters (`renderer`, `fetcher`, `display`, `last_aircraft`). If further per-cycle state is needed (e.g. a stale-cycle counter for escalating display feedback, or a last-successful-fetch timestamp), the signature will grow awkwardly. Future hardening: introduce a `RendererState` dataclass to bundle mutable per-loop state so `_run_one_cycle` receives one state object rather than an expanding parameter list. --- ## Story 2.7: Operational Radar Loop, Startup Screen & Systemd Wiring ### [2-7] WaveshareDisplay SPI driver not yet wired — key production blocker Story: `2-7-operational-radar-loop-startup-screen-and-systemd-wiring` Category: Infrastructure/environment Description: `WaveshareDisplay.show()` is still a `NotImplementedError` stub — the actual Waveshare SPI driver wiring (using the Waveshare Python library) is not yet done. On a Pi without the HAT attached, or until the driver is wired, calling `main()` will crash immediately on `display.show(startup)`. This is the key production blocker before the radar loop can run on real hardware. ### [2-7] main() crashes immediately on Pi without HAT Story: `2-7-operational-radar-loop-startup-screen-and-systemd-wiring` Category: Infrastructure/environment Description: `main()` instantiates `WaveshareDisplay` unconditionally and calls `display.show(startup)` before the radar loop. On a Pi without the Waveshare HAT physically attached, the service will crash immediately. This is correct for production deployment but means the service cannot be run without the HAT even for integration testing. Systemd `Restart=always` will retry indefinitely until hardware is attached. ### [2-7] Dumb fixed sleep — no compensation for render time Story: `2-7-operational-radar-loop-startup-screen-and-systemd-wiring` Category: Technical debt Description: `time.sleep(REFRESH_INTERVAL_S)` is a dumb fixed sleep appended after each cycle. There is no compensation for render time: if `_run_one_cycle` takes 50 seconds, the next cycle starts 110 seconds after the previous one began rather than 60 seconds. Future hardening: compute the remaining sleep as `max(0, REFRESH_INTERVAL_S - cycle_duration)` so the loop stays on a consistent 60-second cadence. ### [2-7] Startup screen text position is hardcoded — may not be visually centred Story: `2-7-operational-radar-loop-startup-screen-and-systemd-wiring` Category: Technical debt Description: The startup screen text is drawn at `DISPLAY_WIDTH // 2 - 60, DISPLAY_HEIGHT // 2` (i.e. x=340). The offset of −60 is a pixel-counted approximation, not derived from actual font metrics. Depending on the Pillow version and the default font's rendered width, the text may appear left-biased. Future hardening: use `ImageDraw.textlength()` (Pillow ≥9.2) to compute the real string width and centre precisely. --- ## Story 4.1: GPIO Button Hold Detection & LED Feedback ### [4-1] GPIO pin numbers belong in constants.py Story: `4-1-gpio-button-hold-detection-and-led-feedback` Category: Technical debt Description: `BUTTON_GPIO_PIN = 17` and `LED_GPIO_PIN = 27` are module-level constants in `gpio_ctrl.py`. For production tuning and hardware revision they belong in `constants.py` alongside other hardware-facing constants. Future hardening: move both to `constants.py` and import them into `gpio_ctrl.py` from there. ### [4-1] ButtonHoldDetector instantiates real gpiozero.Button at __init__ Story: `4-1-gpio-button-hold-detection-and-led-feedback` Category: Infrastructure/environment Description: `ButtonHoldDetector.__init__` constructs a `gpiozero.Button` immediately. The radar main loop must construct `ButtonHoldDetector` at startup (not at import time), or the application will fail if GPIO is unavailable when the module is imported. This is currently safe because `gpio_ctrl.py` is not imported at module level in `main.py`, but any future reorganisation that imports it at the top of a module that runs on non-GPIO hardware will raise a `BadPinFactory` error unless a `MockFactory` is active. --- ## Story 4.2: Config Wipe, Setup Screen & Return to Provisioning ### [4-2] ButtonHoldDetector and LEDController raise at startup on Pi without GPIO Story: `4-2-config-wipe-setup-screen-and-return-to-provisioning` Category: Infrastructure/environment Description: `ButtonHoldDetector` and `LEDController` are now instantiated unconditionally in `main()` before the loop begins. On a Pi without GPIO hardware (or without a `MockFactory` active), both constructors will raise a `BadPinFactory` error at startup, crashing the radar service before it can display anything. Same concern as story 4-1. Future hardening: wrap construction in a try/except and fall back to no-op stubs, or defer construction until first use. ### [4-2] os.execvp replaces process — no cleanup before re-exec Story: `4-2-config-wipe-setup-screen-and-return-to-provisioning` Category: Technical debt Description: `os.execvp` replaces the current process image immediately. Any cleanup that would normally happen at shutdown — flushing log handlers, closing the SPI connection to the e-ink display, releasing GPIO pins — is not performed. Acceptable for MVP: the SPI and GPIO resources will be re-acquired by the provisioning process, and the OS reclaims file descriptors. A future improvement could flush logs and call `display.close()` (if such a method exists) before exec.