fix: improve WiFi AP recovery and scan

This commit is contained in:
stswangzhiping
2026-04-26 10:57:54 +08:00
parent 18bea4ae38
commit f6aad310a8
2 changed files with 283 additions and 79 deletions

View File

@@ -180,19 +180,24 @@ function scanWifi() {
// 等扫描完成 // 等扫描完成
sleep(2000); 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 seen = new Set();
const results = []; const results = [];
for (const line of out.split('\n')) { for (const line of out.split('\n')) {
if (!line.trim()) continue; if (!line.trim()) continue;
const parts = line.split(':'); const parts = _parseNmcliTerseLine(line);
const ssid = parts[0].trim().replace(/\\:/g, ':'); const ssid = (parts[0] || '').trim();
if (!ssid || seen.has(ssid)) continue; if (!ssid || seen.has(ssid)) continue;
seen.add(ssid); seen.add(ssid);
const freq = (parts[3] || '').trim();
const freqMhz = parseInt(freq, 10) || null;
results.push({ results.push({
ssid, ssid,
signal: parseInt(parts[1], 10) || 0, 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); results.sort((a, b) => b.signal - a.signal);
@@ -275,6 +280,7 @@ async function connectWifi(ssid, password) {
if (password) args.push('password', password); if (password) args.push('password', password);
args.push('ifname', iface); args.push('ifname', iface);
await nmcliAsync(args, 120000); await nmcliAsync(args, 120000);
await _ensureActiveWifiAutoconnect();
const deadline = Date.now() + CONNECT_WIFI_STA_WAIT_MS; const deadline = Date.now() + CONNECT_WIFI_STA_WAIT_MS;
while (Date.now() < deadline) { while (Date.now() < deadline) {
@@ -324,6 +330,9 @@ function startAP(clawId) {
cmd.push(`password "${AP_PASSWORD}"`); cmd.push(`password "${AP_PASSWORD}"`);
} }
run(cmd.join(' ')); run(cmd.join(' '));
try {
nmcliSync(['connection', 'modify', CON_NAME, 'connection.autoconnect', 'no'], 8000);
} catch (_) {}
// 等待 AP 启动 // 等待 AP 启动
sleep(2000); sleep(2000);
@@ -361,20 +370,120 @@ function sleep(ms) {
execSync(`sleep ${ms / 1000}`, { timeout: ms + 2000 }); 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 连接(排除自身热点) * 检测是否有已保存的 WiFi STA 连接(排除自身热点)
*/ */
function hasSavedWifiConnection() { function hasSavedWifiConnection() {
return listSavedWifiConnections().length > 0;
}
function getWifiActiveConnectionName() {
const iface = getWifiIface();
try { try {
const out = run('nmcli -t -f NAME,TYPE connection show'); const conn = nmcliSync(['-g', 'GENERAL.CONNECTION', 'device', 'show', iface], 8000).trim();
for (const line of out.split('\n')) { return conn && conn !== '--' ? conn : null;
const [name, type] = line.split(':'); } catch (_) {
if (type === '802-11-wireless' && name !== CON_NAME) { return null;
return true;
} }
}
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 (_) {} } 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, hasLanCableCarrier,
hasWiredInternetProbe, hasWiredInternetProbe,
getWiredIfaceWithCarrier, getWiredIfaceWithCarrier,
listSavedWifiConnections,
hasSavedWifiConnection, hasSavedWifiConnection,
connectSavedWifiConnections,
isWifiStaConnected, isWifiStaConnected,
getWifiIface, getWifiIface,
scanWifi, scanWifi,

View File

@@ -2,33 +2,38 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const log = require('./logger'); 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 { DnsHijack } = require('./dns-hijack');
const { CaptiveServer } = require('./captive-server'); const { CaptiveServer } = require('./captive-server');
const led = require('./led'); const led = require('./led');
const MONITOR_INTERVAL_MS = 15_000; const MONITOR_INTERVAL_MS = 15_000;
const BOOT_WAIT_MAX_MS = 20_000; // 等待 NM 自动连接的最大时间 const WIFI_RECONNECT_MAX_ROUNDS = 3;
const BOOT_POLL_MS = 2_000; // 轮询间隔 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 常驻配网管理器。 * AP 常驻配网管理器。
* *
* 规则: * 规则:
* - 启动时:有已保存 WiFi 配置 → 等 NM 自动连接(最多 20 秒) * - 启动时:WiFi STA 优先;有已保存 WiFi 时主动让 NM 重连,最多 3 轮
* - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页 * - 有线网络可用时:通知网络就绪,但不自动开启 AP
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP * - 自动开 AP 的唯一兜底:无有线/无 WiFi且无 saved WiFi 或 saved WiFi 3 轮失败
* - 运行中 WiFi 断开 → 自动重新开 AP * - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则按网络状态决定是否重新开 AP
* - WiFi 已连接 → AP 关闭 * - AP 状态下:若仍无有线网络,低频释放 wlan0 并尝试 saved WiFi
*/ */
class ProvisionManager extends EventEmitter { class ProvisionManager extends EventEmitter {
constructor(clawId) { constructor(clawId) {
super(); super();
this._clawId = clawId || 'Setup'; this._clawId = clawId || 'Setup';
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta' this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta' | 'wired'
this._dns = null; this._dns = null;
this._server = null; this._server = null;
this._monitorTimer = null; this._monitorTimer = null;
this._monitorBusy = false;
this._apStartedAt = 0;
this._lastApSavedWifiRetryAt = 0;
} }
/** 是否正处于 AP 模式WiFi 热点广播中) */ /** 是否正处于 AP 模式WiFi 热点广播中) */
@@ -46,39 +51,35 @@ class ProvisionManager extends EventEmitter {
return; return;
} }
// 有线有网 → 立即通知 WS 连接,后台异步设置 AP 供 WiFi 配网 // 有线网络可用时,网络已就绪;但不自动开启 AP不抢占 wlan0。
if (hasInternet()) { if (hasInternet()) {
log.info('provision', '有线网络就绪,立即启动 WSAP 后台准备中...'); this._state = 'wired';
log.info('provision', '有线网络就绪,启动 WS不自动开启 AP');
led.off();
this._emitNetworkReady(); this._emitNetworkReady();
setTimeout(() => {
this._enterAP();
this._startMonitor(); this._startMonitor();
}, 0);
return; return;
} }
// 无网:有已保存的 WiFi 配置 → 等 NM 自动连接(重启场景) // 无有线可用时,有 saved WiFi 才主动让 NetworkManager 重连;不要只被动等待 NM autoconnect。
if (hasSavedWifiConnection()) { if (hasSavedWifiConnection()) {
log.info('provision', '发现已保存的 WiFi 配置,等待 NetworkManager 自动连接...'); log.info('provision', `发现已保存的 WiFi 配置,主动重连(最多 ${WIFI_RECONNECT_MAX_ROUNDS} 轮)...`);
led.blink(); // WiFi 灯:等待自动重连期间闪烁 this._state = 'connecting';
const connected = await this._waitForWifiConnect(); led.blink();
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
if (connected) { if (connected) {
this._state = 'sta'; this._state = 'sta';
log.info('provision', 'WiFi 自动连接成功AP 不启动'); log.info('provision', '已保存 WiFi 重连成功AP 不启动');
this._emitNetworkReady(); this._emitNetworkReady();
this._startMonitor(); this._startMonitor();
return; return;
} }
log.warn('provision', 'WiFi 自动连接超时,启动 AP'); log.warn('provision', '已保存 WiFi 重连失败');
} }
// 没有已保存 WiFi 或等待超时 → 开 AP // 无有线、无 WiFi且无 saved WiFi 或 saved WiFi 3 轮失败 → 开 AP 兜底配网。
this._enterAP(); this._enterAP();
this._startMonitor(); this._startMonitor();
if (hasInternet()) {
this._emitNetworkReady();
}
} }
_emitNetworkReady() { _emitNetworkReady() {
@@ -91,23 +92,17 @@ class ProvisionManager extends EventEmitter {
} }
} }
/** async _trySavedWifiReconnectRounds(rounds = WIFI_RECONNECT_MAX_ROUNDS) {
* 轮询等待 NM 自动连接 WiFi最多等 BOOT_WAIT_MAX_MS for (let i = 1; i <= rounds; i++) {
*/ if (isWifiStaConnected()) return true;
_waitForWifiConnect() { log.info('provision', `尝试已保存 WiFi 重连:第 ${i}/${rounds}`);
return new Promise(resolve => { const result = await connectSavedWifiConnections();
let elapsed = 0; if (result.success || isWifiStaConnected()) return true;
const timer = setInterval(() => { if (i < rounds) {
elapsed += BOOT_POLL_MS; await new Promise((r) => setTimeout(r, WIFI_RECONNECT_ROUND_DELAY_MS));
if (isWifiStaConnected()) {
clearInterval(timer);
resolve(true);
} else if (elapsed >= BOOT_WAIT_MAX_MS) {
clearInterval(timer);
resolve(false);
} }
}, BOOT_POLL_MS); }
}); return false;
} }
stop() { stop() {
@@ -126,10 +121,13 @@ class ProvisionManager extends EventEmitter {
if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP有线时等 WS 连接后再定 if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP有线时等 WS 连接后再定
try { try {
// 若上次进程退出前留下 clawd-hotspot必须先释放 wlan0否则会在 AP 模式下扫描,列表可能只剩 2.4G/自身热点。
stopAP();
// AP 模式下无法扫描 WiFi必须在开 AP 之前扫描并缓存 // AP 模式下无法扫描 WiFi必须在开 AP 之前扫描并缓存
log.info('provision', '扫描周边 WiFi...'); log.info('provision', '扫描周边 WiFi...');
this._cachedWifiList = scanWifi(); 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 // 写 DNS 劫持配置NM 启动热点时加载);接口名与热点一致,勿写死 wlan0
this._dns = new DnsHijack(); this._dns = new DnsHijack();
@@ -145,6 +143,8 @@ class ProvisionManager extends EventEmitter {
this._server.startListening(); this._server.startListening();
this._state = 'ap'; this._state = 'ap';
this._apStartedAt = Date.now();
this._lastApSavedWifiRetryAt = 0;
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`); log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
log.info('provision', `配网地址: http://10.42.0.1`); log.info('provision', `配网地址: http://10.42.0.1`);
} catch (e) { } catch (e) {
@@ -178,16 +178,27 @@ class ProvisionManager extends EventEmitter {
return result; return result;
} }
log.warn('provision', `WiFi 连接失败: ${result.error}重新启动 AP`); log.warn('provision', `WiFi 连接失败: ${result.error}按当前网络状态恢复`);
this._safeReenterAP(); this._recoverAfterWifiFailure();
return result; return result;
} catch (e) { } catch (e) {
log.error('provision', `配网过程异常: ${e.message}`); log.error('provision', `配网过程异常: ${e.message}`);
this._safeReenterAP(); this._recoverAfterWifiFailure();
return { success: false, error: e.message }; 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 */ /** 重新开 AP失败时勿把 _state 永久卡在 connecting */
_safeReenterAP() { _safeReenterAP() {
try { try {
@@ -202,34 +213,116 @@ class ProvisionManager extends EventEmitter {
_startMonitor() { _startMonitor() {
this._monitorTimer = setInterval(() => { 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; if (this._state === 'connecting') return;
const wifiUp = isWifiStaConnected(); const wifiUp = isWifiStaConnected();
if (this._state === 'sta' && !wifiUp) { if (wifiUp && this._state !== 'sta') {
log.warn('provision', 'WiFi 连接已断开,重新启动 AP'); if (this._state === 'ap') {
this._enterAP(); // 内部调用 led.off()
return;
}
if (this._state === 'ap' && wifiUp) {
log.info('provision', 'WiFi 已外部连接,关闭 AP'); log.info('provision', 'WiFi 已外部连接,关闭 AP');
this._stopAPServices(); this._stopAPServices();
}
this._state = 'sta'; this._state = 'sta';
this.emit('network-ready'); this.emit('network-ready');
} }
// 产品 WiFi 灯OpenVFD wifi+ethAP 全程强制熄灭,避免与其它逻辑竞态导致误亮 if (this._state === 'sta' && !wifiUp) {
if (this._state === 'ap') { log.warn('provision', 'WiFi 连接已断开,尝试恢复网络');
await this._recoverNetworkWithoutWifi();
return;
}
if (this._state === 'wired') {
if (!hasInternet()) {
log.warn('provision', '有线网络不可用,尝试恢复 WiFi');
await this._recoverNetworkWithoutWifi();
return;
}
led.off(); led.off();
} else if (this._state === 'sta') { return;
}
if (this._state === 'ap') {
if (hasInternet()) { if (hasInternet()) {
led.on(); log.info('provision', '检测到有线网络可用,关闭 AP');
} else { this._stopAPServices();
led.off(); // STA 已连热点但无互联网 this._state = 'wired';
this._emitNetworkReady();
return;
}
if (hasSavedWifiConnection() && this._shouldRetrySavedWifiFromAP()) {
await this._retrySavedWifiFromAP();
return;
}
led.off();
return;
} }
} }
}, MONITOR_INTERVAL_MS);
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() { _stopMonitor() {