16 KiB
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
-
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
-
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.pngAnd OpenAIP airspace GeoJSON is downloaded and saved to/etc/planemapper/airspace.geojson -
Given tile download is complete When cache validation runs Then
background.pngis 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 -
Given cache validation passes When provisioning completes Then
config.write()saves home lat/lon, coverage radius, WiFi credentials, andprovisioned: trueAndrfkill block wifiis called and returns exit code 0 And the portal displays: "Setup complete. The device will now start displaying radar." And ifrfkillfails, aProvisioningErroris raised and the provisioning loop resets
Tasks / Subtasks
-
Task 1: Implement
wifi.join_home_wifi(ssid, password)insrc/planemapper/provisioning/wifi.py(AC: #1)- 1.1 Add
def join_home_wifi(ssid: str, password: str) -> None:towifi.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 raiseProvisioningError(f"nmcli failed (rc={result.returncode}): {result.stderr.decode()}") - 1.4 Log
INFOon success:log.info("joined home WiFi: %s", ssid) - 1.5 Annotate all parameters and return type
- 1.1 Add
-
Task 2: Implement
tiles.download_and_composite(lat, lon, radius_nm)insrc/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; derivex_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, callrequests.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_PATHusingcanvas.save(BACKGROUND_PATH)— ensure parent directory exists first (BACKGROUND_PATH.parent.mkdir(parents=True, exist_ok=True)) - 2.9 Log
INFOwith tile count and path on success - 2.10 Import
BACKGROUND_PATH,DISPLAY_WIDTH,DISPLAY_HEIGHT,COLOUR_WHITEfromplanemapper.constants
- 2.1 Add helper
-
Task 3: Implement
airspace.download(lat, lon, radius_nm)insrc/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": []}toAIRSPACE_PATHand logWARNING("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
ProvisioningErroron non-2xx response - 3.7 Write response JSON to
AIRSPACE_PATH(AIRSPACE_PATH.parent.mkdir(parents=True, exist_ok=True)) - 3.8 Log
INFOon success with path - 3.9 Import
AIRSPACE_PATHfromplanemapper.constants
- 3.1 Add
-
Task 4: Implement cache validation in
src/planemapper/provisioning/tiles.py(AC: #3)- 4.1 Add
def validate_cache() -> None:(raisesProvisioningErroron any failure; no return value on success) - 4.2 Check
BACKGROUND_PATH.exists()— raiseProvisioningError("background.png not found")if missing - 4.3 Check
BACKGROUND_PATH.stat().st_size > 0— raiseProvisioningError("background.png is empty")if zero-byte - 4.4 Attempt
Image.open(BACKGROUND_PATH).verify()— raiseProvisioningError(f"background.png is not a valid PNG: {e}")if it raises - 4.5 Check
BACKGROUND_PATH.stat().st_size < 2 * 1024 ** 3(2GB) — raiseProvisioningError("background.png exceeds 2GB limit")if over limit - 4.6 Log
INFO("cache validation passed: background.png %.1f KB", size_kb)on success
- 4.1 Add
-
Task 5: Add
POST /submitroute tosrc/planemapper/provisioning/portal.py(AC: #1, #2, #3, #4)- 5.1 Import
wifi,tiles,airspace,configfromplanemapper.provisioningat top ofportal.py - 5.2 Add
POST /submitroute function with signaturedef 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(); onProvisioningError, 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(); onProvisioningError, 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.5–5.11 in
try/except ProvisioningErrorwhere appropriate (validation fails → retry HTML; rfkill failure → re-raise)
- 5.1 Import
-
Task 6: Wire
provision.pymain()to run the full provisioning sequence (AC: #1, #2, #3, #4)- 6.1 Remove the placeholder
provisioned = Trueline fromprovision.py - 6.2 Ensure the provisioning loop calls
portal.run()(the Flask blocking call) which now includes thePOST /submitroute - 6.3 Confirm the
ProvisioningErrorfromwifi.kill_wifi()propagates up to theexcept ProvisioningErrorin the main loop, which callsreset_to_portal_state() - 6.4 Confirm the loop sets
provisioned = Trueonly afterportal.run()returns without raising
- 6.1 Remove the placeholder
-
Task 7: Write tests (AC: #1, #2, #3, #4)
- 7.1
tests/provisioning/test_wifi.py—test_join_home_wifi_success: mocksubprocess.runto returnCompletedProcess(returncode=0); assert no exception raised - 7.2
tests/provisioning/test_wifi.py—test_join_home_wifi_failure: mocksubprocess.runto returnCompletedProcess(returncode=1, stderr=b"error"); assertProvisioningErrorraised - 7.3
tests/provisioning/test_tiles.py—test_download_and_composite: mockrequests.getto return a fake 256×256 PNG bytes response; usetmp_pathto patchBACKGROUND_PATH; assertbackground.pngis 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 totmp_path/background.png; patchBACKGROUND_PATH; assert no exception - 7.6
tests/provisioning/test_tiles.py—test_validate_cache_missing: patchBACKGROUND_PATHto a non-existent path; assertProvisioningError - 7.7
tests/provisioning/test_tiles.py—test_validate_cache_empty: write a zero-byte file; assertProvisioningError - 7.8
tests/provisioning/test_tiles.py—test_validate_cache_corrupt: write non-PNG bytes; assertProvisioningError - 7.9
tests/provisioning/test_airspace.py—test_download_no_api_key: unsetOPENAIP_API_KEY; patchAIRSPACE_PATHtotmp_path; calldownload(51.5, -0.1, 100); assert empty GeoJSON written - 7.10
tests/provisioning/test_airspace.py—test_download_with_api_key: setOPENAIP_API_KEY="test"; mockrequests.getto return a minimal GeoJSON response; patchAIRSPACE_PATHtotmp_path; assert file written with response content - 7.11
tests/provisioning/test_portal.py—test_submit_success: mockwifi.join_home_wifi,tiles.download_and_composite,airspace.download,tiles.validate_cache,config.write,wifi.kill_wifi; POST to/submitwith valid form data; assert 200 and response contains "Setup complete" - 7.12
tests/provisioning/test_portal.py—test_submit_validation_failure: mocktiles.validate_cacheto raiseProvisioningError("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 beyondtmp_path
- 7.1
-
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
- 8.1
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:
- 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 - Calculate a pixel offset for each tile:
pixel_x = (tx - x_min) * 256,pixel_y = (ty - y_min) * 256 - Create a canvas sized to fit all tiles:
canvas_w = (x_max - x_min + 1) * 256,canvas_h = (y_max - y_min + 1) * 256 - Paste each 256×256 tile at its pixel offset
- 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 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.
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).