From a85732aa80acdbae22742f4923dc29106f46a82e Mon Sep 17 00:00:00 2001 From: support Date: Sun, 24 May 2026 20:36:37 +0800 Subject: [PATCH] fix: stabilize rk3588 wifi provisioning --- lib/client.js | 9 +- lib/network.js | 298 +++++++++++++++++++++++++++++++++++++------- lib/provisioning.js | 6 +- package-lock.json | 4 +- package.json | 2 +- 5 files changed, 267 insertions(+), 52 deletions(-) diff --git a/lib/client.js b/lib/client.js index e34bed7..9dc238b 100644 --- a/lib/client.js +++ b/lib/client.js @@ -263,11 +263,12 @@ class ClawClient { _connect() { if (this._stopped) return; - // AP 模式 + 无网:不建立 WS,5s 后重新检查(有线经 -I ping 仍通则建立,避免热点误挡 WS) - if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet() && !hasWiredInternetProbe()) { + // AP 模式下 hasInternet() 可能被热点本地网络 / NetworkManager limited 状态误判。 + // 只有明确探测到有线口可访问公网时,才允许进入 WS 连接流程并显示 Conn。 + if (this._provisionMgr && this._provisionMgr.isApMode() && !hasWiredInternetProbe()) { led.display.showAP(); - log.info('clawd', 'AP 模式无网络,5s 后重新检查...'); - this._backoff = 1_000; // 有网时立即快速重连 + log.info('clawd', 'AP 模式无有线网络,5s 后重新检查...'); + this._backoff = 1_000; // 有线恢复时立即快速重连 this._wsFailCount = 0; // 不计入失败 setTimeout(() => this._connect(), 5_000); return; diff --git a/lib/network.js b/lib/network.js index 1b3eb34..97d3104 100644 --- a/lib/network.js +++ b/lib/network.js @@ -10,6 +10,7 @@ const AP_IP = '10.42.0.1'; const AP_PASSWORD = '12345678'; const AP_IFACE = process.env.CLAWD_WIFI_IFACE || ''; const CON_NAME = 'clawd-hotspot'; +const AP_RETRY_TOKEN_FILE = '/run/clawd-ap-retry.token'; /** 产品 RJ45 在 sysfs 中的默认名;等价于检测 `cat /sys/class/net/end0/carrier` */ const DEFAULT_ETH_IFACE = 'end0'; @@ -92,28 +93,15 @@ function hasLanCableCarrier() { return hasWiredCarrier(); } -function _tryPingInternet() { +function _tryPingDefaultInternet() { try { run('ping -c 1 -W 3 8.8.8.8'); return true; } catch (_) {} - - // 开热点时默认路由可能走 wlan,无 -I 的 ping 会误判;指定有线口再试 - const wired = getWiredIfaceWithCarrier(); - if (wired) { - try { - run(`ping -c 1 -W 3 -I ${wired} 8.8.8.8`); - return true; - } catch (_) {} - } return false; } -/** - * 仅经有线口 ping 公网(不依赖默认路由)。 - * AP 开启时 hasInternet() 易误判;维持 WS / 网络监视时用此兜底。 - */ -function hasWiredInternetProbe() { +function _tryPingWiredInternet() { const wired = getWiredIfaceWithCarrier(); if (!wired) return false; try { @@ -124,18 +112,31 @@ function hasWiredInternetProbe() { } /** - * 检测是否有互联网连接(nmcli 连通性 + ping 兜底) + * 仅经有线口 ping 公网(不依赖默认路由)。 + */ +function hasWiredInternetProbe() { + return _tryPingWiredInternet(); +} + +/** + * 检测是否有真实互联网连接。 + * 注意:NetworkManager 的 limited/local 可能只是 AP 本地网络或 captive 状态,不能当公网可用。 */ function hasInternet() { + const wifiSta = isWifiStaConnected(); + const wired = getWiredIfaceWithCarrier(); + // 物理层快检:无 WiFi STA 且无任何有线 carrier → 立即 false(nmcli 有缓存,不可信) - if (!isWifiStaConnected() && !hasWiredCarrier()) return false; + if (!wifiSta && !wired) return false; try { const out = run('nmcli networking connectivity check').trim(); - if (out === 'full' || out === 'limited') return true; + if (out === 'full') return true; } catch (_) {} - return _tryPingInternet(); + if (wifiSta) return _tryPingDefaultInternet(); + if (wired) return _tryPingWiredInternet(); + return false; } /** @@ -265,6 +266,7 @@ function nmcliAsync(args, timeoutMs = 60000) { * @returns {Promise<{ success: boolean, error?: string }>} */ async function connectWifi(ssid, password) { + cancelHotspotRadioRetry(`准备连接 WiFi: ${ssid}`); const iface = getWifiIface(); log.info('network', `尝试连接 WiFi: ${ssid}(ifname=${iface})`); try { @@ -272,14 +274,36 @@ async function connectWifi(ssid, password) { await nmcliAsync(['connection', 'delete', ssid], 15000); } catch (_) {} - try { - await nmcliAsync(['device', 'set', iface, 'managed', 'yes'], 8000); - } catch (_) {} + await _resetWifiRadioForSTA(iface); - const args = ['device', 'wifi', 'connect', ssid]; - if (password) args.push('password', password); - args.push('ifname', iface); - await nmcliAsync(args, 120000); + if (password) { + // 显式创建 STA profile,并固定为 WPA2-PSK only。 + // RK3588/Broadcom DHD 对 NetworkManager 默认生成的 SAE/FT/WPA-PSK-SHA256 混合参数不稳定, + // 可能表现为一直 associating -> disconnected,最后误报“需要密钥”。 + await nmcliAsync([ + 'connection', 'add', + 'type', 'wifi', + 'ifname', iface, + 'con-name', ssid, + 'ssid', ssid, + ], 15000); + + await nmcliAsync([ + 'connection', 'modify', ssid, + // 连接成功前先禁止自动连接,避免失败恢复 AP 时 NM 又自动抢占 wlan0。 + 'connection.autoconnect', 'no', + '802-11-wireless-security.key-mgmt', 'wpa-psk', + '802-11-wireless-security.proto', 'rsn', + '802-11-wireless-security.pairwise', 'ccmp', + '802-11-wireless-security.group', 'ccmp', + '802-11-wireless-security.pmf', 'disable', + '802-11-wireless-security.psk', password, + ], 15000); + + await nmcliAsync(['connection', 'up', 'id', ssid, 'ifname', iface], 120000); + } else { + await nmcliAsync(['device', 'wifi', 'connect', ssid, 'ifname', iface], 120000); + } await _ensureActiveWifiAutoconnect(); const deadline = Date.now() + CONNECT_WIFI_STA_WAIT_MS; @@ -299,11 +323,201 @@ async function connectWifi(ssid, password) { } return { success: false, error: '超时:网卡未进入已连接状态' }; } catch (e) { + try { await nmcliAsync(['connection', 'modify', ssid, 'connection.autoconnect', 'no'], 8000); } catch (_) {} log.error('network', `WiFi 连接失败: ${e.message}`); return { success: false, error: e.message }; } } +function _newHotspotRetryToken() { + const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`; + try { + fs.writeFileSync(AP_RETRY_TOKEN_FILE, token, { mode: 0o600 }); + } catch (e) { + log.warn('network', `写入 AP retry token 失败: ${e.message}`); + } + return token; +} + +function cancelHotspotRadioRetry(reason = 'cancel') { + try { + fs.unlinkSync(AP_RETRY_TOKEN_FILE); + log.info('network', `已取消后台 AP retry: ${reason}`); + } catch (_) {} +} + +async function _resetWifiRadioForSTA(iface, reason = '准备连接 STA 前重置 WiFi radio') { + log.warn('network', `${reason}: ${iface}`); + + try { await nmcliAsync(['connection', 'down', CON_NAME], 8000); } catch (_) {} + try { await nmcliAsync(['connection', 'delete', CON_NAME], 8000); } catch (_) {} + try { await nmcliAsync(['device', 'disconnect', iface], 8000); } catch (_) {} + + try { + await nmcliAsync(['radio', 'wifi', 'off'], 10000); + } catch (e) { + log.warn('network', `关闭 WiFi radio 失败: ${e.message}`); + } + + await _delay(2500); + + try { + await nmcliAsync(['radio', 'wifi', 'on'], 10000); + } catch (e) { + log.warn('network', `开启 WiFi radio 失败: ${e.message}`); + } + + await _delay(5000); + try { await nmcliAsync(['device', 'set', iface, 'managed', 'yes'], 8000); } catch (_) {} + try { await nmcliAsync(['device', 'wifi', 'rescan', 'ifname', iface], 15000); } catch (_) {} + await _delay(1500); +} + +function _resetWifiRadioForAP(iface, reason = '准备 AP 前重置 WiFi radio') { + log.warn('network', `${reason}: ${iface}`); + + try { nmcliSync(['connection', 'down', CON_NAME], 8000); } catch (_) {} + try { nmcliSync(['connection', 'delete', CON_NAME], 8000); } catch (_) {} + try { nmcliSync(['device', 'disconnect', iface], 8000); } catch (_) {} + + try { + nmcliSync(['radio', 'wifi', 'off'], 10000); + } catch (e) { + log.warn('network', `关闭 WiFi radio 失败: ${e.message}`); + } + + sleep(2500); + + try { + nmcliSync(['radio', 'wifi', 'on'], 10000); + } catch (e) { + log.warn('network', `开启 WiFi radio 失败: ${e.message}`); + } + + sleep(5000); + try { nmcliSync(['device', 'set', iface, 'managed', 'yes'], 8000); } catch (_) {} +} + +function _spawnHotspotRadioRetry(ssid, iface) { + const token = _newHotspotRetryToken(); + const script = ` +set -u +log() { logger -t clawd-ap-retry "$*"; } +check_token() { + if [ ! -f "$TOKEN_FILE" ] || [ "$(cat "$TOKEN_FILE" 2>/dev/null || true)" != "$TOKEN" ]; then + log "AP retry canceled" + exit 0 + fi +} +log "AP retry started: ssid=$SSID iface=$IFACE" +check_token +nmcli connection down "$CON_NAME" >/dev/null 2>&1 || true +nmcli connection delete "$CON_NAME" >/dev/null 2>&1 || true +nmcli device disconnect "$IFACE" >/dev/null 2>&1 || true +check_token +nmcli radio wifi off >/dev/null 2>&1 || true +sleep 2.5 +# If canceled while radio is off, always turn it back on before exiting. +nmcli radio wifi on >/dev/null 2>&1 || true +sleep 5 +check_token +nmcli device set "$IFACE" managed yes >/dev/null 2>&1 || true +check_token +if ! nmcli connection add type wifi ifname "$IFACE" con-name "$CON_NAME" ssid "$SSID" >/dev/null 2>&1; then + log "AP retry failed: connection add failed" + exit 1 +fi +args=( + connection modify "$CON_NAME" + connection.autoconnect no + 802-11-wireless.mode ap + 802-11-wireless.band bg + 802-11-wireless.channel 1 + 802-11-wireless-security.key-mgmt wpa-psk + 802-11-wireless-security.proto rsn + 802-11-wireless-security.pairwise ccmp + 802-11-wireless-security.group ccmp + 802-11-wireless-security.pmf disable + ipv4.method shared + ipv4.addresses "$AP_IP/24" + ipv6.method ignore +) +if [ -n "\${AP_PASSWORD:-}" ]; then + args+=(802-11-wireless-security.psk "$AP_PASSWORD") +fi +check_token +if ! nmcli "\${args[@]}" >/dev/null 2>&1; then + log "AP retry failed: connection modify failed" + exit 1 +fi +check_token +if nmcli connection up "$CON_NAME" >/dev/null 2>&1; then + if [ ! -f "$TOKEN_FILE" ] || [ "$(cat "$TOKEN_FILE" 2>/dev/null || true)" != "$TOKEN" ]; then + log "AP retry canceled after connection up; tearing hotspot down" + nmcli connection down "$CON_NAME" >/dev/null 2>&1 || true + nmcli connection delete "$CON_NAME" >/dev/null 2>&1 || true + exit 0 + fi + log "AP retry success: $SSID" + rm -f "$TOKEN_FILE" +else + log "AP retry failed: connection up failed" + exit 1 +fi +`; + + const child = spawn('/bin/bash', ['-lc', script], { + detached: true, + stdio: 'ignore', + env: { + ...process.env, + SSID: ssid, + IFACE: iface, + CON_NAME, + AP_IP, + AP_PASSWORD: AP_PASSWORD || '', + TOKEN_FILE: AP_RETRY_TOKEN_FILE, + TOKEN: token, + }, + }); + child.unref(); +} + +function _createHotspotProfile(ssid, iface) { + nmcliSync([ + 'connection', 'add', + 'type', 'wifi', + 'ifname', iface, + 'con-name', CON_NAME, + 'ssid', ssid, + ], 15000); + + const modifyArgs = [ + 'connection', 'modify', CON_NAME, + 'connection.autoconnect', 'no', + '802-11-wireless.mode', 'ap', + '802-11-wireless.band', 'bg', + '802-11-wireless.channel', '1', + '802-11-wireless-security.key-mgmt', 'wpa-psk', + '802-11-wireless-security.proto', 'rsn', + '802-11-wireless-security.pairwise', 'ccmp', + '802-11-wireless-security.group', 'ccmp', + '802-11-wireless-security.pmf', 'disable', + 'ipv4.method', 'shared', + 'ipv4.addresses', `${AP_IP}/24`, + 'ipv6.method', 'ignore', + ]; + if (AP_PASSWORD) { + modifyArgs.push('802-11-wireless-security.psk', AP_PASSWORD); + } + nmcliSync(modifyArgs, 15000); +} + +function _activateHotspot(ssid, iface, timeoutMs = 8000) { + _createHotspotProfile(ssid, iface); + nmcliSync(['connection', 'up', CON_NAME], timeoutMs); +} + /** * 启动 WiFi AP 热点 */ @@ -313,29 +527,24 @@ function startAP(clawId) { log.info('network', `启动 AP 热点: ${ssid} (${iface})`); - // 关闭已有热点 + // 关闭已有热点,并在重新拉起 AP 前真正 power-cycle WiFi 芯片。 + // RK3588/Broadcom DHD 在 LAN 断开后切 AP 时,单纯 ip link down/up 不一定清掉固件残留状态。 stopAP(); + _resetWifiRadioForAP(iface, '准备 AP 前重置 WiFi radio'); try { - // nmcli 创建热点(开放网络) - const cmd = [ - 'nmcli device wifi hotspot', - `ifname ${iface}`, - `con-name ${CON_NAME}`, - `ssid "${ssid}"`, - 'band bg', - ]; - // 如果需要密码 - if (AP_PASSWORD) { - cmd.push(`password "${AP_PASSWORD}"`); - } - run(cmd.join(' ')); + // 显式创建并激活热点,固定为 WPA2-PSK only。 + // 避免 NetworkManager 自动生成 WPA-PSK-SHA256/SAE 混合配置,部分 3588 Broadcom DHD 驱动重启 AP 时会拒绝该 RSN 参数。 try { - nmcliSync(['connection', 'modify', CON_NAME, 'connection.autoconnect', 'no'], 8000); - } catch (_) {} + _activateHotspot(ssid, iface, 8000); + } catch (firstError) { + log.warn('network', `AP 启动未在短超时内完成,后台再次重置 WiFi radio 后重试;避免阻塞 watchdog: ${firstError.message}`); + _spawnHotspotRadioRetry(ssid, iface); + return { ssid, ip: AP_IP, iface, pending: true }; + } // 等待 AP 启动 - sleep(2000); + sleep(1000); log.info('network', `AP 已启动: ${ssid}, 网关 ${AP_IP}`); return { ssid, ip: AP_IP, iface }; } catch (e) { @@ -348,6 +557,7 @@ function startAP(clawId) { * 关闭热点,恢复普通 WiFi 模式 */ function stopAP() { + cancelHotspotRadioRetry('停止 AP'); try { run(`nmcli connection down ${CON_NAME}`); } catch (_) {} @@ -454,6 +664,7 @@ async function _ensureActiveWifiAutoconnect() { * clawd 只做调度;真正的认证、DHCP、重连细节仍交给 NM。 */ async function connectSavedWifiConnections() { + cancelHotspotRadioRetry('准备连接已保存 WiFi'); const iface = getWifiIface(); const profiles = listSavedWifiConnections(); if (profiles.length === 0) { @@ -574,6 +785,7 @@ module.exports = { connectWifi, startAP, stopAP, + cancelHotspotRadioRetry, AP_IP, getLocalIps, getLocalNetworks, diff --git a/lib/provisioning.js b/lib/provisioning.js index 614c0c6..c67be04 100644 --- a/lib/provisioning.js +++ b/lib/provisioning.js @@ -2,7 +2,7 @@ const EventEmitter = require('events'); const log = require('./logger'); -const { hasInternet, hasSavedWifiConnection, connectSavedWifiConnections, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, getWifiIface, AP_IP } = require('./network'); +const { hasInternet, hasWiredInternetProbe, 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'); @@ -258,7 +258,9 @@ class ProvisionManager extends EventEmitter { } if (this._state === 'ap') { - if (hasInternet()) { + // AP 模式下 hasInternet() 可能被热点本地网络 / NetworkManager limited 状态误判。 + // 只有明确探测到有线口可访问公网时,才关闭配网 AP。 + if (hasWiredInternetProbe()) { log.info('provision', '检测到有线网络可用,关闭 AP'); this._stopAPServices(); this._state = 'wired'; diff --git a/package-lock.json b/package-lock.json index 5a7e390..e71335b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawd", - "version": "1.4.5", + "version": "1.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawd", - "version": "1.4.5", + "version": "1.4.6", "license": "MIT", "dependencies": { "ssh2": "^1.17.0", diff --git a/package.json b/package.json index fcffe15..5cfd143 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawd", - "version": "1.4.5", + "version": "1.4.6", "description": "Claw Box daemon - connects local Linux box to claw.cutos.ai via WebSocket", "main": "lib/client.js", "bin": {