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