From f6aad310a8ca6dd9605deed9c70aee91a50e2c2b Mon Sep 17 00:00:00 2001 From: stswangzhiping Date: Sun, 26 Apr 2026 10:57:54 +0800 Subject: [PATCH] fix: improve WiFi AP recovery and scan --- lib/network.js | 135 +++++++++++++++++++++++--- lib/provisioning.js | 227 +++++++++++++++++++++++++++++++------------- 2 files changed, 283 insertions(+), 79 deletions(-) diff --git a/lib/network.js b/lib/network.js index 6261c1f..e85300b 100644 --- a/lib/network.js +++ b/lib/network.js @@ -180,19 +180,24 @@ function scanWifi() { // 等扫描完成 sleep(2000); - const out = run('nmcli -t -f SSID,SIGNAL,SECURITY device wifi list'); + // 指定 ifname,避免 AP/多网卡场景下读取到非目标接口或旧缓存;带回频率便于诊断 2.4G/5G。 + const out = run(`nmcli -t -f SSID,SIGNAL,SECURITY,FREQ device wifi list ifname ${iface}`); const seen = new Set(); const results = []; for (const line of out.split('\n')) { if (!line.trim()) continue; - const parts = line.split(':'); - const ssid = parts[0].trim().replace(/\\:/g, ':'); + const parts = _parseNmcliTerseLine(line); + const ssid = (parts[0] || '').trim(); if (!ssid || seen.has(ssid)) continue; seen.add(ssid); + const freq = (parts[3] || '').trim(); + const freqMhz = parseInt(freq, 10) || null; results.push({ ssid, signal: parseInt(parts[1], 10) || 0, - security: parts.slice(2).join(':').trim() || 'Open', + security: (parts[2] || '').trim() || 'Open', + freq, + band: freqMhz ? (freqMhz >= 4900 ? '5G' : '2.4G') : null, }); } results.sort((a, b) => b.signal - a.signal); @@ -275,6 +280,7 @@ async function connectWifi(ssid, password) { if (password) args.push('password', password); args.push('ifname', iface); await nmcliAsync(args, 120000); + await _ensureActiveWifiAutoconnect(); const deadline = Date.now() + CONNECT_WIFI_STA_WAIT_MS; while (Date.now() < deadline) { @@ -324,6 +330,9 @@ function startAP(clawId) { cmd.push(`password "${AP_PASSWORD}"`); } run(cmd.join(' ')); + try { + nmcliSync(['connection', 'modify', CON_NAME, 'connection.autoconnect', 'no'], 8000); + } catch (_) {} // 等待 AP 启动 sleep(2000); @@ -361,20 +370,120 @@ function sleep(ms) { execSync(`sleep ${ms / 1000}`, { timeout: ms + 2000 }); } +function _parseNmcliTerseLine(line) { + const fields = []; + let cur = ''; + let escaped = false; + for (const ch of line) { + if (escaped) { + cur += ch; + escaped = false; + continue; + } + if (ch === '\\') { + escaped = true; + continue; + } + if (ch === ':') { + fields.push(cur); + cur = ''; + continue; + } + cur += ch; + } + fields.push(cur); + return fields; +} + +/** + * 列出已保存的 WiFi STA 连接(排除自身热点),按 autoconnect-priority 从高到低排序。 + */ +function listSavedWifiConnections() { + const profiles = []; + try { + const out = run('nmcli -t -f NAME,UUID,TYPE,AUTOCONNECT,AUTOCONNECT-PRIORITY connection show'); + for (const line of out.split('\n')) { + if (!line.trim()) continue; + const [name, uuid, type, autoconnect, priority] = _parseNmcliTerseLine(line); + if (type !== '802-11-wireless' || name === CON_NAME) continue; + profiles.push({ + name, + uuid, + autoconnect: autoconnect === 'yes', + priority: parseInt(priority, 10) || 0, + }); + } + } catch (_) {} + profiles.sort((a, b) => { + if (b.priority !== a.priority) return b.priority - a.priority; + if (a.autoconnect !== b.autoconnect) return a.autoconnect ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return profiles; +} + /** * 检测是否有已保存的 WiFi STA 连接(排除自身热点) */ function hasSavedWifiConnection() { + return listSavedWifiConnections().length > 0; +} + +function getWifiActiveConnectionName() { + const iface = getWifiIface(); try { - const out = run('nmcli -t -f NAME,TYPE connection show'); - for (const line of out.split('\n')) { - const [name, type] = line.split(':'); - if (type === '802-11-wireless' && name !== CON_NAME) { - return true; - } - } + const conn = nmcliSync(['-g', 'GENERAL.CONNECTION', 'device', 'show', iface], 8000).trim(); + return conn && conn !== '--' ? conn : null; + } catch (_) { + return null; + } +} + +async function _ensureActiveWifiAutoconnect() { + const conn = getWifiActiveConnectionName(); + if (!conn || conn === CON_NAME) return; + try { + await nmcliAsync(['connection', 'modify', conn, 'connection.autoconnect', 'yes'], 15000); + } catch (e) { + log.warn('network', `设置 WiFi 自动连接失败: ${conn}: ${e.message}`); + } +} + +/** + * 主动让 NetworkManager 尝试已保存 WiFi。 + * clawd 只做调度;真正的认证、DHCP、重连细节仍交给 NM。 + */ +async function connectSavedWifiConnections() { + const iface = getWifiIface(); + const profiles = listSavedWifiConnections(); + if (profiles.length === 0) { + return { success: false, error: '没有已保存的 WiFi 配置' }; + } + + try { + await nmcliAsync(['device', 'set', iface, 'managed', 'yes'], 8000); } catch (_) {} - return false; + + let lastError = ''; + for (const profile of profiles) { + const label = profile.name || profile.uuid; + try { + log.info('network', `尝试连接已保存 WiFi: ${label}(ifname=${iface})`); + const idArgs = profile.uuid ? ['uuid', profile.uuid] : ['id', profile.name]; + await nmcliAsync(['connection', 'up', ...idArgs, 'ifname', iface], 90000); + if (isWifiStaConnected()) { + await _ensureActiveWifiAutoconnect(); + log.info('network', `已保存 WiFi 连接成功: ${label}`); + return { success: true, profile }; + } + lastError = '连接命令完成但网卡未进入 STA connected 状态'; + } catch (e) { + lastError = e.message; + log.warn('network', `已保存 WiFi 连接失败: ${label}: ${e.message}`); + } + } + + return { success: false, error: lastError || '所有已保存 WiFi 均连接失败' }; } /** @@ -427,7 +536,9 @@ module.exports = { hasLanCableCarrier, hasWiredInternetProbe, getWiredIfaceWithCarrier, + listSavedWifiConnections, hasSavedWifiConnection, + connectSavedWifiConnections, isWifiStaConnected, getWifiIface, scanWifi, diff --git a/lib/provisioning.js b/lib/provisioning.js index c695e43..f6e627b 100644 --- a/lib/provisioning.js +++ b/lib/provisioning.js @@ -2,33 +2,38 @@ const EventEmitter = require('events'); const log = require('./logger'); -const { hasInternet, hasSavedWifiConnection, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, getWifiIface, AP_IP } = require('./network'); +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 BOOT_WAIT_MAX_MS = 20_000; // 等待 NM 自动连接的最大时间 -const BOOT_POLL_MS = 2_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 配置 → 等 NM 自动连接(最多 20 秒) - * - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页 - * - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP - * - 运行中 WiFi 断开 → 自动重新开 AP - * - WiFi 已连接 → 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' + 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 热点广播中) */ @@ -46,39 +51,35 @@ class ProvisionManager extends EventEmitter { return; } - // 有线有网 → 立即通知 WS 连接,后台异步设置 AP 供 WiFi 配网 + // 有线网络可用时,网络已就绪;但不自动开启 AP,不抢占 wlan0。 if (hasInternet()) { - log.info('provision', '有线网络就绪,立即启动 WS,AP 后台准备中...'); + this._state = 'wired'; + log.info('provision', '有线网络就绪,启动 WS;不自动开启 AP'); + led.off(); this._emitNetworkReady(); - setTimeout(() => { - this._enterAP(); - this._startMonitor(); - }, 0); + this._startMonitor(); return; } - // 无网:有已保存的 WiFi 配置 → 等 NM 自动连接(重启场景) + // 无有线可用时,有 saved WiFi 才主动让 NetworkManager 重连;不要只被动等待 NM autoconnect。 if (hasSavedWifiConnection()) { - log.info('provision', '发现已保存的 WiFi 配置,等待 NetworkManager 自动连接...'); - led.blink(); // WiFi 灯:等待自动重连期间闪烁 - const connected = await this._waitForWifiConnect(); + 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 不启动'); + log.info('provision', '已保存 WiFi 重连成功,AP 不启动'); this._emitNetworkReady(); this._startMonitor(); return; } - log.warn('provision', 'WiFi 自动连接超时,启动 AP'); + log.warn('provision', '已保存 WiFi 重连失败'); } - // 没有已保存 WiFi 或等待超时 → 开 AP + // 无有线、无 WiFi;且无 saved WiFi 或 saved WiFi 3 轮失败 → 开 AP 兜底配网。 this._enterAP(); this._startMonitor(); - - if (hasInternet()) { - this._emitNetworkReady(); - } } _emitNetworkReady() { @@ -91,23 +92,17 @@ class ProvisionManager extends EventEmitter { } } - /** - * 轮询等待 NM 自动连接 WiFi,最多等 BOOT_WAIT_MAX_MS - */ - _waitForWifiConnect() { - return new Promise(resolve => { - let elapsed = 0; - const timer = setInterval(() => { - elapsed += BOOT_POLL_MS; - if (isWifiStaConnected()) { - clearInterval(timer); - resolve(true); - } else if (elapsed >= BOOT_WAIT_MAX_MS) { - clearInterval(timer); - resolve(false); - } - }, BOOT_POLL_MS); - }); + 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() { @@ -126,10 +121,13 @@ class ProvisionManager extends EventEmitter { 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} 个网络`); + log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络: ${this._cachedWifiList.map(w => `${w.ssid}${w.band ? `(${w.band})` : ''}`).join(', ')}`); // 写 DNS 劫持配置(NM 启动热点时加载);接口名与热点一致,勿写死 wlan0 this._dns = new DnsHijack(); @@ -145,6 +143,8 @@ class ProvisionManager extends EventEmitter { 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) { @@ -178,16 +178,27 @@ class ProvisionManager extends EventEmitter { return result; } - log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`); - this._safeReenterAP(); + log.warn('provision', `WiFi 连接失败: ${result.error},按当前网络状态恢复`); + this._recoverAfterWifiFailure(); return result; } catch (e) { log.error('provision', `配网过程异常: ${e.message}`); - this._safeReenterAP(); + 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 { @@ -202,34 +213,116 @@ class ProvisionManager extends EventEmitter { _startMonitor() { this._monitorTimer = setInterval(() => { - if (this._state === 'connecting') return; + if (this._monitorBusy) return; + this._monitorBusy = true; + this._monitorTick() + .catch((e) => log.error('provision', `WiFi 状态监控异常: ${e.message}`)) + .finally(() => { this._monitorBusy = false; }); + }, MONITOR_INTERVAL_MS); + } - const wifiUp = isWifiStaConnected(); + async _monitorTick() { + if (this._state === 'connecting') return; - if (this._state === 'sta' && !wifiUp) { - log.warn('provision', 'WiFi 连接已断开,重新启动 AP'); - this._enterAP(); // 内部调用 led.off() + 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 (this._state === 'ap' && wifiUp) { - log.info('provision', 'WiFi 已外部连接,关闭 AP'); - this._stopAPServices(); - this._state = 'sta'; - this.emit('network-ready'); + if (hasSavedWifiConnection() && this._shouldRetrySavedWifiFromAP()) { + await this._retrySavedWifiFromAP(); + return; } + led.off(); + return; + } + } - // 产品 WiFi 灯(OpenVFD wifi+eth):AP 全程强制熄灭,避免与其它逻辑竞态导致误亮 - if (this._state === 'ap') { - led.off(); - } else if (this._state === 'sta') { - if (hasInternet()) { - led.on(); - } else { - led.off(); // STA 已连热点但无互联网 - } + 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; } - }, MONITOR_INTERVAL_MS); + } + + 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() {