Implement story 1.5: provisioning execution tile download cache validation wifi kill

Add tiles.py, airspace.py, wifi.join_home_wifi, portal /submit route, and rewire
provision.py main loop; all tasks and quality gates pass (56 tests, ruff clean).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Edholm
2026-04-22 22:54:34 -04:00
parent a6a6a2796d
commit 4aeeefb488
11 changed files with 455 additions and 81 deletions
@@ -1,6 +1,6 @@
# Story 1.5: Provisioning Execution — Tile Download, Cache Validation & WiFi Kill
Status: ready-for-dev
Status: review
## Story
@@ -20,86 +20,86 @@ So that the device is fully provisioned and permanently offline from that point.
## Tasks / Subtasks
- [ ] Task 1: Implement `wifi.join_home_wifi(ssid, password)` in `src/planemapper/provisioning/wifi.py` (AC: #1)
- [ ] 1.1 Add `def join_home_wifi(ssid: str, password: str) -> None:` to `wifi.py`
- [ ] 1.2 Run `subprocess.run(["nmcli", "device", "wifi", "connect", ssid, "password", password], capture_output=True)` with a reasonable timeout (30s)
- [ ] 1.3 Check `result.returncode`; if non-zero raise `ProvisioningError(f"nmcli failed (rc={result.returncode}): {result.stderr.decode()}")`
- [ ] 1.4 Log `INFO` on success: `log.info("joined home WiFi: %s", ssid)`
- [ ] 1.5 Annotate all parameters and return type
- [x] Task 1: Implement `wifi.join_home_wifi(ssid, password)` in `src/planemapper/provisioning/wifi.py` (AC: #1)
- [x] 1.1 Add `def join_home_wifi(ssid: str, password: str) -> None:` to `wifi.py`
- [x] 1.2 Run `subprocess.run(["nmcli", "device", "wifi", "connect", ssid, "password", password], capture_output=True)` with a reasonable timeout (30s)
- [x] 1.3 Check `result.returncode`; if non-zero raise `ProvisioningError(f"nmcli failed (rc={result.returncode}): {result.stderr.decode()}")`
- [x] 1.4 Log `INFO` on success: `log.info("joined home WiFi: %s", ssid)`
- [x] 1.5 Annotate all parameters and return type
- [ ] Task 2: Implement `tiles.download_and_composite(lat, lon, radius_nm)` in `src/planemapper/provisioning/tiles.py` (AC: #2, #3)
- [ ] 2.1 Add helper `def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:` using the standard Web Mercator OSM formula:
- [x] Task 2: Implement `tiles.download_and_composite(lat, lon, radius_nm)` in `src/planemapper/provisioning/tiles.py` (AC: #2, #3)
- [x] 2.1 Add helper `def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:` using the standard Web Mercator OSM formula:
- `x = int((lon + 180.0) / 360.0 * (1 << zoom))`
- `lat_r = math.radians(lat)`
- `y = int((1.0 - math.log(math.tan(lat_r) + 1.0 / math.cos(lat_r)) / math.pi) / 2.0 * (1 << zoom))`
- [ ] 2.2 Add helper `def _zoom_for_radius(radius_nm: float) -> int:` — returns 8 for >200nm, 9 for 100200nm, 10 for 50100nm, 11 for <50nm
- [ ] 2.3 Add `def download_and_composite(lat: float, lon: float, radius_nm: float) -> None:`
- [ ] 2.4 Calculate zoom from `_zoom_for_radius(radius_nm)`
- [ ] 2.5 Compute tile bounds: convert the four corners of the bounding box (lat ± radius_deg, lon ± radius_deg, where `radius_deg = radius_nm / 60.0`) to tile coordinates; derive `x_min, x_max, y_min, y_max`
- [ ] 2.6 Create a `PIL.Image.new("RGB", (DISPLAY_WIDTH, DISPLAY_HEIGHT), COLOUR_WHITE)` canvas
- [ ] 2.7 For each `(tx, ty)` in the tile grid, call `requests.get(f"https://tile.openstreetmap.org/{zoom}/{tx}/{ty}.png", headers={"User-Agent": "planemapper/0.1 (https://github.com/football2801/planeMapper)"}, timeout=10)` and open the response content as a PIL Image; paste at the calculated pixel offset onto the canvas
- [ ] 2.8 Save the composited image to `BACKGROUND_PATH` using `canvas.save(BACKGROUND_PATH)` — ensure parent directory exists first (`BACKGROUND_PATH.parent.mkdir(parents=True, exist_ok=True)`)
- [ ] 2.9 Log `INFO` with tile count and path on success
- [ ] 2.10 Import `BACKGROUND_PATH`, `DISPLAY_WIDTH`, `DISPLAY_HEIGHT`, `COLOUR_WHITE` from `planemapper.constants`
- [x] 2.2 Add helper `def _zoom_for_radius(radius_nm: float) -> int:` — returns 8 for >200nm, 9 for 100200nm, 10 for 50100nm, 11 for <50nm
- [x] 2.3 Add `def download_and_composite(lat: float, lon: float, radius_nm: float) -> None:`
- [x] 2.4 Calculate zoom from `_zoom_for_radius(radius_nm)`
- [x] 2.5 Compute tile bounds: convert the four corners of the bounding box (lat ± radius_deg, lon ± radius_deg, where `radius_deg = radius_nm / 60.0`) to tile coordinates; derive `x_min, x_max, y_min, y_max`
- [x] 2.6 Create a `PIL.Image.new("RGB", (DISPLAY_WIDTH, DISPLAY_HEIGHT), COLOUR_WHITE)` canvas
- [x] 2.7 For each `(tx, ty)` in the tile grid, call `requests.get(f"https://tile.openstreetmap.org/{zoom}/{tx}/{ty}.png", headers={"User-Agent": "planemapper/0.1 (https://github.com/football2801/planeMapper)"}, timeout=10)` and open the response content as a PIL Image; paste at the calculated pixel offset onto the canvas
- [x] 2.8 Save the composited image to `BACKGROUND_PATH` using `canvas.save(BACKGROUND_PATH)` — ensure parent directory exists first (`BACKGROUND_PATH.parent.mkdir(parents=True, exist_ok=True)`)
- [x] 2.9 Log `INFO` with tile count and path on success
- [x] 2.10 Import `BACKGROUND_PATH`, `DISPLAY_WIDTH`, `DISPLAY_HEIGHT`, `COLOUR_WHITE` from `planemapper.constants`
- [ ] Task 3: Implement `airspace.download(lat, lon, radius_nm)` in `src/planemapper/provisioning/airspace.py` (AC: #2)
- [ ] 3.1 Add `def download(lat: float, lon: float, radius_nm: float) -> None:`
- [ ] 3.2 Check for `OPENAIP_API_KEY = os.environ.get("OPENAIP_API_KEY")`
- [ ] 3.3 If no API key: write empty GeoJSON `{"type": "FeatureCollection", "features": []}` to `AIRSPACE_PATH` and log `WARNING("OPENAIP_API_KEY not set — writing empty airspace cache; airspace outlines will not be shown")`; return early
- [ ] 3.4 If API key present: compute bounding box from lat/lon/radius_nm (`radius_deg = radius_nm / 60.0`; `bbox = [lon - radius_deg, lat - radius_deg, lon + radius_deg, lat + radius_deg]`)
- [ ] 3.5 Call OpenAIP API: `requests.get("https://api.openaip.net/api/airspaces", params={"bbox": ",".join(map(str, bbox))}, headers={"x-openaip-api-key": OPENAIP_API_KEY}, timeout=30)`
- [ ] 3.6 Raise `ProvisioningError` on non-2xx response
- [ ] 3.7 Write response JSON to `AIRSPACE_PATH` (`AIRSPACE_PATH.parent.mkdir(parents=True, exist_ok=True)`)
- [ ] 3.8 Log `INFO` on success with path
- [ ] 3.9 Import `AIRSPACE_PATH` from `planemapper.constants`
- [x] Task 3: Implement `airspace.download(lat, lon, radius_nm)` in `src/planemapper/provisioning/airspace.py` (AC: #2)
- [x] 3.1 Add `def download(lat: float, lon: float, radius_nm: float) -> None:`
- [x] 3.2 Check for `OPENAIP_API_KEY = os.environ.get("OPENAIP_API_KEY")`
- [x] 3.3 If no API key: write empty GeoJSON `{"type": "FeatureCollection", "features": []}` to `AIRSPACE_PATH` and log `WARNING("OPENAIP_API_KEY not set — writing empty airspace cache; airspace outlines will not be shown")`; return early
- [x] 3.4 If API key present: compute bounding box from lat/lon/radius_nm (`radius_deg = radius_nm / 60.0`; `bbox = [lon - radius_deg, lat - radius_deg, lon + radius_deg, lat + radius_deg]`)
- [x] 3.5 Call OpenAIP API: `requests.get("https://api.openaip.net/api/airspaces", params={"bbox": ",".join(map(str, bbox))}, headers={"x-openaip-api-key": OPENAIP_API_KEY}, timeout=30)`
- [x] 3.6 Raise `ProvisioningError` on non-2xx response
- [x] 3.7 Write response JSON to `AIRSPACE_PATH` (`AIRSPACE_PATH.parent.mkdir(parents=True, exist_ok=True)`)
- [x] 3.8 Log `INFO` on success with path
- [x] 3.9 Import `AIRSPACE_PATH` from `planemapper.constants`
- [ ] Task 4: Implement cache validation in `src/planemapper/provisioning/tiles.py` (AC: #3)
- [ ] 4.1 Add `def validate_cache() -> None:` (raises `ProvisioningError` on any failure; no return value on success)
- [ ] 4.2 Check `BACKGROUND_PATH.exists()` — raise `ProvisioningError("background.png not found")` if missing
- [ ] 4.3 Check `BACKGROUND_PATH.stat().st_size > 0` — raise `ProvisioningError("background.png is empty")` if zero-byte
- [ ] 4.4 Attempt `Image.open(BACKGROUND_PATH).verify()` — raise `ProvisioningError(f"background.png is not a valid PNG: {e}")` if it raises
- [ ] 4.5 Check `BACKGROUND_PATH.stat().st_size < 2 * 1024 ** 3` (2GB) — raise `ProvisioningError("background.png exceeds 2GB limit")` if over limit
- [ ] 4.6 Log `INFO("cache validation passed: background.png %.1f KB", size_kb)` on success
- [x] Task 4: Implement cache validation in `src/planemapper/provisioning/tiles.py` (AC: #3)
- [x] 4.1 Add `def validate_cache() -> None:` (raises `ProvisioningError` on any failure; no return value on success)
- [x] 4.2 Check `BACKGROUND_PATH.exists()` — raise `ProvisioningError("background.png not found")` if missing
- [x] 4.3 Check `BACKGROUND_PATH.stat().st_size > 0` — raise `ProvisioningError("background.png is empty")` if zero-byte
- [x] 4.4 Attempt `Image.open(BACKGROUND_PATH).verify()` — raise `ProvisioningError(f"background.png is not a valid PNG: {e}")` if it raises
- [x] 4.5 Check `BACKGROUND_PATH.stat().st_size < 2 * 1024 ** 3` (2GB) — raise `ProvisioningError("background.png exceeds 2GB limit")` if over limit
- [x] 4.6 Log `INFO("cache validation passed: background.png %.1f KB", size_kb)` on success
- [ ] Task 5: Add `POST /submit` route to `src/planemapper/provisioning/portal.py` (AC: #1, #2, #3, #4)
- [ ] 5.1 Import `wifi`, `tiles`, `airspace`, `config` from `planemapper.provisioning` at top of `portal.py`
- [ ] 5.2 Add `POST /submit` route function with signature `def submit() -> str:`
- [ ] 5.3 Read form fields: `confirmed_lat = float(request.form["confirmed_lat"])`, `confirmed_lon = float(request.form["confirmed_lon"])`, `confirmed_name = request.form["confirmed_name"]`, `radius = float(request.form["radius"])`, `wifi_ssid = request.form["wifi_ssid"]`, `wifi_password = request.form["wifi_password"]`
- [ ] 5.4 Immediately return a "Downloading…" status page (HTML string) — this is the synchronous MVP approach; the browser waits while provisioning runs
- [ ] 5.5 After returning the status page response (or within a single synchronous handler): call `wifi.join_home_wifi(wifi_ssid, wifi_password)`
- [ ] 5.6 Call `tiles.download_and_composite(confirmed_lat, confirmed_lon, radius)`
- [ ] 5.7 Call `airspace.download(confirmed_lat, confirmed_lon, radius)`
- [ ] 5.8 Call `tiles.validate_cache()`; on `ProvisioningError`, return retry HTML: `"<p>Cache validation failed: {e}. <a href='/'>Try again</a></p>"`
- [ ] 5.9 Call `config.write({"lat": confirmed_lat, "lon": confirmed_lon, "radius_nm": radius, "wifi_ssid": wifi_ssid, "wifi_password": wifi_password, "provisioned": True})`
- [ ] 5.10 Call `wifi.kill_wifi()`; on `ProvisioningError`, re-raise so the provisioning loop resets
- [ ] 5.11 On full success, return: `"<p>Setup complete. The device will now start displaying radar.</p>"`
- [ ] 5.12 Wrap steps 5.55.11 in `try/except ProvisioningError` where appropriate (validation fails → retry HTML; rfkill failure → re-raise)
- [x] Task 5: Add `POST /submit` route to `src/planemapper/provisioning/portal.py` (AC: #1, #2, #3, #4)
- [x] 5.1 Import `wifi`, `tiles`, `airspace`, `config` from `planemapper.provisioning` at top of `portal.py`
- [x] 5.2 Add `POST /submit` route function with signature `def submit() -> str:`
- [x] 5.3 Read form fields: `confirmed_lat = float(request.form["confirmed_lat"])`, `confirmed_lon = float(request.form["confirmed_lon"])`, `confirmed_name = request.form["confirmed_name"]`, `radius = float(request.form["radius"])`, `wifi_ssid = request.form["wifi_ssid"]`, `wifi_password = request.form["wifi_password"]`
- [x] 5.4 Immediately return a "Downloading…" status page (HTML string) — this is the synchronous MVP approach; the browser waits while provisioning runs
- [x] 5.5 After returning the status page response (or within a single synchronous handler): call `wifi.join_home_wifi(wifi_ssid, wifi_password)`
- [x] 5.6 Call `tiles.download_and_composite(confirmed_lat, confirmed_lon, radius)`
- [x] 5.7 Call `airspace.download(confirmed_lat, confirmed_lon, radius)`
- [x] 5.8 Call `tiles.validate_cache()`; on `ProvisioningError`, return retry HTML: `"<p>Cache validation failed: {e}. <a href='/'>Try again</a></p>"`
- [x] 5.9 Call `config.write({"lat": confirmed_lat, "lon": confirmed_lon, "radius_nm": radius, "wifi_ssid": wifi_ssid, "wifi_password": wifi_password, "provisioned": True})`
- [x] 5.10 Call `wifi.kill_wifi()`; on `ProvisioningError`, re-raise so the provisioning loop resets
- [x] 5.11 On full success, return: `"<p>Setup complete. The device will now start displaying radar.</p>"`
- [x] 5.12 Wrap steps 5.55.11 in `try/except ProvisioningError` where appropriate (validation fails → retry HTML; rfkill failure → re-raise)
- [ ] Task 6: Wire `provision.py` `main()` to run the full provisioning sequence (AC: #1, #2, #3, #4)
- [ ] 6.1 Remove the placeholder `provisioned = True` line from `provision.py`
- [ ] 6.2 Ensure the provisioning loop calls `portal.run()` (the Flask blocking call) which now includes the `POST /submit` route
- [ ] 6.3 Confirm the `ProvisioningError` from `wifi.kill_wifi()` propagates up to the `except ProvisioningError` in the main loop, which calls `reset_to_portal_state()`
- [ ] 6.4 Confirm the loop sets `provisioned = True` only after `portal.run()` returns without raising
- [x] Task 6: Wire `provision.py` `main()` to run the full provisioning sequence (AC: #1, #2, #3, #4)
- [x] 6.1 Remove the placeholder `provisioned = True` line from `provision.py`
- [x] 6.2 Ensure the provisioning loop calls `portal.run()` (the Flask blocking call) which now includes the `POST /submit` route
- [x] 6.3 Confirm the `ProvisioningError` from `wifi.kill_wifi()` propagates up to the `except ProvisioningError` in the main loop, which calls `reset_to_portal_state()`
- [x] 6.4 Confirm the loop sets `provisioned = True` only after `portal.run()` returns without raising
- [ ] Task 7: Write tests (AC: #1, #2, #3, #4)
- [ ] 7.1 `tests/provisioning/test_wifi.py``test_join_home_wifi_success`: mock `subprocess.run` to return `CompletedProcess(returncode=0)`; assert no exception raised
- [ ] 7.2 `tests/provisioning/test_wifi.py``test_join_home_wifi_failure`: mock `subprocess.run` to return `CompletedProcess(returncode=1, stderr=b"error")`; assert `ProvisioningError` raised
- [ ] 7.3 `tests/provisioning/test_tiles.py``test_download_and_composite`: mock `requests.get` to return a fake 256×256 PNG bytes response; use `tmp_path` to patch `BACKGROUND_PATH`; assert `background.png` is created and is a valid PNG
- [ ] 7.4 `tests/provisioning/test_tiles.py``test_lat_lon_to_tile_known_values`: assert known tile coordinates for a documented lat/lon/zoom triple (e.g. lat=51.5, lon=-0.1, zoom=10 → well-known London tile)
- [ ] 7.5 `tests/provisioning/test_tiles.py``test_validate_cache_passes`: write a real 1×1 PNG to `tmp_path/background.png`; patch `BACKGROUND_PATH`; assert no exception
- [ ] 7.6 `tests/provisioning/test_tiles.py``test_validate_cache_missing`: patch `BACKGROUND_PATH` to a non-existent path; assert `ProvisioningError`
- [ ] 7.7 `tests/provisioning/test_tiles.py``test_validate_cache_empty`: write a zero-byte file; assert `ProvisioningError`
- [ ] 7.8 `tests/provisioning/test_tiles.py``test_validate_cache_corrupt`: write non-PNG bytes; assert `ProvisioningError`
- [ ] 7.9 `tests/provisioning/test_airspace.py``test_download_no_api_key`: unset `OPENAIP_API_KEY`; patch `AIRSPACE_PATH` to `tmp_path`; call `download(51.5, -0.1, 100)`; assert empty GeoJSON written
- [ ] 7.10 `tests/provisioning/test_airspace.py``test_download_with_api_key`: set `OPENAIP_API_KEY="test"`; mock `requests.get` to return a minimal GeoJSON response; patch `AIRSPACE_PATH` to `tmp_path`; assert file written with response content
- [ ] 7.11 `tests/provisioning/test_portal.py``test_submit_success`: mock `wifi.join_home_wifi`, `tiles.download_and_composite`, `airspace.download`, `tiles.validate_cache`, `config.write`, `wifi.kill_wifi`; POST to `/submit` with valid form data; assert 200 and response contains "Setup complete"
- [ ] 7.12 `tests/provisioning/test_portal.py``test_submit_validation_failure`: mock `tiles.validate_cache` to raise `ProvisioningError("bad png")`; POST to `/submit`; assert 200 and response contains "Try again"
- [ ] 7.13 All mocks use `unittest.mock.patch`; no real network or filesystem access beyond `tmp_path`
- [x] Task 7: Write tests (AC: #1, #2, #3, #4)
- [x] 7.1 `tests/provisioning/test_wifi.py``test_join_home_wifi_success`: mock `subprocess.run` to return `CompletedProcess(returncode=0)`; assert no exception raised
- [x] 7.2 `tests/provisioning/test_wifi.py``test_join_home_wifi_failure`: mock `subprocess.run` to return `CompletedProcess(returncode=1, stderr=b"error")`; assert `ProvisioningError` raised
- [x] 7.3 `tests/provisioning/test_tiles.py``test_download_and_composite`: mock `requests.get` to return a fake 256×256 PNG bytes response; use `tmp_path` to patch `BACKGROUND_PATH`; assert `background.png` is created and is a valid PNG
- [x] 7.4 `tests/provisioning/test_tiles.py``test_lat_lon_to_tile_known_values`: assert known tile coordinates for a documented lat/lon/zoom triple (e.g. lat=51.5, lon=-0.1, zoom=10 → well-known London tile)
- [x] 7.5 `tests/provisioning/test_tiles.py``test_validate_cache_passes`: write a real 1×1 PNG to `tmp_path/background.png`; patch `BACKGROUND_PATH`; assert no exception
- [x] 7.6 `tests/provisioning/test_tiles.py``test_validate_cache_missing`: patch `BACKGROUND_PATH` to a non-existent path; assert `ProvisioningError`
- [x] 7.7 `tests/provisioning/test_tiles.py``test_validate_cache_empty`: write a zero-byte file; assert `ProvisioningError`
- [x] 7.8 `tests/provisioning/test_tiles.py``test_validate_cache_corrupt`: write non-PNG bytes; assert `ProvisioningError`
- [x] 7.9 `tests/provisioning/test_airspace.py``test_download_no_api_key`: unset `OPENAIP_API_KEY`; patch `AIRSPACE_PATH` to `tmp_path`; call `download(51.5, -0.1, 100)`; assert empty GeoJSON written
- [x] 7.10 `tests/provisioning/test_airspace.py``test_download_with_api_key`: set `OPENAIP_API_KEY="test"`; mock `requests.get` to return a minimal GeoJSON response; patch `AIRSPACE_PATH` to `tmp_path`; assert file written with response content
- [x] 7.11 `tests/provisioning/test_portal.py``test_submit_success`: mock `wifi.join_home_wifi`, `tiles.download_and_composite`, `airspace.download`, `tiles.validate_cache`, `config.write`, `wifi.kill_wifi`; POST to `/submit` with valid form data; assert 200 and response contains "Setup complete"
- [x] 7.12 `tests/provisioning/test_portal.py``test_submit_validation_failure`: mock `tiles.validate_cache` to raise `ProvisioningError("bad png")`; POST to `/submit`; assert 200 and response contains "Try again"
- [x] 7.13 All mocks use `unittest.mock.patch`; no real network or filesystem access beyond `tmp_path`
- [ ] Task 8: Run quality gates
- [ ] 8.1 `pytest tests/` — all tests pass, 0 failures
- [ ] 8.2 `ruff check .` — zero violations
- [ ] 8.3 `ruff format --check .` — no formatting issues
- [x] Task 8: Run quality gates
- [x] 8.1 `pytest tests/` — all tests pass, 0 failures
- [x] 8.2 `ruff check .` — zero violations
- [x] 8.3 `ruff format --check .` — no formatting issues
## Dev Notes
@@ -48,7 +48,7 @@ development_status:
1-2-configuration-read-write-wipe: done
1-3-wifi-hotspot-and-captive-portal-form: done
1-4-location-resolution-icao-and-address: done
1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill: ready-for-dev
1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill: review
epic-1-retrospective: optional
# Epic 2: Live Radar Display