#!/usr/bin/env python3
"""Captive portal for Remote Hands Pi — WiFi configuration via web form."""
import subprocess, re, os, time, threading
from flask import Flask, render_template_string, request, jsonify
app = Flask(__name__)
HTML = '''<!DOCTYPE html>
<html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Hermes Pi WiFi</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}body{font-family:system-ui,sans-serif;background:#0d1117;color:#c9d1d9;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
.c{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:28px;max-width:440px;width:100%}
h1{font-size:1.4rem;color:#58a6ff;margin-bottom:4px}.sub{font-size:.85rem;color:#8b949e;margin-bottom:20px}
label{display:block;font-size:.8rem;color:#8b949e;margin:12px 0 4px}
input{width:100%;padding:10px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#c9d1d9;font-size:1rem}
input:focus{border-color:#58a6ff;outline:none}
button{margin-top:16px;width:100%;padding:12px;border:none;border-radius:6px;font-size:1rem;font-weight:600;cursor:pointer}
.b1{background:#238636;color:#fff}.b2{background:#30363d;color:#c9d1d9;margin-top:10px}
.nets{margin-top:16px;max-height:240px;overflow-y:auto}.ni{display:flex;align-items:center;padding:8px;border-radius:6px;cursor:pointer;gap:10px}.ni:hover{background:#21262d}
.ns{flex:1;font-size:.9rem}.nb{width:60px;height:6px;background:#21262d;border-radius:3px;overflow:hidden}.nbar{height:100%;border-radius:3px}
.ne{font-size:.65rem;padding:2px 6px;border-radius:4px;font-weight:600}.sg{background:#238636}.sy{background:#d29922}.sr{background:#f85149}
.ew{background:#6e4b00;color:#d29922}.eo{background:#1c3a5c;color:#58a6ff}
.st{margin-top:16px;padding:12px;border-radius:6px;font-size:.9rem;text-align:center;line-height:1.6}
.sc{background:#1c3a5c;color:#58a6ff}.ss{background:#0d2818;color:#3fb950}.se{background:#4a1c1c;color:#f85149}.h{display:none}
.check{font-size:2.5rem;margin-bottom:10px}
</style></head><body><div class=c>
<h1>Hermes Pi WiFi Setup</h1><p class=sub>Connect the Pi to a WiFi network</p>
<div id=fs><label for=ssid>Network Name</label><input type=text id=ssid placeholder=Network>
<label for=pw>Password</label><input type=password id=pw placeholder=Password>
<button class=b1 onclick=go()>Connect</button><button class=b2 onclick=scan()>Scan Networks</button></div>
<div id=ss class=h><label>Networks (click to select)</label><div id=nl class=nets></div>
<button class=b2 onclick=clearS()>Done</button></div>
<div id=sts class=h></div></div>
<script>
function go(){var s=document.getElementById('ssid').value.trim(),p=document.getElementById('pw').value;
if(!s){alert('Enter network name');return}document.getElementById('fs').classList.add('h');
var e=document.getElementById('sts');e.classList.remove('h');e.className='st sc';e.innerHTML='<div class=check>⟳</div>Writing WiFi config...';
fetch('/connect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:s,password:p})}).then(r=>r.json()).then(d=>{
if(d.error){e.className='st se';e.innerHTML='<div class=check>✕</div>Error: '+d.error+'<br><button class=b2 onclick=location.reload()>Try Again</button>'}
else{e.className='st ss';e.innerHTML='<div class=check>✓</div><b>WiFi config saved!</b><br><br>Connecting to '+d.ssid+'...<br><br><i>The Pi will now switch from hotspot to WiFi mode.<br>This takes about 15-30 seconds.</i><br><br>After connecting:<br>• Tailscale IP: <b>'+d.tailscale_ip+'</b><br>• SSH: ssh hermes@hermes-pi<br>• Reconnect to Pi via Tailscale or scan your LAN for 10.0.0.x'}.catch(e=>{
e.className='st se';e.innerHTML='<div class=check>✕</div>Error: '+e.message;document.getElementById('fs').classList.remove('h')})}
function scan(){document.getElementById('nl').innerHTML='<p style=color:#8b949e;padding:8px>Scanning...</p>';
document.getElementById('ss').classList.remove('h');fetch('/scan').then(r=>r.json()).then(ns=>{
var l=document.getElementById('nl');if(!ns.length){l.innerHTML='<p style=color:#8b949e;padding:8px>None found</p>';return}
l.innerHTML=ns.map(n=>{var pct=Math.max(0,Math.min(100,Math.round((n.signal+100)*2)));
var c=pct>=70?'sg':pct>=40?'sy':'sr',ec=n.enc=='WPA2'?'ew':n.enc=='WEP'?'sr':'eo';
return'<div class=ni onclick=sN("'+n.ssid+'")><span class=ns>'+n.ssid+'</span><div class=nb><div class=nbar style="width:'+pct+'%;background:'+c+'"></div></div><span class="ne '+ec+'">'+(n.enc=='Unknown'?'Open':n.enc)+'</span></div>'}).join('')})}
function sN(s){document.getElementById('ssid').value=s;document.getElementById('ss').classList.add('h');document.getElementById('fs').classList.remove('h');document.getElementById('pw').focus()}
function clearS(){document.getElementById('ss').classList.add('h');document.getElementById('fs').classList.remove('h')}
</script></body></html>'''
def run_scan():
try:
r = subprocess.run(['sudo','iwlist','wlan0','scan'], capture_output=True, text=True, timeout=15)
nets, cells = [], r.stdout.split('Cell')
for c in cells[1:]:
sm = re.search(r'ESSID:"([^"]*)"', c)
sl = re.search(r'Signal level[=:](-?\d+)', c)
enc = 'WPA2' if 'WPA2' in c else 'WPA' if 'WPA' in c else 'WEP' if 'WEP' in c else 'Unknown'
if sm and sl: nets.append({'ssid':sm.group(1),'signal':int(sl.group(1)),'enc':enc})
nets.sort(key=lambda n:n['signal'], reverse=True)
seen, out = set(), []
for n in nets:
if n['ssid'] not in seen: seen.add(n['ssid']); out.append(n)
return out
except: return []
def do_switch(ssid, pw):
"""Run network switch in background after response is sent."""
time.sleep(2) # Give HTTP response time to reach browser
try:
subprocess.run(['sudo','systemctl','stop','hostapd','dnsmasq','hermes-portal'], timeout=10)
except: pass
try:
subprocess.run(['sudo','ip','addr','flush','wlan0'], timeout=5)
subprocess.run(['sudo','ip','link','set','wlan0','up'], timeout=5)
subprocess.run(['sudo','systemctl','restart','wpa_supplicant'], timeout=10)
subprocess.run(['sudo','systemctl','restart','NetworkManager'], timeout=10)
time.sleep(8)
except: pass
try:
subprocess.run(['sudo','tailscale','up','--ssh'], capture_output=True, text=True, timeout=30)
except: pass
def switch(ssid, pw):
"""Write config, start background switch thread, return confirmation immediately."""
# Write wpa_supplicant config
try:
with open('/etc/wpa_supplicant/wpa_supplicant.conf','w') as f:
f.write(f'country=US\nctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\nupdate_config=1\n\nnetwork={{\n ssid="{ssid}"\n psk="{pw}"\n key_mgmt=WPA-PSK\n}}\n')
os.chmod('/etc/wpa_supplicant/wpa_supplicant.conf', 0o600)
except Exception as e:
return {'error':str(e), 'tailscale_ip':'', 'ssid':''}
# Get current tailscale IP for display (might be from old connection)
try:
r = subprocess.run(['tailscale','ip'], capture_output=True, text=True, timeout=5)
ts = r.stdout.strip().split()[0] if r.returncode==0 else 'pending...'
except: ts = 'pending...'
# Start background switch (will stop portal after response sent)
t = threading.Thread(target=do_switch, args=(ssid, pw), daemon=True)
t.start()
return {'error':None, 'tailscale_ip': ts, 'ssid': ssid}
@app.route('/')
def index(): return render_template_string(HTML)
@app.route('/scan')
def scan_ep(): return jsonify(run_scan())
@app.route('/connect', methods=['POST'])
def connect_ep():
d = request.get_json()
if not d.get('ssid','').strip(): return jsonify({'error':'SSID required', 'tailscale_ip':'', 'ssid':''})
return jsonify(switch(d['ssid'], d.get('password','')))
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80, debug=False)