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

@@ -1,93 +1,147 @@
'use strict';
const EventEmitter = require('events');
const log = require('./logger');
const { hasInternet, startAP, stopAP, AP_IP } = require('./network');
const { DnsHijack } = require('./dns-hijack');
const { CaptiveServer } = require('./captive-server');
const { hasInternet, isWifiStaConnected, startAP, stopAP, connectWifi, AP_IP } = require('./network');
const { DnsHijack } = require('./dns-hijack');
const { CaptiveServer } = require('./captive-server');
const config = require('./config');
const MAX_RETRIES = 3; // 配网连接失败后最多重新进入 AP 模式次数
const MONITOR_INTERVAL_MS = 30_000;
/**
* 确保设备有互联网连接
* 已联网 → 直接返回
* 未联网 → 进入 AP 配网模式 → 等待用户配网 → 成功后返回
* AP 常驻配网管理器
*
* @param {object} opts
* @param {string|number} opts.clawId - 设备 ID用于 AP SSID
* @returns {Promise<void>}
* 规则:
* - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP
* - 运行中 WiFi 断开 → 自动重新开 AP
* - WiFi 已连接 → AP 关闭
*/
async function ensureNetwork(opts = {}) {
// 先检测是否已联网
if (hasInternet()) {
log.info('provision', '网络已就绪,跳过配网');
return;
class ProvisionManager extends EventEmitter {
constructor(clawId) {
super();
this._clawId = clawId || 'Setup';
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta'
this._dns = null;
this._server = null;
this._monitorTimer = null;
}
log.warn('provision', '未检测到网络,进入配网模式...');
start() {
if (isWifiStaConnected()) {
this._state = 'sta';
log.info('provision', 'WiFi STA 已连接AP 不启动');
} else {
this._enterAP();
}
this._startMonitor();
const cfg = config.load();
const clawId = opts.clawId || cfg.claw_id || 'Setup';
let retries = 0;
if (hasInternet()) {
this.emit('network-ready');
}
}
stop() {
this._stopMonitor();
this._stopAll();
this._state = 'idle';
}
// ── 进入 AP 模式 ─────────────────────────────────────────────────────────
_enterAP() {
if (this._state === 'ap') return;
while (retries < MAX_RETRIES) {
try {
await runProvisioningRound(clawId);
const ap = startAP(this._clawId);
// 配网成功,再验证一次
if (hasInternet()) {
log.info('provision', '配网完成,网络已就绪');
return;
this._dns = new DnsHijack();
this._dns.start(ap.iface, AP_IP);
this._server = new CaptiveServer({
clawId: this._clawId,
onConnect: (ssid, password) => this._handleWifiConnect(ssid, password),
});
this._server.startListening();
this._state = 'ap';
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
log.info('provision', `配网地址: http://ap.cutos.ai`);
} catch (e) {
log.error('provision', `AP 启动失败: ${e.message}`);
}
}
// ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
async _handleWifiConnect(ssid, password) {
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
this._state = 'connecting';
log.info('provision', `用户请求连接 WiFi: ${ssid}`);
this._stopAPServices();
const result = connectWifi(ssid, password);
if (result.success) {
this._state = 'sta';
log.info('provision', `WiFi 已连接: ${ssid}`);
this.emit('network-ready');
return result;
}
log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`);
this._enterAP();
return result;
}
// ── WiFi 状态监控 ─────────────────────────────────────────────────────────
_startMonitor() {
this._monitorTimer = setInterval(() => {
if (this._state === 'connecting') return;
const wifiUp = isWifiStaConnected();
if (this._state === 'sta' && !wifiUp) {
log.warn('provision', 'WiFi 连接已断开,重新启动 AP');
this._enterAP();
}
log.warn('provision', '配网后仍无网络,重新进入配网模式...');
} catch (e) {
log.error('provision', `配网异常: ${e.message}`);
}
if (this._state === 'ap' && wifiUp) {
log.info('provision', 'WiFi 已外部连接,关闭 AP');
this._stopAPServices();
this._state = 'sta';
this.emit('network-ready');
}
}, MONITOR_INTERVAL_MS);
}
retries++;
if (retries < MAX_RETRIES) {
log.info('provision', `重试配网 (${retries}/${MAX_RETRIES})...`);
// 等一会再重试,避免过快循环
await sleep(3000);
_stopMonitor() {
if (this._monitorTimer) {
clearInterval(this._monitorTimer);
this._monitorTimer = null;
}
}
log.error('provision', `配网失败 ${MAX_RETRIES} 次,将以离线模式继续启动(等待网络恢复后重连)`);
}
// ── 清理 ──────────────────────────────────────────────────────────────────
/**
* 单轮配网流程:开 AP → 启动 DNS + HTTP → 等待用户配网 → 清理
*/
async function runProvisioningRound(clawId) {
const dns = new DnsHijack();
const server = new CaptiveServer({ clawId });
try {
// 1. 启动 WiFi AP
const ap = startAP(clawId);
// 2. 启动 DNS 劫持
dns.start(ap.iface, AP_IP);
// 3. 启动 HTTP 配网页面,等待用户完成配网
// server.start() 返回 Promise配网成功时 resolve
log.info('provision', '配网页面已就绪,等待用户操作...');
log.info('provision', `用户请连接 WiFi "${ap.ssid}" 并访问 http://ap.cutos.ai`);
const result = await server.start();
log.info('provision', `用户已连接 WiFi: ${result.ssid}`);
} finally {
// 清理:无论成功失败都关闭 AP / DNS / HTTP
server.stop();
dns.stop();
_stopAPServices() {
if (this._server) {
this._server.stop();
this._server = null;
}
if (this._dns) {
this._dns.stop();
this._dns = null;
}
stopAP();
}
_stopAll() {
this._stopAPServices();
}
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
module.exports = { ensureNetwork };
module.exports = { ProvisionManager };