diff --git a/.commitmsg b/.commitmsg index 5ee0910..98e3132 100644 --- a/.commitmsg +++ b/.commitmsg @@ -1,5 +1,9 @@ -fix: clawd-rfkill.service failed due to systemd $ expansion +feat: AP always-on mode - hotspot stays until WiFi STA connects -systemd expands $rf as env var (empty), breaking the inline script. -Move rfkill logic to standalone script to avoid escaping issues. -Also use quoted heredoc to prevent bash expansion in install.sh. +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 diff --git a/lib/captive-server.js b/lib/captive-server.js index 4ae23a2..467fd95 100644 --- a/lib/captive-server.js +++ b/lib/captive-server.js @@ -2,12 +2,11 @@ const http = require('http'); const log = require('./logger'); -const { scanWifi, connectWifi } = require('./network'); +const { scanWifi } = 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 @@ -20,48 +19,43 @@ const CAPTIVE_DETECT_PATHS = new Set([ ]); /** - * 配网 HTTP 服务器。 + * 配网 HTTP 服务器(回调模式)。 * * 路由: * GET / → 配网页面(HTML) * GET /api/scan → WiFi 扫描结果 JSON - * POST /api/connect → 提交 WiFi 凭证,尝试连接 + * POST /api/connect → 提交 WiFi 凭证,触发 onConnect 回调 * GET /api/status → 当前连接状态 * Captive Portal 检测 → 302 重定向到配网页 */ class CaptiveServer { constructor(opts = {}) { - this._server = null; - this._clawId = opts.clawId || '???'; - this._resolve = null; // provisioning 等待配网完成的 resolve + this._server = null; + this._clawId = opts.clawId || '???'; + this._onConnect = opts.onConnect || null; // (ssid, password) => Promise<{success, error?}> } - /** - * 启动 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: '内部错误' })); - }); + startListening() { + 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.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); - } - }); + this._server.on('error', (e) => { + if (e.code === 'EACCES') { + log.error('http', `端口 ${PORT} 无权限,请以 root 运行`); + } else if (e.code === 'EADDRINUSE') { + log.error('http', `端口 ${PORT} 已被占用`); + } else { + log.error('http', '服务器错误:', e.message); + } }); } @@ -76,14 +70,12 @@ class CaptiveServer { 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); } @@ -94,7 +86,6 @@ class CaptiveServer { return this._apiStatus(req, res); } - // 默认返回配网页面 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', @@ -125,21 +116,16 @@ class CaptiveServer { log.info('http', `用户提交配网: ssid=${ssid}`); - // 先返回 "尝试中" 让前端轮询 /api/status - this._json(res, { success: true, message: '正在连接...' }); + // 先返回响应,让手机端知道设备收到了请求 + // (AP 即将关闭,手机会断开连接) + this._json(res, { success: true, message: '正在连接,AP 将临时关闭...' }); - // 异步连接 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); + // 延迟执行,确保 HTTP 响应送达 + if (this._onConnect) { + setTimeout(() => { + this._onConnect(ssid, password || ''); + }, 1000); + } } _apiStatus(req, res) { @@ -257,19 +243,18 @@ async function doConnect(){ var pw=$('password').value; if(!ssid){setStatus('请选择或输入 WiFi','err');return} $('connectBtn').disabled=true; - setStatus('正在连接 '+ssid+' ...','info'); + 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); + setStatus('✓ 设备正在连接 WiFi,热点将关闭。如连接失败,热点会自动恢复,请重新连接配网。','ok'); }else{ - setStatus('连接失败: '+(d.error||'未知错误'),'err'); + setStatus('失败: '+(d.error||'未知错误'),'err'); $('connectBtn').disabled=false; } }catch(e){ - setStatus('连接失败: '+e.message,'err'); + setStatus('请求失败: '+e.message,'err'); $('connectBtn').disabled=false; } } diff --git a/lib/client.js b/lib/client.js index 2060a22..070a74f 100644 --- a/lib/client.js +++ b/lib/client.js @@ -7,7 +7,8 @@ const log = require('./logger'); const { getBoxId } = require('./fingerprint'); const { collect } = require('./metrics'); const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc'); -const { ensureNetwork } = require('./provisioning'); +const { ProvisionManager } = require('./provisioning'); +const { hasInternet } = require('./network'); const MAX_BACKOFF_MS = 60_000; const PONG_TIMEOUT_MS = 15_000; @@ -59,12 +60,27 @@ class ClawClient { async start() { log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`); - // 第一步:确保网络可用(无网时进入 AP 配网模式) - await ensureNetwork({ clawId: this._cfg.claw_id }); + // 后台启动 AP 配网管理器(WiFi 未连接时常驻热点) + this._provisionMgr = new ProvisionManager(this._cfg.claw_id); + this._provisionMgr.start(); - // 第二步:并行获取 dashboard 信息 + 启动 ttyd + // 有网络时直接连接云端 + if (hasInternet()) { + await this._proceedWithConnection(); + } else { + // 等待配网完成后再连 + log.info('clawd', '等待网络就绪...'); + this._provisionMgr.once('network-ready', () => { + this._proceedWithConnection().catch(e => { + log.error('clawd', '连接启动失败:', e.message); + }); + }); + } + } + + async _proceedWithConnection() { const [dashInfo] = await Promise.all([ - getDashboardInfo(), + getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }), startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)), ]); this._dashInfo = dashInfo || {}; @@ -77,6 +93,7 @@ class ClawClient { this._clearHeartbeat(); this._clearPing(); if (this._sdTimer) { clearInterval(this._sdTimer); this._sdTimer = null; } + if (this._provisionMgr) { this._provisionMgr.stop(); this._provisionMgr = null; } this._frpc.stop(); if (this._ws) this._ws.terminate(); this._sdNotify('STOPPING=1'); diff --git a/lib/network.js b/lib/network.js index bbbe2df..9154956 100644 --- a/lib/network.js +++ b/lib/network.js @@ -173,8 +173,26 @@ 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, diff --git a/lib/provisioning.js b/lib/provisioning.js index 1ba326d..0c083b3 100644 --- a/lib/provisioning.js +++ b/lib/provisioning.js @@ -1,93 +1,147 @@ 'use strict'; +const EventEmitter = require('events'); const log = require('./logger'); -const { hasInternet, startAP, stopAP, AP_IP } = require('./network'); -const { DnsHijack } = require('./dns-hijack'); -const { CaptiveServer } = require('./captive-server'); +const { hasInternet, isWifiStaConnected, startAP, stopAP, connectWifi, AP_IP } = require('./network'); +const { DnsHijack } = require('./dns-hijack'); +const { CaptiveServer } = require('./captive-server'); -const config = require('./config'); - -const MAX_RETRIES = 3; // 配网连接失败后最多重新进入 AP 模式次数 +const MONITOR_INTERVAL_MS = 30_000; /** - * 确保设备有互联网连接。 - * 已联网 → 直接返回 - * 未联网 → 进入 AP 配网模式 → 等待用户配网 → 成功后返回 + * AP 常驻配网管理器。 * - * @param {object} opts - * @param {string|number} opts.clawId - 设备 ID(用于 AP SSID) - * @returns {Promise} + * 规则: + * - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页 + * - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP + * - 运行中 WiFi 断开 → 自动重新开 AP + * - WiFi 已连接 → AP 关闭 */ -async function ensureNetwork(opts = {}) { - // 先检测是否已联网 - if (hasInternet()) { - log.info('provision', '网络已就绪,跳过配网'); - return; +class ProvisionManager extends EventEmitter { + constructor(clawId) { + super(); + this._clawId = clawId || 'Setup'; + this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta' + this._dns = null; + this._server = null; + this._monitorTimer = null; } - log.warn('provision', '未检测到网络,进入配网模式...'); + start() { + if (isWifiStaConnected()) { + this._state = 'sta'; + log.info('provision', 'WiFi STA 已连接,AP 不启动'); + } else { + this._enterAP(); + } + this._startMonitor(); - const cfg = config.load(); - const clawId = opts.clawId || cfg.claw_id || 'Setup'; - let retries = 0; + if (hasInternet()) { + this.emit('network-ready'); + } + } + + stop() { + this._stopMonitor(); + this._stopAll(); + this._state = 'idle'; + } + + // ── 进入 AP 模式 ───────────────────────────────────────────────────────── + + _enterAP() { + if (this._state === 'ap') return; - while (retries < MAX_RETRIES) { try { - await runProvisioningRound(clawId); + const ap = startAP(this._clawId); - // 配网成功,再验证一次 - if (hasInternet()) { - log.info('provision', '配网完成,网络已就绪'); - return; + this._dns = new DnsHijack(); + this._dns.start(ap.iface, AP_IP); + + this._server = new CaptiveServer({ + clawId: this._clawId, + onConnect: (ssid, password) => this._handleWifiConnect(ssid, password), + }); + this._server.startListening(); + + this._state = 'ap'; + log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`); + log.info('provision', `配网地址: http://ap.cutos.ai`); + } catch (e) { + log.error('provision', `AP 启动失败: ${e.message}`); + } + } + + // ── 用户提交 WiFi 凭证 ─────────────────────────────────────────────────── + + async _handleWifiConnect(ssid, password) { + if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' }; + + this._state = 'connecting'; + log.info('provision', `用户请求连接 WiFi: ${ssid}`); + + this._stopAPServices(); + + const result = connectWifi(ssid, password); + + if (result.success) { + this._state = 'sta'; + log.info('provision', `WiFi 已连接: ${ssid}`); + this.emit('network-ready'); + return result; + } + + log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`); + this._enterAP(); + return result; + } + + // ── WiFi 状态监控 ───────────────────────────────────────────────────────── + + _startMonitor() { + this._monitorTimer = setInterval(() => { + if (this._state === 'connecting') return; + + const wifiUp = isWifiStaConnected(); + + if (this._state === 'sta' && !wifiUp) { + log.warn('provision', 'WiFi 连接已断开,重新启动 AP'); + this._enterAP(); } - log.warn('provision', '配网后仍无网络,重新进入配网模式...'); - } catch (e) { - log.error('provision', `配网异常: ${e.message}`); - } + if (this._state === 'ap' && wifiUp) { + log.info('provision', 'WiFi 已外部连接,关闭 AP'); + this._stopAPServices(); + this._state = 'sta'; + this.emit('network-ready'); + } + }, MONITOR_INTERVAL_MS); + } - retries++; - if (retries < MAX_RETRIES) { - log.info('provision', `重试配网 (${retries}/${MAX_RETRIES})...`); - // 等一会再重试,避免过快循环 - await sleep(3000); + _stopMonitor() { + if (this._monitorTimer) { + clearInterval(this._monitorTimer); + this._monitorTimer = null; } } - 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(); + _stopAPServices() { + if (this._server) { + this._server.stop(); + this._server = null; + } + if (this._dns) { + this._dns.stop(); + this._dns = null; + } stopAP(); } + + _stopAll() { + this._stopAPServices(); + } } -function sleep(ms) { - return new Promise(r => setTimeout(r, ms)); -} - -module.exports = { ensureNetwork }; +module.exports = { ProvisionManager };