Implement story 1.3: WiFi hotspot and captive portal form

Implement wifi.py (start_ap/stop_ap/kill_wifi with subprocess and ProvisioningError),
portal.py (Flask captive portal with Android/iOS/Windows probe redirects and 404 catch-all),
updated provision.py provisioning loop, and full test suite (38 passing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Edholm
2026-04-22 22:41:05 -04:00
parent 76c2d66ed1
commit 563b0d4665
7 changed files with 253 additions and 39 deletions
+21 -1
View File
@@ -1,8 +1,28 @@
import logging
from planemapper.provisioning import ProvisioningError, wifi
log = logging.getLogger(__name__)
def _reset_to_portal_state() -> None:
log.info("Resetting to portal state")
try:
wifi.stop_ap()
except Exception:
pass
def main() -> None:
logging.basicConfig(level=logging.INFO)
log.info("not implemented")
provisioned = False
while not provisioned:
try:
wifi.start_ap()
# Portal runs here (Story 1.3+ wires Flask app)
# Provisioning sequence continues in Story 1.5
log.info("Provisioning sequence started")
provisioned = True # placeholder — full sequence wired in 1.5
except ProvisioningError as e:
log.error("Provisioning failed: %s", e)
_reset_to_portal_state()
+53 -1
View File
@@ -1 +1,53 @@
# stub
import logging
from flask import Flask, redirect, url_for
from werkzeug.wrappers import Response
log = logging.getLogger(__name__)
app = Flask(__name__)
_FORM_HTML = """<!DOCTYPE html>
<html>
<head><title>planeMapper Setup</title></head>
<body>
<h1>planeMapper Setup</h1>
<form method="POST" action="/find-location">
<label>Location (ICAO code or address/postcode):<br>
<input type="text" name="location" required>
</label><br><br>
<label>Coverage radius (nm):<br>
<input type="number" name="radius" value="100" min="10" max="500">
</label><br><br>
<button type="submit">Find location</button>
</form>
<hr>
<form method="POST" action="/submit">
<label>Confirmed location: <span id="confirmed-location">Not yet confirmed</span></label><br><br>
<label>Home WiFi SSID:<br>
<input type="text" name="wifi_ssid" required>
</label><br><br>
<label>Home WiFi password:<br>
<input type="password" name="wifi_password" required>
</label><br><br>
<button type="submit">Set up device</button>
</form>
</body>
</html>"""
@app.route("/")
def index() -> str:
return _FORM_HTML
@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"))
+54 -1
View File
@@ -1 +1,54 @@
# stub
import logging
import subprocess
from pathlib import Path
from planemapper.provisioning import ProvisioningError
log = logging.getLogger(__name__)
_HOSTAPD_CONF_PATH = Path("/etc/hostapd/hostapd.conf")
_HOSTAPD_CONF = """\
interface=wlan0
driver=nl80211
ssid=planeMapper-setup
hw_mode=g
channel=6
wmm_enabled=0
auth_algs=1
ignore_broadcast_ssid=0
"""
def _write_hostapd_conf() -> None:
_HOSTAPD_CONF_PATH.parent.mkdir(parents=True, exist_ok=True)
_HOSTAPD_CONF_PATH.write_text(_HOSTAPD_CONF)
def start_ap() -> None:
_write_hostapd_conf()
result = subprocess.run(["hostapd", str(_HOSTAPD_CONF_PATH)], 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}")
log.info("AP started: SSID=planeMapper-setup")
def stop_ap() -> None:
subprocess.run(["pkill", "-f", "hostapd"], check=False)
subprocess.run(["pkill", "-f", "dnsmasq"], check=False)
log.info("AP stopped")
def kill_wifi() -> None:
result = subprocess.run(["rfkill", "block", "wifi"], check=False)
if result.returncode != 0:
log.error("rfkill failed with return code %d", result.returncode)
raise ProvisioningError(f"rfkill failed: returncode={result.returncode}")
log.info("WiFi radio killed")