feat: add WiFi provisioning for headless devices (AP + Captive Portal)

- Add lib/network.js: WiFi scan, connect, AP hotspot via nmcli
- Add lib/dns-hijack.js: dnsmasq management for DNS hijack + DHCP
- Add lib/captive-server.js: embedded HTTP captive portal with WiFi setup page
- Add lib/provisioning.js: orchestrator (detect network -> AP mode -> wait -> exit)
- Update client.js: call ensureNetwork() before WS connection
- Update install.sh: auto-install dnsmasq dependency

Made-with: Cursor
This commit is contained in:
stswangzhiping
2026-03-16 08:58:51 +08:00
parent b3770d21d4
commit eb9f4ab1c3
7 changed files with 712 additions and 2 deletions

View File

@@ -99,6 +99,18 @@ systemctl disable clawd # 取消开机自启
| `load_1m` / `load_5m` / `load_15m` | 系统负载 | — |
| `uptime` | 运行时间 | 秒 |
## WiFi 配网(无屏设备)
首次开机无网络时clawd 自动进入 AP 配网模式:
1. 设备开启热点 `ClawBox-{ID}`(无密码)
2. 用户手机连接该热点
3. 自动弹出配网页面(或访问 `http://ap.cutos.ai`
4. 选择家庭 WiFi 并输入密码
5. 设备连接成功后自动接入云端
需要 `dnsmasq`(安装脚本会自动安装)和 `NetworkManager`
## 架构
```
@@ -111,8 +123,12 @@ clawd/
│ ├── frpc.js ← frpc/ttyd/dashboard 管理Watchdog 守护)
│ ├── logger.js ← 结构化日志 + 文件轮转
│ ├── metrics.js ← 系统指标采集
── watchdog.js ← 通用子进程守护(速率限制重启)
├── install.sh ← 一键安装(含 systemd
── watchdog.js ← 通用子进程守护(速率限制重启)
│ ├── network.js ← 网络检测、WiFi 扫描/连接、AP 模式
│ ├── dns-hijack.js ← dnsmasq 管理DNS 劫持 + DHCP
│ ├── captive-server.js ← 配网 HTTP 页面Captive Portal
│ └── provisioning.js ← 配网编排检测→AP→配网→退出
├── install.sh ← 一键安装(含 systemd + dnsmasq
└── package.json
```

View File

@@ -28,6 +28,26 @@ if [ "$MAJOR" -lt 18 ]; then
fi
info "Node.js $NODE_VER"
# ── 检查/安装 dnsmasqWiFi 配网需要)──────────────────────────────────────
if ! command -v dnsmasq &>/dev/null; then
info "安装 dnsmasqWiFi 配网所需)..."
if command -v apt-get &>/dev/null; then
apt-get install -y -qq dnsmasq >/dev/null 2>&1
elif command -v yum &>/dev/null; then
yum install -y -q dnsmasq >/dev/null 2>&1
elif command -v apk &>/dev/null; then
apk add --quiet dnsmasq >/dev/null 2>&1
else
warn "无法自动安装 dnsmasqWiFi 配网功能可能不可用"
fi
# 禁止 dnsmasq 系统服务自启clawd 自己管理)
systemctl disable dnsmasq 2>/dev/null || true
systemctl stop dnsmasq 2>/dev/null || true
fi
if command -v dnsmasq &>/dev/null; then
info "dnsmasq ✓"
fi
# ── 安装 clawd ───────────────────────────────────────────────────────────────
INSTALL_DIR="/opt/clawd"
CONFIG_DIR="/etc/clawd"

293
lib/captive-server.js Normal file
View File

@@ -0,0 +1,293 @@
'use strict';
const http = require('http');
const log = require('./logger');
const { scanWifi, connectWifi } = require('./network');
const { CAPTIVE_DOMAIN } = require('./dns-hijack');
const PORT = 80;
// iOS / Android / Windows Captive Portal 检测路径
const CAPTIVE_DETECT_PATHS = new Set([
'/hotspot-detect.html', // iOS
'/library/test/success.html', // iOS older
'/generate_204', // Android
'/gen_204', // Android alt
'/connecttest.txt', // Windows
'/ncsi.txt', // Windows alt
'/redirect', // Windows 11
'/canonical.html', // Firefox
]);
/**
* 配网 HTTP 服务器。
*
* 路由:
* GET / → 配网页面HTML
* GET /api/scan → WiFi 扫描结果 JSON
* POST /api/connect → 提交 WiFi 凭证,尝试连接
* GET /api/status → 当前连接状态
* Captive Portal 检测 → 302 重定向到配网页
*/
class CaptiveServer {
constructor(opts = {}) {
this._server = null;
this._clawId = opts.clawId || '???';
this._resolve = null; // provisioning 等待配网完成的 resolve
}
/**
* 启动 HTTP 服务器,返回 Promise配网成功后 resolve。
*/
start() {
return new Promise((resolve) => {
this._resolve = resolve;
this._server = http.createServer((req, res) => {
this._handle(req, res).catch(e => {
log.error('http', `${req.method} ${req.url} 异常:`, e.message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: '内部错误' }));
});
});
this._server.listen(PORT, '0.0.0.0', () => {
log.info('http', `配网页面就绪: http://${CAPTIVE_DOMAIN} (端口 ${PORT})`);
});
this._server.on('error', (e) => {
if (e.code === 'EACCES') {
log.error('http', `端口 ${PORT} 无权限,请以 root 运行或改用高端口`);
} else {
log.error('http', '服务器错误:', e.message);
}
});
});
}
stop() {
if (this._server) {
this._server.close();
this._server = null;
}
}
async _handle(req, res) {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const pathname = url.pathname;
// Captive Portal 检测请求 → 302 到配网页
if (CAPTIVE_DETECT_PATHS.has(pathname)) {
res.writeHead(302, { Location: `http://${CAPTIVE_DOMAIN}/` });
res.end();
return;
}
// API 路由
if (pathname === '/api/scan' && req.method === 'GET') {
return this._apiScan(req, res);
}
if (pathname === '/api/connect' && req.method === 'POST') {
return this._apiConnect(req, res);
}
if (pathname === '/api/status' && req.method === 'GET') {
return this._apiStatus(req, res);
}
// 默认返回配网页面
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
});
res.end(this._renderPage());
}
// ── API ──────────────────────────────────────────────────────────────────
_apiScan(req, res) {
const list = scanWifi();
this._json(res, { wifi: list });
}
async _apiConnect(req, res) {
const body = await readBody(req);
let data;
try { data = JSON.parse(body); } catch (_) {
this._json(res, { success: false, error: 'JSON 格式错误' }, 400);
return;
}
const { ssid, password } = data;
if (!ssid) {
this._json(res, { success: false, error: '请选择 WiFi' }, 400);
return;
}
log.info('http', `用户提交配网: ssid=${ssid}`);
// 先返回 "尝试中" 让前端轮询 /api/status
this._json(res, { success: true, message: '正在连接...' });
// 异步连接 WiFi会关闭 AP客户端会断开
setTimeout(async () => {
const result = connectWifi(ssid, password || '');
if (result.success && this._resolve) {
log.info('http', '配网成功,退出配网模式');
this._resolve({ ssid });
this._resolve = null;
} else {
log.warn('http', `配网失败: ${result.error},重新启动 AP`);
// provisioning.js 会处理重新进入 AP
}
}, 500);
}
_apiStatus(req, res) {
const { hasInternet } = require('./network');
this._json(res, { connected: hasInternet() });
}
_json(res, data, code = 200) {
res.writeHead(code, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
});
res.end(JSON.stringify(data));
}
// ── 配网页面 HTML ───────────────────────────────────────────────────────
_renderPage() {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>Claw Box 配网</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333;min-height:100vh;display:flex;justify-content:center;align-items:center;padding:20px}
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.1);padding:32px;width:100%;max-width:380px}
.logo{text-align:center;margin-bottom:24px}
.logo span{font-size:40px}
.logo h1{font-size:20px;margin-top:8px;color:#1a1a2e}
.logo p{font-size:13px;color:#888;margin-top:4px}
.device-id{text-align:center;background:#f8f9fa;border-radius:8px;padding:8px;margin-bottom:20px;font-size:14px;color:#555}
.device-id strong{color:#1a1a2e;font-size:16px}
label{display:block;font-size:14px;font-weight:500;margin-bottom:6px;color:#555}
select,input{width:100%;padding:12px;border:1.5px solid #ddd;border-radius:8px;font-size:15px;outline:none;transition:border-color .2s}
select:focus,input:focus{border-color:#4a6cf7}
.field{margin-bottom:16px}
.btn{width:100%;padding:14px;background:linear-gradient(135deg,#4a6cf7,#3b5de7);color:#fff;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;transition:opacity .2s}
.btn:hover{opacity:.9}
.btn:disabled{opacity:.5;cursor:not-allowed}
.btn-scan{background:#f0f2f5;color:#555;font-size:13px;padding:8px;margin-bottom:16px;font-weight:400}
.btn-scan:hover{background:#e8eaed}
.status{text-align:center;margin-top:16px;padding:12px;border-radius:8px;font-size:14px;display:none}
.status.ok{display:block;background:#e8f5e9;color:#2e7d32}
.status.err{display:block;background:#ffeaea;color:#c62828}
.status.info{display:block;background:#e3f2fd;color:#1565c0}
.manual{margin-top:8px}
.manual input{display:none}
.manual label{font-size:13px;color:#4a6cf7;cursor:pointer;text-align:center;display:block}
</style>
</head>
<body>
<div class="card">
<div class="logo">
<span>🦀</span>
<h1>Claw Box 配网</h1>
<p>将设备连接到您的 WiFi</p>
</div>
<div class="device-id">设备 ID: <strong>${this._clawId}</strong></div>
<button class="btn btn-scan" onclick="doScan()">🔍 扫描 WiFi</button>
<div class="field">
<label for="ssid">WiFi 网络</label>
<select id="ssid"><option value="">-- 点击上方扫描 --</option></select>
</div>
<div class="manual">
<input type="checkbox" id="manualToggle" onchange="toggleManual()">
<label for="manualToggle">手动输入 SSID</label>
<input type="text" id="manualSsid" placeholder="输入 WiFi 名称" style="display:none;margin-top:8px">
</div>
<div class="field" style="margin-top:16px">
<label for="password">密码</label>
<input type="password" id="password" placeholder="WiFi 密码(开放网络留空)">
</div>
<button class="btn" id="connectBtn" onclick="doConnect()">连接</button>
<div class="status" id="status"></div>
</div>
<script>
function $(id){return document.getElementById(id)}
function setStatus(msg,type){var s=$('status');s.textContent=msg;s.className='status '+type}
async function doScan(){
$('connectBtn').disabled=true;
setStatus('正在扫描...','info');
try{
var r=await fetch('/api/scan');
var d=await r.json();
var sel=$('ssid');
sel.innerHTML='<option value="">-- 请选择 --</option>';
(d.wifi||[]).forEach(function(w){
var o=document.createElement('option');
o.value=w.ssid;
o.textContent=w.ssid+' ('+w.signal+'% '+w.security+')';
sel.appendChild(o);
});
setStatus('扫描到 '+d.wifi.length+' 个网络','ok');
}catch(e){setStatus('扫描失败: '+e.message,'err')}
$('connectBtn').disabled=false;
}
function toggleManual(){
var on=$('manualToggle').checked;
$('manualSsid').style.display=on?'block':'none';
$('ssid').style.display=on?'none':'block';
}
async function doConnect(){
var ssid=$('manualToggle').checked?$('manualSsid').value:$('ssid').value;
var pw=$('password').value;
if(!ssid){setStatus('请选择或输入 WiFi','err');return}
$('connectBtn').disabled=true;
setStatus('正在连接 '+ssid+' ...','info');
try{
var r=await fetch('/api/connect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:ssid,password:pw})});
var d=await r.json();
if(d.success){
setStatus('✓ 正在连接,请稍候... 设备将自动重启网络','ok');
setTimeout(function(){setStatus('✓ 配网成功!您可以断开此热点','ok')},8000);
}else{
setStatus('连接失败: '+(d.error||'未知错误'),'err');
$('connectBtn').disabled=false;
}
}catch(e){
setStatus('连接失败: '+e.message,'err');
$('connectBtn').disabled=false;
}
}
doScan();
</script>
</body>
</html>`;
}
}
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', c => { data += c; if (data.length > 4096) req.destroy(); });
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
module.exports = { CaptiveServer };

View File

@@ -7,6 +7,7 @@ const log = require('./logger');
const { getBoxId } = require('./fingerprint');
const { collect } = require('./metrics');
const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc');
const { ensureNetwork } = require('./provisioning');
const MAX_BACKOFF_MS = 60_000;
const PONG_TIMEOUT_MS = 15_000;
@@ -58,6 +59,10 @@ class ClawClient {
async start() {
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
// 第一步:确保网络可用(无网时进入 AP 配网模式)
await ensureNetwork({ clawId: this._cfg.claw_id });
// 第二步:并行获取 dashboard 信息 + 启动 ttyd
const [dashInfo] = await Promise.all([
getDashboardInfo(),
startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),

99
lib/dns-hijack.js Normal file
View File

@@ -0,0 +1,99 @@
'use strict';
const { spawn, execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const log = require('./logger');
const { Watchdog } = require('./watchdog');
const CONFIG_DIR = process.env.CLAWD_CONFIG_DIR
|| (process.getuid && process.getuid() === 0 ? '/etc/clawd' : path.join(os.homedir(), '.clawd'));
const DNSMASQ_CONF = path.join(CONFIG_DIR, 'dnsmasq-captive.conf');
const CAPTIVE_DOMAIN = 'ap.cutos.ai';
/**
* 管理 dnsmasq 进程:
* - 所有 DNS 查询 → 网关 IP触发 Captive Portal 弹窗)
* - DHCP 分配 10.42.0.50 ~ 10.42.0.150
* - ap.cutos.ai 专门指向网关
*/
class DnsHijack {
constructor() {
this._watchdog = null;
}
/**
* 启动 dnsmasq
* @param {string} iface - AP 接口名(如 wlan0
* @param {string} gatewayIp - 网关 IP如 10.42.0.1
*/
start(iface, gatewayIp) {
this.stop();
const rangeStart = gatewayIp.replace(/\.\d+$/, '.50');
const rangeEnd = gatewayIp.replace(/\.\d+$/, '.150');
const conf = [
`interface=${iface}`,
'bind-interfaces',
`listen-address=${gatewayIp}`,
'',
'# DHCP',
`dhcp-range=${rangeStart},${rangeEnd},255.255.255.0,12h`,
`dhcp-option=3,${gatewayIp}`, // gateway
`dhcp-option=6,${gatewayIp}`, // DNS server
'',
'# DNS: 所有域名指向网关(触发 Captive Portal 检测)',
`address=/#/${gatewayIp}`,
'',
'# 日志',
'log-queries',
'log-facility=-', // stdout → Watchdog 采集
'',
'# 禁用系统 resolv.conf',
'no-resolv',
'no-poll',
].join('\n');
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(DNSMASQ_CONF, conf, 'utf8');
log.info('dns', `dnsmasq 配置已写入: ${DNSMASQ_CONF}`);
// 终止系统可能残留的 dnsmasq
try { execSync('pkill -f "dnsmasq.*clawd"', { timeout: 3000 }); } catch (_) {}
// 检查 dnsmasq 是否安装
try {
execSync('which dnsmasq', { timeout: 3000 });
} catch (_) {
log.error('dns', 'dnsmasq 未安装,请运行: apt install dnsmasq');
return;
}
this._watchdog = new Watchdog('dns', 'dnsmasq', [
'--no-daemon',
`--conf-file=${DNSMASQ_CONF}`,
], {
maxRestarts: 5,
windowMs: 60_000,
restartDelay: 2_000,
onStdout: (line) => log.debug('dns', line),
onStderr: (line) => log.debug('dns', line),
});
this._watchdog.start();
log.info('dns', `dnsmasq 已启动: ${CAPTIVE_DOMAIN}${gatewayIp}, 全域劫持`);
}
stop() {
if (this._watchdog) {
this._watchdog.stop();
this._watchdog = null;
}
try { execSync('pkill -f "dnsmasq.*clawd"', { timeout: 3000 }); } catch (_) {}
try { fs.unlinkSync(DNSMASQ_CONF); } catch (_) {}
}
}
module.exports = { DnsHijack, CAPTIVE_DOMAIN };

184
lib/network.js Normal file
View File

@@ -0,0 +1,184 @@
'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 = ''; // 开放网络,无密码(配网专用,生命周期短)
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 });
}
module.exports = {
hasInternet,
getWifiIface,
scanWifi,
connectWifi,
startAP,
stopAP,
AP_IP,
};

93
lib/provisioning.js Normal file
View File

@@ -0,0 +1,93 @@
'use strict';
const log = require('./logger');
const { hasInternet, startAP, stopAP, AP_IP } = require('./network');
const { DnsHijack } = require('./dns-hijack');
const { CaptiveServer } = require('./captive-server');
const config = require('./config');
const MAX_RETRIES = 3; // 配网连接失败后最多重新进入 AP 模式次数
/**
* 确保设备有互联网连接。
* 已联网 → 直接返回
* 未联网 → 进入 AP 配网模式 → 等待用户配网 → 成功后返回
*
* @param {object} opts
* @param {string|number} opts.clawId - 设备 ID用于 AP SSID
* @returns {Promise<void>}
*/
async function ensureNetwork(opts = {}) {
// 先检测是否已联网
if (hasInternet()) {
log.info('provision', '网络已就绪,跳过配网');
return;
}
log.warn('provision', '未检测到网络,进入配网模式...');
const cfg = config.load();
const clawId = opts.clawId || cfg.claw_id || 'Setup';
let retries = 0;
while (retries < MAX_RETRIES) {
try {
await runProvisioningRound(clawId);
// 配网成功,再验证一次
if (hasInternet()) {
log.info('provision', '配网完成,网络已就绪');
return;
}
log.warn('provision', '配网后仍无网络,重新进入配网模式...');
} catch (e) {
log.error('provision', `配网异常: ${e.message}`);
}
retries++;
if (retries < MAX_RETRIES) {
log.info('provision', `重试配网 (${retries}/${MAX_RETRIES})...`);
// 等一会再重试,避免过快循环
await sleep(3000);
}
}
log.error('provision', `配网失败 ${MAX_RETRIES} 次,将以离线模式继续启动(等待网络恢复后重连)`);
}
/**
* 单轮配网流程:开 AP → 启动 DNS + HTTP → 等待用户配网 → 清理
*/
async function runProvisioningRound(clawId) {
const dns = new DnsHijack();
const server = new CaptiveServer({ clawId });
try {
// 1. 启动 WiFi AP
const ap = startAP(clawId);
// 2. 启动 DNS 劫持
dns.start(ap.iface, AP_IP);
// 3. 启动 HTTP 配网页面,等待用户完成配网
// server.start() 返回 Promise配网成功时 resolve
log.info('provision', '配网页面已就绪,等待用户操作...');
log.info('provision', `用户请连接 WiFi "${ap.ssid}" 并访问 http://ap.cutos.ai`);
const result = await server.start();
log.info('provision', `用户已连接 WiFi: ${result.ssid}`);
} finally {
// 清理:无论成功失败都关闭 AP / DNS / HTTP
server.stop();
dns.stop();
stopAP();
}
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
module.exports = { ensureNetwork };