Files
planeMapper/_bmad-output/implementation-artifacts/1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill.md
T
Matt Edholm 4aeeefb488 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>
2026-04-22 22:54:34 -04:00

16 KiB
Raw Blame History

Story 1.5: Provisioning Execution — Tile Download, Cache Validation & WiFi Kill

Status: review

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 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
  • 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: "<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)
  • 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.pytest_join_home_wifi_success: mock subprocess.run to return CompletedProcess(returncode=0); assert no exception raised
    • 7.2 tests/provisioning/test_wifi.pytest_join_home_wifi_failure: mock subprocess.run to return CompletedProcess(returncode=1, stderr=b"error"); assert ProvisioningError raised
    • 7.3 tests/provisioning/test_tiles.pytest_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.pytest_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.pytest_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.pytest_validate_cache_missing: patch BACKGROUND_PATH to a non-existent path; assert ProvisioningError
    • 7.7 tests/provisioning/test_tiles.pytest_validate_cache_empty: write a zero-byte file; assert ProvisioningError
    • 7.8 tests/provisioning/test_tiles.pytest_validate_cache_corrupt: write non-PNG bytes; assert ProvisioningError
    • 7.9 tests/provisioning/test_airspace.pytest_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.pytest_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.pytest_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.pytest_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:

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:

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

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:

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

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:

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

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