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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user