Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9.0 KiB
Story 1.3: WiFi Hotspot & Captive Portal Form
Status: done
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
-
Given the device boots with no config file present When
planemapper-provisionstarts Thenhostapdanddnsmasqare started and theplaneMapper-setupSSID is broadcast And any DNS query from a connected client resolves to the Pi's IP (triggering captive portal detection on phones) -
Given a phone connected to
planeMapper-setupWhen the phone attempts to load any URL Then the Flask portal page is served (captive portal detection triggers automatically) -
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
-
Given
wifi.start_ap()fails (e.g. hostapd not installed or subprocess returns non-zero) When the failure occurs Then aProvisioningErroris raised, an ERROR is logged, and the provisioning loop resets to portal state
Tasks / Subtasks
-
Task 1: Implement
wifi.start_ap()insrc/planemapper/provisioning/wifi.py(AC: #1, #4)- 1.1 Import
ProvisioningErrorfromplanemapper.provisioning - 1.2 Write the hostapd config to
/etc/hostapd/hostapd.confbefore calling the subprocess - 1.3 Call
subprocess.run(["hostapd", "/etc/hostapd/hostapd.conf", ...], check=False)and inspectresult.returncodeexplicitly - 1.4 Call
subprocess.run(["dnsmasq", "--no-daemon", ...], check=False)and inspectresult.returncodeexplicitly - 1.5 Log
log.error(...)before raisingProvisioningErroron any non-zero return code - 1.6 Annotate the function signature:
def start_ap() -> None
- 1.1 Import
-
Task 2: Implement
wifi.stop_ap()insrc/planemapper/provisioning/wifi.py(AC: #1)- 2.1 Stop
hostapdanddnsmasqprocesses (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
- 2.1 Stop
-
Task 3: Implement the Flask app in
src/planemapper/provisioning/portal.py(AC: #2, #3)- 3.1 Create a Flask app instance; import
ProvisioningErrorfromplanemapper.provisioning - 3.2 Implement
GET /— serve the setup form HTML inline (no templates dir needed for MVP): location field, coverage radius field with default100, 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
- 3.1 Create a Flask app instance; import
-
Task 4: Update
provision.pymain()to callwifi.start_ap()inside the provisioning loop (AC: #4)- 4.1 Import
wififromplanemapper.provisioning - 4.2 Call
wifi.start_ap()at the start of the provisioning sequence inside the existingwhile not provisionedloop - 4.3 Ensure the existing
except ProvisioningErrorhandler catches failures fromwifi.start_ap(), logs the error, and resets to portal state viareset_to_portal_state()(or equivalent)
- 4.1 Import
-
Task 5: Write tests (AC: #1, #2, #3, #4)
- 5.1 In
tests/provisioning/test_provision_loop.py— add a test that patchessubprocess.runto return a non-zero exit code, callswifi.start_ap(), and assertsProvisioningErroris raised - 5.2 In
tests/provisioning/test_provision_loop.py— add a test that aProvisioningErrorraised during the provisioning loop is caught, logged at ERROR, and the loop continues (does not crash) - 5.3 Create
tests/provisioning/test_portal.py— testGET /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— testGET /generate_204,GET /hotspot-detect.html,GET /ncsi.txteach 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/
- 5.1 In
-
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
- 6.1
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:
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:
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-locationor JS — exact routing handled in Story 1.4) - A number input named
radiuswith value100 - 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:
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
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 importsportal.py: Flask app and routes only — no subprocess calls; importsProvisioningErrorfromplanemapper.provisioning- Never call
subprocess.runfrom withinportal.py