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