Files
planeMapper/_bmad-output/implementation-artifacts/1-3-wifi-hotspot-and-captive-portal-form.md
T
2026-04-22 22:42:20 -04:00

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

  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:

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-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:

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 imports
  • portal.py: Flask app and routes only — no subprocess calls; imports ProvisioningError from planemapper.provisioning
  • Never call subprocess.run from within portal.py