Files
clawd/lib/network.js
stswangzhiping b42e59fab8 feat: AP always-on mode - hotspot stays until WiFi STA connects
Redesign provisioning from one-shot blocking to persistent background manager:
- AP hotspot starts at boot regardless of eth0 status
- Captive portal runs alongside AP for WiFi configuration
- AP automatically shuts down only when WiFi STA connects
- WiFi drops at runtime -> AP auto-restarts
- WiFi connect fails -> AP auto-restarts for retry
- client.js no longer blocks on network; connects WS when ready

Made-with: Cursor
2026-03-16 12:18:35 +08:00

203 lines
5.0 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 { execSync } = require('child_process');
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';
/**
* 检测是否有互联网连接(尝试 DNS 解析 + HTTP 连通性)
*/
function hasInternet() {
// 优先用 nmcli 的 connectivity check
try {
const out = run('nmcli networking connectivity check').trim();
if (out === 'full' || out === 'limited') return true;
} catch (_) {}
// 兜底ping DNS
try {
run('ping -c 1 -W 3 8.8.8.8');
return true;
} catch (_) {}
return false;
}
/**
* 获取默认 WiFi 接口名wlan0 等)
*/
function getWifiIface() {
if (AP_IFACE) return AP_IFACE;
try {
const out = run('nmcli -t -f DEVICE,TYPE device | grep wifi | head -1');
const iface = out.split(':')[0].trim();
if (iface) return iface;
} 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);
const out = run('nmcli -t -f SSID,SIGNAL,SECURITY device wifi list');
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, ':');
if (!ssid || seen.has(ssid)) continue;
seen.add(ssid);
results.push({
ssid,
signal: parseInt(parts[1], 10) || 0,
security: parts.slice(2).join(':').trim() || 'Open',
});
}
results.sort((a, b) => b.signal - a.signal);
return results;
} catch (e) {
log.error('network', 'WiFi 扫描失败:', e.message);
return [];
}
}
/**
* 连接指定 WiFi
* @returns {{ success: boolean, error?: string }}
*/
function connectWifi(ssid, password) {
const iface = getWifiIface();
log.info('network', `尝试连接 WiFi: ${ssid}`);
try {
// 先删除可能残留的同名连接
try { run(`nmcli connection delete "${ssid}"`); } catch (_) {}
const pwdArg = password ? `password "${password}"` : '';
run(`nmcli device wifi connect "${ssid}" ${pwdArg} ifname ${iface}`, 30000);
// 验证连通性
sleep(3000);
if (hasInternet()) {
log.info('network', `WiFi 已连接: ${ssid}`);
return { success: true };
}
return { success: false, error: '已连接但无法访问互联网' };
} catch (e) {
log.error('network', `WiFi 连接失败: ${e.message}`);
return { success: false, error: e.message };
}
}
/**
* 启动 WiFi AP 热点
*/
function startAP(clawId) {
const iface = getWifiIface();
const ssid = `${AP_SSID_PREFIX}${clawId || 'Setup'}`;
log.info('network', `启动 AP 热点: ${ssid} (${iface})`);
// 关闭已有热点
stopAP();
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(' '));
// 等待 AP 启动
sleep(2000);
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() {
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 });
}
/**
* 检测 wlan0 是否以 STA 模式连接了 WiFi排除自身热点
*/
function isWifiStaConnected() {
const iface = getWifiIface();
try {
const out = run('nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device');
for (const line of out.split('\n')) {
const parts = line.split(':');
if (parts[0] === iface && parts[1] === 'wifi' && parts[2] === 'connected') {
return parts[3] !== CON_NAME;
}
}
} catch (_) {}
return false;
}
module.exports = {
hasInternet,
isWifiStaConnected,
getWifiIface,
scanWifi,
connectWifi,
startAP,
stopAP,
AP_IP,
};