# Story 1.5: Provisioning Execution — Tile Download, Cache Validation & WiFi Kill Status: ready-for-dev ## Story 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 1. **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 2. **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` 3. **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 4. **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 ## 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 - [ ] 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 = 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 100–200nm, 10 for 50–100nm, 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` - [ ] 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` - [ ] 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 - [ ] 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: `"

Cache validation failed: {e}. Try again

"` - [ ] 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: `"

Setup complete. The device will now start displaying radar.

"` - [ ] 5.12 Wrap steps 5.5–5.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 - [ ] 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` - [ ] 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 ## Dev Notes ### OSM tile URL pattern and User-Agent Tile URL: `https://tile.openstreetmap.org/{z}/{x}/{y}.png` OSM requires a `User-Agent` header (same policy as Nominatim). Use the same agent string established in Story 1.4: ```python TILE_USER_AGENT = "planemapper/0.1 (https://github.com/football2801/planeMapper)" ``` ### Tile coordinate math (Web Mercator) Standard OSM formula for converting lat/lon to tile (x, y) at a given zoom level: ```python import math def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]: 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)) return x, y ``` Reference: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames ### Zoom level selection ```python def _zoom_for_radius(radius_nm: float) -> int: if radius_nm > 200: return 8 if radius_nm > 100: return 9 if radius_nm > 50: return 10 return 11 ``` Zoom 10 ≈ 100nm coverage; zoom 8 gives wider coverage for large radii. ### background.png compositing The compositing logic must: 1. Determine the tile bounding box: convert the four corners of the geographic bounding box to tile coordinates, then derive `x_min, x_max, y_min, y_max` 2. Calculate a pixel offset for each tile: `pixel_x = (tx - x_min) * 256`, `pixel_y = (ty - y_min) * 256` 3. Create a canvas sized to fit all tiles: `canvas_w = (x_max - x_min + 1) * 256`, `canvas_h = (y_max - y_min + 1) * 256` 4. Paste each 256×256 tile at its pixel offset 5. Crop or resize the result to `(DISPLAY_WIDTH, DISPLAY_HEIGHT)` = `(800, 480)` — centre the crop on the home location pixel The home location pixel is: `home_x = (home_tile_x - x_min) * 256 + home_pixel_offset_x` (and similarly for y), where `home_pixel_offset_x` is the sub-tile offset computed from the fractional tile coordinate. Crop symmetrically around this point; clamp to canvas bounds. ### `BACKGROUND_PATH` and `AIRSPACE_PATH` from constants Both paths must be imported from `planemapper.constants`, never hardcoded: ```python from planemapper.constants import BACKGROUND_PATH, AIRSPACE_PATH ``` ### `POST /submit` synchronous approach For MVP, the browser connection stays open while provisioning runs (no background thread). The handler executes: join WiFi → download tiles → download airspace → validate → write config → kill WiFi. This means the browser may wait 2–5 minutes. The "Downloading…" page is returned as the HTTP response body in a single blocking `return` — but given Flask's synchronous mode, this means we must render the "Downloading…" response first and then run provisioning. The simplest approach is to use Flask's `Response` with a generator or to use `after_this_request`. A pragmatic MVP alternative: return the status page as a streamed response (use `flask.stream_with_context` or a plain generator response) so the browser renders "Downloading…" immediately, then provisioning runs synchronously before the stream closes. If streaming is overly complex, an acceptable fallback is to use `threading.Thread` to run provisioning in the background and return the status page immediately. The test should mock all provisioning calls regardless. ### OpenAIP API key fallback If `OPENAIP_API_KEY` is not set in the environment, write an empty GeoJSON and degrade gracefully — airspace outlines are a cosmetic overlay and the device is fully functional without them. This avoids a hard dependency on a third-party API key for provisioning to succeed. ```python EMPTY_GEOJSON = '{"type": "FeatureCollection", "features": []}' ``` ### Patching paths in tests Tests must patch `BACKGROUND_PATH` and `AIRSPACE_PATH` to `tmp_path` entries to avoid any writes to `/etc/planemapper/`. Use `unittest.mock.patch` targeting the module-level name: ```python from unittest.mock import patch from pathlib import Path def test_download_and_composite(tmp_path, mock_tile_response): bg_path = tmp_path / "background.png" with patch("planemapper.provisioning.tiles.BACKGROUND_PATH", bg_path): download_and_composite(51.5, -0.1, 100) assert bg_path.exists() ``` Similarly for `AIRSPACE_PATH` in airspace tests. ### subprocess mock for `nmcli` and `rfkill` ```python from unittest.mock import patch, MagicMock import subprocess @patch("planemapper.provisioning.wifi.subprocess.run") def test_join_home_wifi_success(mock_run): mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0) join_home_wifi("MySSID", "MyPass") # should not raise mock_run.assert_called_once() ``` Mock `subprocess.run` at the module where it is used (`planemapper.provisioning.wifi.subprocess.run`), not at the `subprocess` package level. ### Coordinate convention All internal functions use `(lat, lon)` order — never `(lon, lat)`. The bounding box computation (`lat ± radius_deg`, `lon ± radius_deg`) follows this convention. The radius in degrees is approximated as `radius_nm / 60.0` (1 arcminute ≈ 1 nautical mile).