Files
clawd/lib/provisioning.js
2026-04-26 10:57:54 +08:00

355 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const EventEmitter = require('events');
const log = require('./logger');
const { hasInternet, hasSavedWifiConnection, connectSavedWifiConnections, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, getWifiIface, AP_IP } = require('./network');
const { DnsHijack } = require('./dns-hijack');
const { CaptiveServer } = require('./captive-server');
const led = require('./led');
const MONITOR_INTERVAL_MS = 15_000;
const WIFI_RECONNECT_MAX_ROUNDS = 3;
const WIFI_RECONNECT_ROUND_DELAY_MS = 5_000;
const AP_SAVED_WIFI_RETRY_INTERVAL_MS = 180_000;
const AP_MIN_UP_BEFORE_RETRY_MS = 60_000;
/**
* AP 常驻配网管理器。
*
* 规则:
* - 启动时WiFi STA 优先;有已保存 WiFi 时主动让 NM 重连,最多 3 轮
* - 有线网络可用时:通知网络就绪,但不自动开启 AP
* - 自动开 AP 的唯一兜底:无有线/无 WiFi且无 saved WiFi 或 saved WiFi 3 轮失败
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则按网络状态决定是否重新开 AP
* - AP 状态下:若仍无有线网络,低频释放 wlan0 并尝试 saved WiFi
*/
class ProvisionManager extends EventEmitter {
constructor(clawId) {
super();
this._clawId = clawId || 'Setup';
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta' | 'wired'
this._dns = null;
this._server = null;
this._monitorTimer = null;
this._monitorBusy = false;
this._apStartedAt = 0;
this._lastApSavedWifiRetryAt = 0;
}
/** 是否正处于 AP 模式WiFi 热点广播中) */
isApMode() { return this._state === 'ap'; }
async start() {
led.off(); // WiFi 灯初始状态:熄灭
// WiFi STA 已连接 → 直接进入 STA 模式
if (isWifiStaConnected()) {
this._state = 'sta';
log.info('provision', 'WiFi STA 已连接AP 不启动');
this._emitNetworkReady();
this._startMonitor();
return;
}
// 有线网络可用时,网络已就绪;但不自动开启 AP不抢占 wlan0。
if (hasInternet()) {
this._state = 'wired';
log.info('provision', '有线网络就绪,启动 WS不自动开启 AP');
led.off();
this._emitNetworkReady();
this._startMonitor();
return;
}
// 无有线可用时,有 saved WiFi 才主动让 NetworkManager 重连;不要只被动等待 NM autoconnect。
if (hasSavedWifiConnection()) {
log.info('provision', `发现已保存的 WiFi 配置,主动重连(最多 ${WIFI_RECONNECT_MAX_ROUNDS} 轮)...`);
this._state = 'connecting';
led.blink();
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
if (connected) {
this._state = 'sta';
log.info('provision', '已保存 WiFi 重连成功AP 不启动');
this._emitNetworkReady();
this._startMonitor();
return;
}
log.warn('provision', '已保存 WiFi 重连失败');
}
// 无有线、无 WiFi且无 saved WiFi 或 saved WiFi 3 轮失败 → 开 AP 兜底配网。
this._enterAP();
this._startMonitor();
}
_emitNetworkReady() {
if (hasInternet()) {
// WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮)
if (this._state === 'sta') led.on();
this.emit('network-ready');
} else {
log.warn('provision', 'hasInternet() 返回 falseLED 保持熄灭');
}
}
async _trySavedWifiReconnectRounds(rounds = WIFI_RECONNECT_MAX_ROUNDS) {
for (let i = 1; i <= rounds; i++) {
if (isWifiStaConnected()) return true;
log.info('provision', `尝试已保存 WiFi 重连:第 ${i}/${rounds}`);
const result = await connectSavedWifiConnections();
if (result.success || isWifiStaConnected()) return true;
if (i < rounds) {
await new Promise((r) => setTimeout(r, WIFI_RECONNECT_ROUND_DELAY_MS));
}
}
return false;
}
stop() {
this._stopMonitor();
this._stopAll();
this._state = 'idle';
led.destroy(); // WiFi 灯:停止时关灯、释放闪烁定时器
}
// ── 进入 AP 模式 ─────────────────────────────────────────────────────────
_enterAP() {
if (this._state === 'ap') return;
led.off(); // AP 模式WiFi 未连接WiFi 灯熄灭
if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP有线时等 WS 连接后再定
try {
// 若上次进程退出前留下 clawd-hotspot必须先释放 wlan0否则会在 AP 模式下扫描,列表可能只剩 2.4G/自身热点。
stopAP();
// AP 模式下无法扫描 WiFi必须在开 AP 之前扫描并缓存
log.info('provision', '扫描周边 WiFi...');
this._cachedWifiList = scanWifi();
log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络: ${this._cachedWifiList.map(w => `${w.ssid}${w.band ? `(${w.band})` : ''}`).join(', ')}`);
// 写 DNS 劫持配置NM 启动热点时加载);接口名与热点一致,勿写死 wlan0
this._dns = new DnsHijack();
this._dns.start(getWifiIface(), AP_IP);
const ap = startAP(this._clawId);
this._server = new CaptiveServer({
clawId: this._clawId,
cachedWifiList: this._cachedWifiList,
onConnect: (ssid, password) => this._handleWifiConnect(ssid, password),
});
this._server.startListening();
this._state = 'ap';
this._apStartedAt = Date.now();
this._lastApSavedWifiRetryAt = 0;
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
log.info('provision', `配网地址: http://10.42.0.1`);
} catch (e) {
log.error('provision', `AP 启动失败: ${e.message}`);
if (this._state !== 'sta') this._state = 'idle';
}
}
// ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
async _handleWifiConnect(ssid, password) {
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
this._state = 'connecting';
log.info('provision', `用户请求连接 WiFi: ${ssid}`);
led.blink(); // 正在连接 → 闪烁
try {
this._stopAPServices();
// 关热点后射频/模式切换需要时间,立刻 connect 在部分板子上会失败
await new Promise((r) => setTimeout(r, 3500));
const result = await connectWifi(ssid, password);
if (result.success) {
this._state = 'sta';
log.info('provision', `WiFi 已连接: ${ssid}`);
led.on(); // WiFi 灯:连接成功 → 常亮
this.emit('network-ready');
return result;
}
log.warn('provision', `WiFi 连接失败: ${result.error},按当前网络状态恢复`);
this._recoverAfterWifiFailure();
return result;
} catch (e) {
log.error('provision', `配网过程异常: ${e.message}`);
this._recoverAfterWifiFailure();
return { success: false, error: e.message };
}
}
/** WiFi 连接失败后:有线可用则保持 wired否则开 AP 兜底。 */
_recoverAfterWifiFailure() {
if (hasInternet()) {
this._state = 'wired';
led.off();
this._emitNetworkReady();
return;
}
this._safeReenterAP();
}
/** 重新开 AP失败时勿把 _state 永久卡在 connecting */
_safeReenterAP() {
try {
this._enterAP();
} catch (e) {
log.error('provision', `重新启动 AP 失败: ${e.message}`);
this._state = 'idle';
}
}
// ── WiFi 状态监控 ─────────────────────────────────────────────────────────
_startMonitor() {
this._monitorTimer = setInterval(() => {
if (this._monitorBusy) return;
this._monitorBusy = true;
this._monitorTick()
.catch((e) => log.error('provision', `WiFi 状态监控异常: ${e.message}`))
.finally(() => { this._monitorBusy = false; });
}, MONITOR_INTERVAL_MS);
}
async _monitorTick() {
if (this._state === 'connecting') return;
const wifiUp = isWifiStaConnected();
if (wifiUp && this._state !== 'sta') {
if (this._state === 'ap') {
log.info('provision', 'WiFi 已外部连接,关闭 AP');
this._stopAPServices();
}
this._state = 'sta';
this.emit('network-ready');
}
if (this._state === 'sta' && !wifiUp) {
log.warn('provision', 'WiFi 连接已断开,尝试恢复网络');
await this._recoverNetworkWithoutWifi();
return;
}
if (this._state === 'wired') {
if (!hasInternet()) {
log.warn('provision', '有线网络不可用,尝试恢复 WiFi');
await this._recoverNetworkWithoutWifi();
return;
}
led.off();
return;
}
if (this._state === 'ap') {
if (hasInternet()) {
log.info('provision', '检测到有线网络可用,关闭 AP');
this._stopAPServices();
this._state = 'wired';
this._emitNetworkReady();
return;
}
if (hasSavedWifiConnection() && this._shouldRetrySavedWifiFromAP()) {
await this._retrySavedWifiFromAP();
return;
}
led.off();
return;
}
}
async _recoverNetworkWithoutWifi() {
this._state = 'connecting';
led.blink();
if (hasSavedWifiConnection()) {
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
if (connected) {
this._state = 'sta';
this._emitNetworkReady();
return;
}
}
if (hasInternet()) {
this._state = 'wired';
led.off();
this._emitNetworkReady();
return;
}
this._safeReenterAP();
}
_shouldRetrySavedWifiFromAP() {
const now = Date.now();
if (this._apStartedAt && now - this._apStartedAt < AP_MIN_UP_BEFORE_RETRY_MS) return false;
if (this._lastApSavedWifiRetryAt && now - this._lastApSavedWifiRetryAt < AP_SAVED_WIFI_RETRY_INTERVAL_MS) return false;
return true;
}
async _retrySavedWifiFromAP() {
this._lastApSavedWifiRetryAt = Date.now();
log.info('provision', 'AP 模式下定期尝试已保存 WiFi');
this._state = 'connecting';
led.blink();
this._stopAPServices();
await new Promise((r) => setTimeout(r, 3500));
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
if (connected) {
this._state = 'sta';
this._emitNetworkReady();
return;
}
if (hasInternet()) {
this._state = 'wired';
led.off();
this._emitNetworkReady();
return;
}
log.warn('provision', 'AP 模式下重试已保存 WiFi 失败,恢复 AP');
this._safeReenterAP();
}
_stopMonitor() {
if (this._monitorTimer) {
clearInterval(this._monitorTimer);
this._monitorTimer = null;
}
}
// ── 清理 ──────────────────────────────────────────────────────────────────
_stopAPServices() {
if (this._server) {
this._server.stop();
this._server = null;
}
if (this._dns) {
this._dns.stop();
this._dns = null;
}
stopAP();
}
_stopAll() {
this._stopAPServices();
}
}
module.exports = { ProvisionManager };