From 76c2d66ed1e08a4645e7e4ab2cc378a7bca3dd72 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 22 Apr 2026 22:38:18 -0400 Subject: [PATCH] Create story 1.3: WiFi hotspot and captive portal form Co-Authored-By: Claude Sonnet 4.6 --- ...-3-wifi-hotspot-and-captive-portal-form.md | 184 ++++++++++++++++++ .../sprint-status.yaml | 2 +- 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 _bmad-output/implementation-artifacts/1-3-wifi-hotspot-and-captive-portal-form.md diff --git a/_bmad-output/implementation-artifacts/1-3-wifi-hotspot-and-captive-portal-form.md b/_bmad-output/implementation-artifacts/1-3-wifi-hotspot-and-captive-portal-form.md new file mode 100644 index 0000000..0972e80 --- /dev/null +++ b/_bmad-output/implementation-artifacts/1-3-wifi-hotspot-and-captive-portal-form.md @@ -0,0 +1,184 @@ +# Story 1.3: WiFi Hotspot & Captive Portal Form + +Status: ready-for-dev + +## Story + +As a user setting up the device for the first time, +I want to connect my phone to the `planeMapper-setup` hotspot and be automatically redirected to a setup page where I can enter my location, coverage radius, and home WiFi credentials, +So that I can configure the device without a keyboard or monitor. + +## Acceptance Criteria + +1. **Given** the device boots with no config file present **When** `planemapper-provision` starts **Then** `hostapd` and `dnsmasq` are started and the `planeMapper-setup` SSID is broadcast **And** any DNS query from a connected client resolves to the Pi's IP (triggering captive portal detection on phones) + +2. **Given** a phone connected to `planeMapper-setup` **When** the phone attempts to load any URL **Then** the Flask portal page is served (captive portal detection triggers automatically) + +3. **Given** the portal page is displayed **When** the user views the form **Then** the form contains: location field (ICAO code or address/postcode), coverage radius field (default 100nm), WiFi SSID field, WiFi password field, and a "Find location" button separate from the final submit + +4. **Given** `wifi.start_ap()` fails (e.g. hostapd not installed or subprocess returns non-zero) **When** the failure occurs **Then** a `ProvisioningError` is raised, an ERROR is logged, and the provisioning loop resets to portal state + +## Tasks / Subtasks + +- [ ] Task 1: Implement `wifi.start_ap()` in `src/planemapper/provisioning/wifi.py` (AC: #1, #4) + - [ ] 1.1 Import `ProvisioningError` from `planemapper.provisioning` + - [ ] 1.2 Write the hostapd config to `/etc/hostapd/hostapd.conf` before calling the subprocess + - [ ] 1.3 Call `subprocess.run(["hostapd", "/etc/hostapd/hostapd.conf", ...], check=False)` and inspect `result.returncode` explicitly + - [ ] 1.4 Call `subprocess.run(["dnsmasq", "--no-daemon", ...], check=False)` and inspect `result.returncode` explicitly + - [ ] 1.5 Log `log.error(...)` before raising `ProvisioningError` on any non-zero return code + - [ ] 1.6 Annotate the function signature: `def start_ap() -> None` + +- [ ] Task 2: Implement `wifi.stop_ap()` in `src/planemapper/provisioning/wifi.py` (AC: #1) + - [ ] 2.1 Stop `hostapd` and `dnsmasq` processes (e.g. `subprocess.run(["pkill", "-f", "hostapd"], check=False)`) + - [ ] 2.2 Log at INFO level when AP is stopped + - [ ] 2.3 Annotate: `def stop_ap() -> None` + +- [ ] Task 3: Implement the Flask app in `src/planemapper/provisioning/portal.py` (AC: #2, #3) + - [ ] 3.1 Create a Flask app instance; import `ProvisioningError` from `planemapper.provisioning` + - [ ] 3.2 Implement `GET /` — serve the setup form HTML inline (no templates dir needed for MVP): location field, coverage radius field with default `100`, WiFi SSID field, WiFi password field, a "Find location" button (separate action), and a "Set up device" submit button + - [ ] 3.3 Implement `GET /generate_204` → redirect to `/` (Android captive portal probe) + - [ ] 3.4 Implement `GET /hotspot-detect.html` → redirect to `/` (iOS captive portal probe) + - [ ] 3.5 Implement `GET /ncsi.txt` → redirect to `/` (Windows captive portal probe) + - [ ] 3.6 Add `@app.errorhandler(404)` wildcard redirect → `/` so any unrecognised URL served by Flask goes to the portal form + - [ ] 3.7 Annotate all route functions with return type `str | Response` + +- [ ] Task 4: Update `provision.py` `main()` to call `wifi.start_ap()` inside the provisioning loop (AC: #4) + - [ ] 4.1 Import `wifi` from `planemapper.provisioning` + - [ ] 4.2 Call `wifi.start_ap()` at the start of the provisioning sequence inside the existing `while not provisioned` loop + - [ ] 4.3 Ensure the existing `except ProvisioningError` handler catches failures from `wifi.start_ap()`, logs the error, and resets to portal state via `reset_to_portal_state()` (or equivalent) + +- [ ] Task 5: Write tests (AC: #1, #2, #3, #4) + - [ ] 5.1 In `tests/provisioning/test_provision_loop.py` — add a test that patches `subprocess.run` to return a non-zero exit code, calls `wifi.start_ap()`, and asserts `ProvisioningError` is raised + - [ ] 5.2 In `tests/provisioning/test_provision_loop.py` — add a test that a `ProvisioningError` raised during the provisioning loop is caught, logged at ERROR, and the loop continues (does not crash) + - [ ] 5.3 Create `tests/provisioning/test_portal.py` — test `GET /` returns 200 with a form containing location, radius, SSID, password fields and a "Find location" button using Flask test client (`app.test_client()`) + - [ ] 5.4 In `tests/provisioning/test_portal.py` — test `GET /generate_204`, `GET /hotspot-detect.html`, `GET /ncsi.txt` each return a redirect (3xx) to `/` + - [ ] 5.5 In `tests/provisioning/test_portal.py` — test that a request to an unknown route (e.g. `GET /unknown-path`) returns a redirect to `/` + +- [ ] Task 6: Run quality gates + - [ ] 6.1 `pytest tests/` — all tests pass, 0 failures + - [ ] 6.2 `ruff check .` — zero violations + - [ ] 6.3 `ruff format --check .` — no formatting issues + +## Dev Notes + +### `wifi.start_ap()` subprocess pattern +Always use `check=False` and inspect `result.returncode` explicitly — never use `check=True`. This gives a controlled error message and lets us log before raising: + +```python +import subprocess +import logging +from planemapper.provisioning import ProvisioningError + +log = logging.getLogger(__name__) + +def start_ap() -> None: + _write_hostapd_conf() + result = subprocess.run(["hostapd", "/etc/hostapd/hostapd.conf"], check=False) + if result.returncode != 0: + log.error("hostapd failed with return code %d", result.returncode) + raise ProvisioningError(f"hostapd failed: returncode={result.returncode}") + result = subprocess.run( + ["dnsmasq", "--no-daemon", "--address=/#/192.168.4.1"], + check=False, + ) + if result.returncode != 0: + log.error("dnsmasq failed with return code %d", result.returncode) + raise ProvisioningError(f"dnsmasq failed: returncode={result.returncode}") +``` + +### hostapd config file +Write `/etc/hostapd/hostapd.conf` before calling `hostapd`. Minimal config for `planeMapper-setup`: + +``` +interface=wlan0 +driver=nl80211 +ssid=planeMapper-setup +hw_mode=g +channel=6 +wmm_enabled=0 +auth_algs=1 +ignore_broadcast_ssid=0 +``` + +### dnsmasq DNS redirect +Start dnsmasq with `--address=/#/192.168.4.1` to resolve every DNS query to the Pi's AP IP. This is the mechanism that triggers captive portal detection on connecting phones. + +### Flask wildcard redirect +Use `@app.errorhandler(404)` to catch any route Flask doesn't recognise and redirect to `/`. Also register explicit routes for known captive portal probe paths: + +```python +from flask import Flask, redirect, url_for +from typing import Union +from werkzeug.wrappers import Response + +app = Flask(__name__) + +@app.route("/") +def index() -> str: + return FORM_HTML # inline HTML string + +@app.route("/generate_204") +@app.route("/hotspot-detect.html") +@app.route("/ncsi.txt") +def captive_redirect() -> Response: + return redirect(url_for("index")) + +@app.errorhandler(404) +def catch_all(e: Exception) -> Response: + return redirect(url_for("index")) +``` + +### Portal form HTML +Keep the setup form as an inline string in `portal.py` (no `templates/` directory needed for MVP). The form must include: +- A text input named `location` (ICAO code or address/postcode) +- A "Find location" button (type `submit`, form action `/find-location` or JS — exact routing handled in Story 1.4) +- A number input named `radius` with value `100` +- A text input named `wifi_ssid` +- A password input named `wifi_password` +- A "Set up device" submit button (form action `POST /submit`) + +The "Find location" button and the "Set up device" submit are separate actions — Story 1.4 wires the location resolution; this story only needs the form fields and buttons to be present and correctly named. + +### Tests: mocking subprocess +Use `unittest.mock.patch` to mock `subprocess.run`: + +```python +from unittest.mock import patch, MagicMock +import pytest +from planemapper.provisioning import ProvisioningError +from planemapper.provisioning import wifi + +def test_start_ap_raises_on_hostapd_failure(): + mock_result = MagicMock() + mock_result.returncode = 1 + with patch("planemapper.provisioning.wifi.subprocess.run", return_value=mock_result): + with pytest.raises(ProvisioningError): + wifi.start_ap() +``` + +### Tests: Flask test client +```python +import pytest +from planemapper.provisioning.portal import app + +@pytest.fixture +def client(): + app.config["TESTING"] = True + with app.test_client() as c: + yield c + +def test_index_returns_form(client): + resp = client.get("/") + assert resp.status_code == 200 + data = resp.data.decode() + assert "location" in data + assert "radius" in data + assert "wifi_ssid" in data + assert "wifi_password" in data + assert "Find location" in data +``` + +### Separation of concerns +- `wifi.py`: all subprocess calls (`hostapd`, `dnsmasq`, `rfkill`) — no Flask imports +- `portal.py`: Flask app and routes only — no subprocess calls; imports `ProvisioningError` from `planemapper.provisioning` +- Never call `subprocess.run` from within `portal.py` diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index e75e360..8ebbfe6 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -46,7 +46,7 @@ development_status: epic-1: in-progress 1-1-project-scaffold-and-verified-entry-points: done 1-2-configuration-read-write-wipe: done - 1-3-wifi-hotspot-and-captive-portal-form: backlog + 1-3-wifi-hotspot-and-captive-portal-form: ready-for-dev 1-4-location-resolution-icao-and-address: backlog 1-5-provisioning-execution-tile-download-cache-validation-and-wifi-kill: backlog epic-1-retrospective: optional