'use strict'; const { execSync, spawnSync, spawn } = require('child_process'); const fs = require('fs'); const os = require('os'); const log = require('./logger'); const AP_SSID_PREFIX = 'ClawBox-'; 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'; function _ethIfaceEnvOrDefault() { return process.env.CLAWD_ETH_IFACE || DEFAULT_ETH_IFACE; } function _netIfaceExists(name) { try { return fs.existsSync(`/sys/class/net/${name}`); } catch (_) { return false; } } /** 读取 `/sys/class/net//carrier`,`1` 为链路 up;缺失或异常视为 down */ function _sysfsCarrierUp(iface) { try { return fs.readFileSync(`/sys/class/net/${iface}/carrier`, 'utf8').trim() === '1'; } catch (_) { return false; } } /** 非 WiFi、非典型虚拟接口,用于开发机扫描有线口(enp* 等) */ function _isExcludedVirtualIface(name) { if (name === 'lo' || name === 'bonding_masters') return true; if (name.startsWith('wl')) return true; if (name.startsWith('docker')) return true; if (name.startsWith('veth')) return true; if (name.startsWith('virbr')) return true; if (name.startsWith('br-')) return true; if (name.startsWith('tun') || name.startsWith('tap')) return true; if (name.startsWith('wg') || name.startsWith('bond')) return true; if (name.startsWith('can')) return true; return false; } /** * 开发机:无 CLAWD_ETH_IFACE 且无 end0 时,扫描 sysfs 找第一个 carrier=1 的有线口。 */ function _firstScanWiredIfaceWithCarrier() { try { const names = fs.readdirSync('/sys/class/net'); for (const name of names.sort()) { if (_isExcludedVirtualIface(name)) continue; if (_sysfsCarrierUp(name)) return name; } } catch (_) {} return null; } /** * 返回当前可用于「有线 ping / 路由」的网卡名。 * 优先级:CLAWD_ETH_IFACE → 存在 end0 则只用 end0 → 否则扫描 sysfs。 */ function getWiredIfaceWithCarrier() { const explicit = process.env.CLAWD_ETH_IFACE; if (explicit) { return _netIfaceExists(explicit) && _sysfsCarrierUp(explicit) ? explicit : null; } if (_netIfaceExists(DEFAULT_ETH_IFACE)) { return _sysfsCarrierUp(DEFAULT_ETH_IFACE) ? DEFAULT_ETH_IFACE : null; } return _firstScanWiredIfaceWithCarrier(); } function hasWiredCarrier() { return getWiredIfaceWithCarrier() !== null; } /** * LAN 面板灯:只反映 RJ45 对应口,与 `cat /sys/class/net/end0/carrier 2>/dev/null` 同源(仅读 carrier)。 * 若配置的接口在 sysfs 中不存在(常见为开发机无 end0),则退回与 hasWiredCarrier() 一致,避免灯永远灭。 */ function hasLanCableCarrier() { const iface = _ethIfaceEnvOrDefault(); if (_netIfaceExists(iface)) return _sysfsCarrierUp(iface); return hasWiredCarrier(); } function _tryPingDefaultInternet() { try { run('ping -c 1 -W 3 8.8.8.8'); return true; } catch (_) {} return false; } function _tryPingWiredInternet() { const wired = getWiredIfaceWithCarrier(); if (!wired) return false; try { run(`ping -c 1 -W 3 -I ${wired} 8.8.8.8`); return true; } catch (_) {} return false; } /** * 仅经有线口 ping 公网(不依赖默认路由)。 */ function hasWiredInternetProbe() { return _tryPingWiredInternet(); } /** * 检测是否有真实互联网连接。 * 注意:NetworkManager 的 limited/local 可能只是 AP 本地网络或 captive 状态,不能当公网可用。 */ function hasInternet() { const wifiSta = isWifiStaConnected(); const wired = getWiredIfaceWithCarrier(); // 物理层快检:无 WiFi STA 且无任何有线 carrier → 立即 false(nmcli 有缓存,不可信) if (!wifiSta && !wired) return false; try { const out = run('nmcli networking connectivity check').trim(); if (out === 'full') return true; } catch (_) {} if (wifiSta) return _tryPingDefaultInternet(); if (wired) return _tryPingWiredInternet(); return false; } /** * 获取默认 WiFi 接口名(wlan0 等)。 * 必须 TYPE 精确为 wifi,不能用 grep wifi(会误匹配 wifi-p2p,导致选到 p2p-dev-wlan0,STA/热点均失败)。 */ function getWifiIface() { if (AP_IFACE) return AP_IFACE; try { const out = run('nmcli -t -f DEVICE,TYPE device'); let fallback = ''; for (const line of out.split('\n')) { if (!line.trim()) continue; const parts = line.split(':'); const dev = (parts[0] || '').trim(); const type = (parts[1] || '').trim(); if (type !== 'wifi' || !dev) continue; if (dev.startsWith('p2p-dev-')) continue; if (dev.startsWith('wlan')) return dev; if (!fallback) fallback = dev; } if (fallback) return fallback; } catch (_) {} try { const out = run("ls /sys/class/net | grep -E '^wl'"); const iface = out.split('\n')[0].trim(); if (iface) return iface; } catch (_) {} return 'wlan0'; } /** * 扫描周围 WiFi,返回 [{ ssid, signal, security }] */ function scanWifi() { const iface = getWifiIface(); try { // 先触发一次扫描 try { run(`nmcli device wifi rescan ifname ${iface}`); } catch (_) {} // 等扫描完成 sleep(2000); // 指定 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 = _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[2] || '').trim() || 'Open', freq, band: freqMhz ? (freqMhz >= 4900 ? '5G' : '2.4G') : null, }); } results.sort((a, b) => b.signal - a.signal); return results; } catch (e) { log.error('network', 'WiFi 扫描失败:', e.message); return []; } } /** AP 切 STA 后等待网卡进入 connected 的最长时间(不依赖外网探测) */ const CONNECT_WIFI_STA_WAIT_MS = 25_000; const CONNECT_WIFI_STA_POLL_MS = 1_000; /** 不走 shell,避免 SSID/密码中的引号、空格、$ 等破坏命令 */ function nmcliSync(args, timeoutMs = 60000) { const r = spawnSync('nmcli', args, { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 2 * 1024 * 1024, }); if (r.error) throw r.error; if (r.status !== 0) { const msg = (r.stderr || '').trim() || (r.stdout || '').trim() || `nmcli exit ${r.status}`; throw new Error(msg); } return (r.stdout || '').trim(); } function _delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** 异步 nmcli,不阻塞事件循环(systemd Watchdog 依赖 setInterval 在主线程运行) */ function nmcliAsync(args, timeoutMs = 60000) { return new Promise((resolve, reject) => { const child = spawn('nmcli', args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('nmcli 超时')); }, timeoutMs); child.stdout.on('data', (d) => { stdout += d; }); child.stderr.on('data', (d) => { stderr += d; }); child.on('error', (err) => { clearTimeout(timer); reject(err); }); child.on('close', (code) => { clearTimeout(timer); if (code !== 0) { const msg = stderr.trim() || stdout.trim() || `nmcli exit ${code}`; reject(new Error(msg)); } else { resolve(stdout.trim()); } }); }); } /** * 连接指定 WiFi(配网场景:成功 = NM 显示 STA 已连上目标网,不要求一定能 ping 通 8.8.8.8) * 必须异步:同步 spawnSync + execSync(sleep) 会卡住主线程,导致 systemd WatchdogSec 内收不到 WATCHDOG=1。 * @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 { try { await nmcliAsync(['connection', 'delete', ssid], 15000); } catch (_) {} await _resetWifiRadioForSTA(iface); 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; while (Date.now() < deadline) { if (isWifiStaConnected()) { if (hasInternet()) { log.info('network', `WiFi 已连接且有外网: ${ssid}`); } else { log.warn( 'network', `WiFi STA 已连接(${ssid}),暂未检测到外网;配网仍视为成功(内网/防火墙/国内 DNS 常见)`, ); } return { success: true }; } await _delay(CONNECT_WIFI_STA_POLL_MS); } 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 热点 */ function startAP(clawId) { const iface = getWifiIface(); const ssid = `${AP_SSID_PREFIX}${clawId || 'Setup'}`; 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 { // 显式创建并激活热点,固定为 WPA2-PSK only。 // 避免 NetworkManager 自动生成 WPA-PSK-SHA256/SAE 混合配置,部分 3588 Broadcom DHD 驱动重启 AP 时会拒绝该 RSN 参数。 try { _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(1000); log.info('network', `AP 已启动: ${ssid}, 网关 ${AP_IP}`); return { ssid, ip: AP_IP, iface }; } catch (e) { log.error('network', `AP 启动失败: ${e.message}`); throw e; } } /** * 关闭热点,恢复普通 WiFi 模式 */ function stopAP() { cancelHotspotRadioRetry('停止 AP'); try { run(`nmcli connection down ${CON_NAME}`); } catch (_) {} try { run(`nmcli connection delete ${CON_NAME}`); } catch (_) {} } // ── 工具 ───────────────────────────────────────────────────────────────────── function run(cmd, timeout = 10000) { return execSync(cmd, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'], }); } 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 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() { cancelHotspotRadioRetry('准备连接已保存 WiFi'); 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 (_) {} 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 均连接失败' }; } /** * 是否已以 STA 连上某 WiFi(排除自身热点)。 * 不用 device 列表按 `:` 拆字段(连接名含冒号会错;state 含 connecting 勿误匹配 connected)。 */ function isWifiStaConnected() { const iface = getWifiIface(); let state; let conn; try { state = nmcliSync(['-g', 'GENERAL.STATE', 'device', 'show', iface], 8000); conn = nmcliSync(['-g', 'GENERAL.CONNECTION', 'device', 'show', iface], 8000); } catch (_) { return false; } const s = (state || '').trim(); const c = (conn || '').trim(); if (!/\(connected\)/.test(s)) return false; if (!c || c === CON_NAME) return false; return true; } function _ifaceNetworkType(name) { const wifi = getWifiIface(); if (name === wifi || name.startsWith('wl')) return 'wifi'; if (name === DEFAULT_ETH_IFACE || name.startsWith('en') || name.startsWith('eth')) return 'lan'; return null; } function _localNetworkEntries() { const ifaces = os.networkInterfaces(); const entries = []; for (const [name, addrs] of Object.entries(ifaces)) { if (!addrs) continue; const type = _ifaceNetworkType(name); if (!type) continue; for (const addr of addrs) { if (addr.family !== 'IPv4' || addr.internal) continue; // clawd-hotspot 的 AP 管理网段只用于配网,不上报为 BOX 可访问地址。 if (addr.address.startsWith('10.42.')) continue; entries.push({ ip: addr.address, type, iface: name }); } } return entries; } /** * 获取本机所有非回环 IPv4 地址,逗号拼接返回。 * 保持旧协议字段 local_ip 兼容:'192.168.1.100' 或 '192.168.1.100,10.0.0.5'。 */ function getLocalIps() { try { const ips = _localNetworkEntries().map((entry) => entry.ip); return ips.length > 0 ? ips.join(',') : null; } catch (e) { log.warn('network', '获取本机 IP 失败:', e.message); return null; } } /** * 获取本机 IPv4 地址及网络类型,用于上报服务器。 * 例:[{ ip: '192.168.1.100', type: 'wifi', iface: 'wlan0' }] */ function getLocalNetworks() { try { const entries = _localNetworkEntries(); return entries.length > 0 ? entries : null; } catch (e) { log.warn('network', '获取本机网络类型失败:', e.message); return null; } } module.exports = { hasInternet, hasWiredCarrier, hasLanCableCarrier, hasWiredInternetProbe, getWiredIfaceWithCarrier, listSavedWifiConnections, hasSavedWifiConnection, connectSavedWifiConnections, isWifiStaConnected, getWifiIface, scanWifi, connectWifi, startAP, stopAP, cancelHotspotRadioRetry, AP_IP, getLocalIps, getLocalNetworks, };