feat: AP always-on mode - hotspot stays until WiFi STA connects

Redesign provisioning from one-shot blocking to persistent background manager:
- AP hotspot starts at boot regardless of eth0 status
- Captive portal runs alongside AP for WiFi configuration
- AP automatically shuts down only when WiFi STA connects
- WiFi drops at runtime -> AP auto-restarts
- WiFi connect fails -> AP auto-restarts for retry
- client.js no longer blocks on network; connects WS when ready

Made-with: Cursor
This commit is contained in:
stswangzhiping
2026-03-16 12:18:35 +08:00
parent dac68f78b4
commit b42e59fab8
5 changed files with 207 additions and 129 deletions

View File

@@ -2,12 +2,11 @@
const http = require('http');
const log = require('./logger');
const { scanWifi, connectWifi } = require('./network');
const { scanWifi } = require('./network');
const { CAPTIVE_DOMAIN } = require('./dns-hijack');
const PORT = 80;
// iOS / Android / Windows Captive Portal 检测路径
const CAPTIVE_DETECT_PATHS = new Set([
'/hotspot-detect.html', // iOS
'/library/test/success.html', // iOS older
@@ -20,48 +19,43 @@ const CAPTIVE_DETECT_PATHS = new Set([
]);
/**
* 配网 HTTP 服务器。
* 配网 HTTP 服务器(回调模式)
*
* 路由:
* GET / → 配网页面HTML
* GET /api/scan → WiFi 扫描结果 JSON
* POST /api/connect → 提交 WiFi 凭证,尝试连接
* POST /api/connect → 提交 WiFi 凭证,触发 onConnect 回调
* GET /api/status → 当前连接状态
* Captive Portal 检测 → 302 重定向到配网页
*/
class CaptiveServer {
constructor(opts = {}) {
this._server = null;
this._clawId = opts.clawId || '???';
this._resolve = null; // provisioning 等待配网完成的 resolve
this._server = null;
this._clawId = opts.clawId || '???';
this._onConnect = opts.onConnect || null; // (ssid, password) => Promise<{success, error?}>
}
/**
* 启动 HTTP 服务器,返回 Promise配网成功后 resolve。
*/
start() {
return new Promise((resolve) => {
this._resolve = resolve;
this._server = http.createServer((req, res) => {
this._handle(req, res).catch(e => {
log.error('http', `${req.method} ${req.url} 异常:`, e.message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: '内部错误' }));
});
startListening() {
this._server = http.createServer((req, res) => {
this._handle(req, res).catch(e => {
log.error('http', `${req.method} ${req.url} 异常:`, e.message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: '内部错误' }));
});
});
this._server.listen(PORT, '0.0.0.0', () => {
log.info('http', `配网页面就绪: http://${CAPTIVE_DOMAIN} (端口 ${PORT})`);
});
this._server.listen(PORT, '0.0.0.0', () => {
log.info('http', `配网页面就绪: http://${CAPTIVE_DOMAIN} (端口 ${PORT})`);
});
this._server.on('error', (e) => {
if (e.code === 'EACCES') {
log.error('http', `端口 ${PORT} 无权限,请以 root 运行或改用高端口`);
} else {
log.error('http', '服务器错误:', e.message);
}
});
this._server.on('error', (e) => {
if (e.code === 'EACCES') {
log.error('http', `端口 ${PORT} 无权限,请以 root 运行`);
} else if (e.code === 'EADDRINUSE') {
log.error('http', `端口 ${PORT} 已被占用`);
} else {
log.error('http', '服务器错误:', e.message);
}
});
}
@@ -76,14 +70,12 @@ class CaptiveServer {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const pathname = url.pathname;
// Captive Portal 检测请求 → 302 到配网页
if (CAPTIVE_DETECT_PATHS.has(pathname)) {
res.writeHead(302, { Location: `http://${CAPTIVE_DOMAIN}/` });
res.end();
return;
}
// API 路由
if (pathname === '/api/scan' && req.method === 'GET') {
return this._apiScan(req, res);
}
@@ -94,7 +86,6 @@ class CaptiveServer {
return this._apiStatus(req, res);
}
// 默认返回配网页面
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
@@ -125,21 +116,16 @@ class CaptiveServer {
log.info('http', `用户提交配网: ssid=${ssid}`);
// 先返回 "尝试中" 让前端轮询 /api/status
this._json(res, { success: true, message: '正在连接...' });
// 先返回响应,让手机端知道设备收到了请求
// AP 即将关闭,手机会断开连接)
this._json(res, { success: true, message: '正在连接AP 将临时关闭...' });
// 异步连接 WiFi会关闭 AP客户端会断开
setTimeout(async () => {
const result = connectWifi(ssid, password || '');
if (result.success && this._resolve) {
log.info('http', '配网成功,退出配网模式');
this._resolve({ ssid });
this._resolve = null;
} else {
log.warn('http', `配网失败: ${result.error},重新启动 AP`);
// provisioning.js 会处理重新进入 AP
}
}, 500);
// 延迟执行,确保 HTTP 响应送达
if (this._onConnect) {
setTimeout(() => {
this._onConnect(ssid, password || '');
}, 1000);
}
}
_apiStatus(req, res) {
@@ -257,19 +243,18 @@ async function doConnect(){
var pw=$('password').value;
if(!ssid){setStatus('请选择或输入 WiFi','err');return}
$('connectBtn').disabled=true;
setStatus('正在连接 '+ssid+' ...','info');
setStatus('正在连接 '+ssid+' ... 热点将临时关闭','info');
try{
var r=await fetch('/api/connect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:ssid,password:pw})});
var d=await r.json();
if(d.success){
setStatus('✓ 正在连接,请稍候... 设备将自动重启网络','ok');
setTimeout(function(){setStatus('✓ 配网成功!您可以断开此热点','ok')},8000);
setStatus('✓ 设备正在连接 WiFi热点将关闭。如连接失败热点会自动恢复请重新连接配网。','ok');
}else{
setStatus('连接失败: '+(d.error||'未知错误'),'err');
setStatus('失败: '+(d.error||'未知错误'),'err');
$('connectBtn').disabled=false;
}
}catch(e){
setStatus('连接失败: '+e.message,'err');
setStatus('请求失败: '+e.message,'err');
$('connectBtn').disabled=false;
}
}