diff --git a/README.md b/README.md
index 4967eb6..d186959 100644
--- a/README.md
+++ b/README.md
@@ -1,199 +1,199 @@
-# clawd
-
-Claw Box 守护进程,将本地 Linux 设备通过 WebSocket 长连接接入 [claw.cutos.ai](https://claw.cutos.ai)。
-
-## 功能
-
-- 自动生成硬件唯一指纹(`box_id`)
-- 首次连接自动注册,获取 `claw_id` + `token` 并持久化
-- 每 30 秒上报系统指标(CPU、内存、磁盘、温度、负载、运行时间)
-- 断线自动重连(指数退避,最大 60 秒)
-- WS 层 Ping/Pong 活性检测,连接假死自动重连
-- frpc / ttyd 子进程 Watchdog 守护,崩溃自动重启(速率限制)
-- 结构化日志 + 文件轮转(5MB × 5 份)
-- systemd 集成:Watchdog、资源限制、优雅停止
-- 全局异常兜底(uncaughtException / unhandledRejection)
-
-## 快速安装(Linux,需要 root)
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/stswangzhiping/clawd/main/install.sh | sudo bash
-```
-
-要求:
-- Node.js >= 18
-- Linux(systemd)
-
-## 手动运行(开发调试)
-
-```bash
-git clone https://github.com/stswangzhiping/clawd.git
-cd clawd
-npm install
-node bin/clawd.js
-```
-
-## 首次启动输出示例
-
-```
-2026-03-16T10:00:00.000Z INFO [clawd] 启动中... 服务器 = wss://claw.cutos.ai/ws
-2026-03-16T10:00:01.000Z INFO [clawd] WebSocket 已连接
-2026-03-16T10:00:01.100Z INFO [clawd] 注册成功!claw_id = 1000
-2026-03-16T10:00:01.100Z INFO [clawd]
-2026-03-16T10:00:01.100Z INFO [clawd] ╔════════════════════════════════════╗
-2026-03-16T10:00:01.100Z INFO [clawd] ║ Claw ID : 1000 ║
-2026-03-16T10:00:01.100Z INFO [clawd] ║ PIN 码 : 779413 ║
-2026-03-16T10:00:01.100Z INFO [clawd] ║ 请在网页前台「添加设备」中输入 ║
-2026-03-16T10:00:01.100Z INFO [clawd] ╚════════════════════════════════════╝
-2026-03-16T10:00:01.100Z INFO [clawd]
-2026-03-16T10:00:01.100Z INFO [clawd] 等待激活,心跳正常运行...
-```
-
-## 配置文件
-
-路径:`/etc/clawd/config.json`(root 运行)或 `~/.clawd/config.json`(普通用户)
-
-```json
-{
- "server": "wss://claw.cutos.ai/ws",
- "claw_id": 1000,
- "token": "6e0c182e...",
- "heartbeat_interval": 30
-}
-```
-
-## 环境变量
-
-| 变量 | 默认值 | 说明 |
-|------|--------|------|
-| `CLAWD_LOG_LEVEL` | `info` | 日志级别:debug / info / warn / error |
-| `CLAWD_LOG_FILE` | `1` | 是否写日志文件(`0` = 仅 stdout/journald) |
-| `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 |
-| `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 |
-
-systemd 安装后环境变量文件位于 `/etc/clawd/env`。
-
-## 服务管理
-
-```bash
-systemctl status clawd # 查看状态
-journalctl -u clawd -f # 实时日志
-systemctl restart clawd # 重启
-systemctl stop clawd # 停止
-systemctl disable clawd # 取消开机自启
-```
-
-## 更新
-
-clawd 安装在 `/opt/clawd`,更新时需在该目录执行 `git pull`:
-
-```bash
-cd /opt/clawd && sudo git pull && sudo systemctl restart clawd
-```
-
-## 日志
-
-- **stdout/journald**:所有日志同时输出到标准输出(systemd 自动采集到 journald)
-- **文件日志**:`/etc/clawd/logs/clawd.log`,单文件 5MB,保留 5 份轮转
-
-## 心跳上报字段
-
-| 字段 | 说明 | 单位 |
-|------|------|------|
-| `cpu` | CPU 使用率 | % |
-| `mem_total` / `mem_used` | 内存总量 / 已用 | KB |
-| `disk_total` / `disk_used` | 磁盘总量 / 已用 | KB |
-| `temperature` | CPU 温度 | °C |
-| `load_1m` / `load_5m` / `load_15m` | 系统负载 | — |
-| `uptime` | 运行时间 | 秒 |
-
-## WiFi 配网(用户手册)
-
-Claw Box 是无屏设备,通过 WiFi 热点完成网络配置。
-
-### 什么时候会出现热点?
-
-| 场景 | 热点状态 |
-|------|----------|
-| 首次开机,从未配过 WiFi | 立即开启 |
-| 配过 WiFi,但信号范围外或密码已改 | 等待约 20 秒后自动开启 |
-| WiFi 正常连接中 | 不开启 |
-| 运行中 WiFi 突然断开 | 约 30 秒后自动开启 |
-
-### 配网步骤
-
-**第一步:找到热点**
-
-打开手机 WiFi 设置,找到名为 **ClawBox-{设备ID}** 的热点(例如 `ClawBox-1002`)。
-设备 ID 印在机身标签上。
-
-**第二步:连接热点**
-
-- 热点名称:`ClawBox-{设备ID}`
-- 密码:**`12345678`**
-
-**第三步:打开配网页面**
-
-连接成功后,手机通常会**自动弹出配网页面**。
-
-如果没有弹出,请手动打开浏览器访问:
-- `http://ap.cutos.ai`
-- 或 `http://10.42.0.1`
-
-**第四步:选择 WiFi 并连接**
-
-1. 点击 **「扫描 WiFi」** 按钮,等待扫描完成
-2. 从下拉列表中选择您的 WiFi(或勾选「手动输入 SSID」)
-3. 输入 WiFi 密码
-4. 点击 **「连接」**
-
-**第五步:等待连接**
-
-- 设备会临时关闭热点,尝试连接您选择的 WiFi
-- **连接成功**:热点不再出现,设备自动接入云端
-- **连接失败**:热点会在几秒后重新出现,请重新连接热点再试
-
-### 更换 WiFi
-
-如果需要更换 WiFi(例如搬到新环境),只需等待设备检测到网络断开,
-热点会自动重新出现,按上述步骤重新配网即可。
-
-### 常见问题
-
-| 问题 | 解决方法 |
-|------|----------|
-| 找不到 ClawBox 热点 | 等待 30 秒;确认设备已通电且指示灯正常 |
-| 连上热点但页面打不开 | 手动访问 `http://10.42.0.1` |
-| 扫描不到我的 WiFi | 点击刷新重试;确认路由器开启且距离不太远 |
-| 输入密码后连接失败 | 检查密码是否正确;热点恢复后重试 |
-| 配网成功但设备仍离线 | 检查路由器是否能上外网;稍等 1 分钟 |
-
-### 系统要求
-
-- `NetworkManager`(安装脚本自动启用)
-- WiFi 硬件(wlan0)
-
-## 架构
-
-```
-clawd/
-├── bin/clawd.js ← 入口,优雅停止
-├── lib/
-│ ├── client.js ← 核心:WS 连接、心跳、Ping/Pong、sd-notify
-│ ├── config.js ← 配置读写
-│ ├── fingerprint.js ← 硬件指纹生成
-│ ├── frpc.js ← frpc/ttyd/dashboard 管理(Watchdog 守护)
-│ ├── logger.js ← 结构化日志 + 文件轮转
-│ ├── metrics.js ← 系统指标采集
-│ ├── watchdog.js ← 通用子进程守护(速率限制重启)
-│ ├── network.js ← 网络检测、WiFi 扫描/连接、AP 模式
-│ ├── dns-hijack.js ← DNS 劫持(NM dnsmasq-shared.d 配置)
-│ ├── captive-server.js ← 配网 HTTP 页面(Captive Portal)
-│ └── provisioning.js ← AP 常驻管理器(WiFi 状态监控)
-├── install.sh ← 一键安装(含 systemd + dnsmasq)
-└── package.json
-```
-
-## License
-
-MIT
+# clawd
+
+Claw Box 守护进程,将本地 Linux 设备通过 WebSocket 长连接接入 [claw.cutos.ai](https://claw.cutos.ai)。
+
+## 功能
+
+- 自动生成硬件唯一指纹(`box_id`)
+- 首次连接自动注册,获取 `claw_id` + `token` 并持久化
+- 每 30 秒上报系统指标(CPU、内存、磁盘、温度、负载、运行时间)
+- 断线自动重连(指数退避,最大 60 秒)
+- WS 层 Ping/Pong 活性检测,连接假死自动重连
+- frpc / ttyd 子进程 Watchdog 守护,崩溃自动重启(速率限制)
+- 结构化日志 + 文件轮转(5MB × 5 份)
+- systemd 集成:Watchdog、资源限制、优雅停止
+- 全局异常兜底(uncaughtException / unhandledRejection)
+
+## 快速安装(Linux,需要 root)
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/stswangzhiping/clawd/main/install.sh | sudo bash
+```
+
+要求:
+- Node.js >= 18
+- Linux(systemd)
+
+## 手动运行(开发调试)
+
+```bash
+git clone https://github.com/stswangzhiping/clawd.git
+cd clawd
+npm install
+node bin/clawd.js
+```
+
+## 首次启动输出示例
+
+```
+2026-03-16T10:00:00.000Z INFO [clawd] 启动中... 服务器 = wss://claw.cutos.ai/ws
+2026-03-16T10:00:01.000Z INFO [clawd] WebSocket 已连接
+2026-03-16T10:00:01.100Z INFO [clawd] 注册成功!claw_id = 1000
+2026-03-16T10:00:01.100Z INFO [clawd]
+2026-03-16T10:00:01.100Z INFO [clawd] ╔════════════════════════════════════╗
+2026-03-16T10:00:01.100Z INFO [clawd] ║ Claw ID : 1000 ║
+2026-03-16T10:00:01.100Z INFO [clawd] ║ PIN 码 : 779413 ║
+2026-03-16T10:00:01.100Z INFO [clawd] ║ 请在网页前台「添加设备」中输入 ║
+2026-03-16T10:00:01.100Z INFO [clawd] ╚════════════════════════════════════╝
+2026-03-16T10:00:01.100Z INFO [clawd]
+2026-03-16T10:00:01.100Z INFO [clawd] 等待激活,心跳正常运行...
+```
+
+## 配置文件
+
+路径:`/etc/clawd/config.json`(root 运行)或 `~/.clawd/config.json`(普通用户)
+
+```json
+{
+ "server": "wss://claw.cutos.ai/ws",
+ "claw_id": 1000,
+ "token": "6e0c182e...",
+ "heartbeat_interval": 30
+}
+```
+
+## 环境变量
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `CLAWD_LOG_LEVEL` | `info` | 日志级别:debug / info / warn / error |
+| `CLAWD_LOG_FILE` | `1` | 是否写日志文件(`0` = 仅 stdout/journald) |
+| `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 |
+| `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 |
+
+systemd 安装后环境变量文件位于 `/etc/clawd/env`。
+
+## 服务管理
+
+```bash
+systemctl status clawd # 查看状态
+journalctl -u clawd -f # 实时日志
+systemctl restart clawd # 重启
+systemctl stop clawd # 停止
+systemctl disable clawd # 取消开机自启
+```
+
+## 更新
+
+clawd 安装在 `/opt/clawd`,更新时需在该目录执行 `git pull`:
+
+```bash
+cd /opt/clawd && sudo git pull && sudo systemctl restart clawd
+```
+
+## 日志
+
+- **stdout/journald**:所有日志同时输出到标准输出(systemd 自动采集到 journald)
+- **文件日志**:`/etc/clawd/logs/clawd.log`,单文件 5MB,保留 5 份轮转
+
+## 心跳上报字段
+
+| 字段 | 说明 | 单位 |
+|------|------|------|
+| `cpu` | CPU 使用率 | % |
+| `mem_total` / `mem_used` | 内存总量 / 已用 | KB |
+| `disk_total` / `disk_used` | 磁盘总量 / 已用 | KB |
+| `temperature` | CPU 温度 | °C |
+| `load_1m` / `load_5m` / `load_15m` | 系统负载 | — |
+| `uptime` | 运行时间 | 秒 |
+
+## WiFi 配网(用户手册)
+
+Claw Box 是无屏设备,通过 WiFi 热点完成网络配置。
+
+### 什么时候会出现热点?
+
+| 场景 | 热点状态 |
+|------|----------|
+| 首次开机,从未配过 WiFi | 立即开启 |
+| 配过 WiFi,但信号范围外或密码已改 | 等待约 20 秒后自动开启 |
+| WiFi 正常连接中 | 不开启 |
+| 运行中 WiFi 突然断开 | 约 30 秒后自动开启 |
+
+### 配网步骤
+
+**第一步:找到热点**
+
+打开手机 WiFi 设置,找到名为 **ClawBox-{设备ID}** 的热点(例如 `ClawBox-1002`)。
+设备 ID 印在机身标签上。
+
+**第二步:连接热点**
+
+- 热点名称:`ClawBox-{设备ID}`
+- 密码:**`12345678`**
+
+**第三步:打开配网页面**
+
+连接成功后,手机通常会**自动弹出配网页面**。
+
+如果没有弹出,请手动打开浏览器访问:
+- `http://ap.cutos.ai`
+- 或 `http://10.42.0.1`
+
+**第四步:选择 WiFi 并连接**
+
+1. 点击 **「扫描 WiFi」** 按钮,等待扫描完成
+2. 从下拉列表中选择您的 WiFi(或勾选「手动输入 SSID」)
+3. 输入 WiFi 密码
+4. 点击 **「连接」**
+
+**第五步:等待连接**
+
+- 设备会临时关闭热点,尝试连接您选择的 WiFi
+- **连接成功**:热点不再出现,设备自动接入云端
+- **连接失败**:热点会在几秒后重新出现,请重新连接热点再试
+
+### 更换 WiFi
+
+如果需要更换 WiFi(例如搬到新环境),只需等待设备检测到网络断开,
+热点会自动重新出现,按上述步骤重新配网即可。
+
+### 常见问题
+
+| 问题 | 解决方法 |
+|------|----------|
+| 找不到 ClawBox 热点 | 等待 30 秒;确认设备已通电且指示灯正常 |
+| 连上热点但页面打不开 | 手动访问 `http://10.42.0.1` |
+| 扫描不到我的 WiFi | 点击刷新重试;确认路由器开启且距离不太远 |
+| 输入密码后连接失败 | 检查密码是否正确;热点恢复后重试 |
+| 配网成功但设备仍离线 | 检查路由器是否能上外网;稍等 1 分钟 |
+
+### 系统要求
+
+- `NetworkManager`(安装脚本自动启用)
+- WiFi 硬件(wlan0)
+
+## 架构
+
+```
+clawd/
+├── bin/clawd.js ← 入口,优雅停止
+├── lib/
+│ ├── client.js ← 核心:WS 连接、心跳、Ping/Pong、sd-notify
+│ ├── config.js ← 配置读写
+│ ├── fingerprint.js ← 硬件指纹生成
+│ ├── frpc.js ← frpc/ttyd/dashboard 管理(Watchdog 守护)
+│ ├── logger.js ← 结构化日志 + 文件轮转
+│ ├── metrics.js ← 系统指标采集
+│ ├── watchdog.js ← 通用子进程守护(速率限制重启)
+│ ├── network.js ← 网络检测、WiFi 扫描/连接、AP 模式
+│ ├── dns-hijack.js ← DNS 劫持(NM dnsmasq-shared.d 配置)
+│ ├── captive-server.js ← 配网 HTTP 页面(Captive Portal)
+│ └── provisioning.js ← AP 常驻管理器(WiFi 状态监控)
+├── install.sh ← 一键安装(含 systemd + dnsmasq)
+└── package.json
+```
+
+## License
+
+MIT
diff --git a/lib/captive-server.js b/lib/captive-server.js
index 87ebd6c..4bcd6a2 100644
--- a/lib/captive-server.js
+++ b/lib/captive-server.js
@@ -1,319 +1,319 @@
-'use strict';
-
-const http = require('http');
-const log = require('./logger');
-// scanWifi 不再在此调用(AP 模式下无法扫描),改用缓存
-const { CAPTIVE_DOMAIN } = require('./dns-hijack');
-
-const PORT = 80;
-
-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 凭证,触发 onConnect 回调
- * GET /api/status → 当前连接状态
- * Captive Portal 检测 → 302 重定向到配网页
- */
-class CaptiveServer {
- constructor(opts = {}) {
- this._server = null;
- this._clawId = opts.clawId || '???';
- this._onConnect = opts.onConnect || null;
- this._cachedWifiList = opts.cachedWifiList || [];
- }
-
- 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.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);
- }
- });
- }
-
- 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;
-
- if (CAPTIVE_DETECT_PATHS.has(pathname)) {
- res.writeHead(302, { Location: `http://${CAPTIVE_DOMAIN}/` });
- res.end();
- return;
- }
-
- 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) {
- // AP 模式下 wlan0 无法扫描,返回开 AP 前的缓存结果
- this._json(res, { wifi: this._cachedWifiList, cached: true });
- }
-
- 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}`);
-
- // 先返回响应,让手机端知道设备收到了请求
- // (AP 即将关闭,手机会断开连接)
- this._json(res, { success: true, message: '正在连接,AP 将临时关闭...' });
-
- // 延迟执行,确保 HTTP 响应送达
- if (this._onConnect) {
- setTimeout(() => {
- this._onConnect(ssid, password || '');
- }, 1000);
- }
- }
-
- _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 `
-
-
-
-
-Claw Box 配网
-
-
-
-
-
-
🦀
-
Claw Box 配网
-
将设备连接到您的 WiFi
-
-
设备 ID: ${this._clawId}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
- }
-}
-
-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 };
+'use strict';
+
+const http = require('http');
+const log = require('./logger');
+// scanWifi 不再在此调用(AP 模式下无法扫描),改用缓存
+const { CAPTIVE_DOMAIN } = require('./dns-hijack');
+
+const PORT = 80;
+
+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 凭证,触发 onConnect 回调
+ * GET /api/status → 当前连接状态
+ * Captive Portal 检测 → 302 重定向到配网页
+ */
+class CaptiveServer {
+ constructor(opts = {}) {
+ this._server = null;
+ this._clawId = opts.clawId || '???';
+ this._onConnect = opts.onConnect || null;
+ this._cachedWifiList = opts.cachedWifiList || [];
+ }
+
+ 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.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);
+ }
+ });
+ }
+
+ 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;
+
+ if (CAPTIVE_DETECT_PATHS.has(pathname)) {
+ res.writeHead(302, { Location: `http://${CAPTIVE_DOMAIN}/` });
+ res.end();
+ return;
+ }
+
+ 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) {
+ // AP 模式下 wlan0 无法扫描,返回开 AP 前的缓存结果
+ this._json(res, { wifi: this._cachedWifiList, cached: true });
+ }
+
+ 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}`);
+
+ // 先返回响应,让手机端知道设备收到了请求
+ // (AP 即将关闭,手机会断开连接)
+ this._json(res, { success: true, message: '正在连接,AP 将临时关闭...' });
+
+ // 延迟执行,确保 HTTP 响应送达
+ if (this._onConnect) {
+ setTimeout(() => {
+ this._onConnect(ssid, password || '');
+ }, 1000);
+ }
+ }
+
+ _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 `
+
+
+
+
+Claw Box 配网
+
+
+
+
+
+
🦀
+
Claw Box 配网
+
将设备连接到您的 WiFi
+
+
设备 ID: ${this._clawId}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+ }
+}
+
+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 };
diff --git a/lib/client.js b/lib/client.js
index f1e6d84..2d2af7c 100644
--- a/lib/client.js
+++ b/lib/client.js
@@ -1,420 +1,444 @@
-'use strict';
-
-const WebSocket = require('ws');
-const { execFileSync } = require('child_process');
-const config = require('./config');
-const log = require('./logger');
-const { getBoxId } = require('./fingerprint');
-const { collect } = require('./metrics');
-const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新
-const { ProvisionManager } = require('./provisioning');
-const { hasInternet } = require('./network');
-const led = require('./led');
-
-const MAX_BACKOFF_MS = 60_000;
-const PONG_TIMEOUT_MS = 8_000;
-const PING_INTERVAL_MS = 10_000;
-const NET_MONITOR_MS = 5_000; // AP 模式网络监视间隔
-const HEARTBEAT_INTERVAL_MS = 10_000; // 心跳间隔:10 秒,用于快速感知网络状态
-const METRICS_EVERY_N = 3; // 每 N 次心跳采集一次指标(= 30 秒)
-
-// systemd watchdog: 如果 WatchdogSec 存在,定期发 WATCHDOG=1
-const SD_WATCHDOG_USEC = parseInt(process.env.WATCHDOG_USEC || '0', 10);
-const SD_NOTIFY_INTERVAL = SD_WATCHDOG_USEC > 0
- ? Math.floor(SD_WATCHDOG_USEC / 2 / 1000) // 半周期通知(μs → ms)
- : 0;
-
-class ClawClient {
- constructor() {
- this._cfg = config.load();
- this._boxId = getBoxId();
- this._ws = null;
- this._hbTimer = null;
- this._backoff = 1_000;
- this._stopped = false;
- this._frpc = new FrpcManager();
- this._dashInfo = {};
- this._hbCount = 0; // 心跳计数器,用于定期刷新 dashboard 信息
-
- // WS 层活性检测
- this._pingTimer = null;
- this._awaitingPong = false;
-
- // AP 模式网络监视(WS 连通后每 5s 检查,断网立即 terminate)
- this._netMonitorTimer = null;
-
- // WS 连续失败计数(open 时清零)
- this._wsFailCount = 0;
- // 是否曾经成功连接过(首次成功前不显示 Err0/AP)
- this._hasEverConnected = false;
- // 最近一次 WS 错误是否是证书时间问题(NTP 未同步)
- this._certTimeError = false;
-
- // systemd watchdog
- this._sdTimer = null;
-
- this._setupGlobalHandlers();
- }
-
- // ── 全局异常兜底 ─────────────────────────────────────────────────────────────
-
- _setupGlobalHandlers() {
- process.on('uncaughtException', (err) => {
- log.error('process', '未捕获异常:', err);
- // 给日志写盘的时间,然后退出让 systemd 重启
- setTimeout(() => process.exit(1), 1000);
- });
-
- process.on('unhandledRejection', (reason) => {
- log.error('process', '未处理的 Promise 拒绝:', reason);
- });
- }
-
- // ── 生命周期 ─────────────────────────────────────────────────────────────────
-
- async start() {
- log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
-
- // 启动时全灭,WS 连接后由 _applyStatus() 按实际状态设置
- led.status.off();
-
- this._startSdNotify();
-
- // 启动 AP 配网管理器(等待已保存 WiFi 自动连接,超时再开 AP)
- this._provisionMgr = new ProvisionManager(this._cfg.claw_id);
- this._connectionStarted = false;
-
- // 网络就绪时连接云端(仅触发一次)
- this._provisionMgr.on('network-ready', () => {
- if (!this._connectionStarted) {
- this._connectionStarted = true;
- this._proceedWithConnection().catch(e => {
- log.error('clawd', '连接启动失败:', e.message);
- });
- }
- });
-
- await this._provisionMgr.start();
-
- // start() 返回后,如果已有网络且尚未启动连接
- if (hasInternet() && !this._connectionStarted) {
- this._connectionStarted = true;
- await this._proceedWithConnection();
- } else if (!hasInternet()) {
- log.info('clawd', '等待网络就绪(WiFi 配网或网线接入)...');
- }
- }
-
- async _proceedWithConnection() {
- const [dashInfo] = await Promise.all([
- getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }),
- startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
- ]);
- this._dashInfo = dashInfo || {};
- this._connect();
- }
-
- stop() {
- this._stopped = true;
- this._clearHeartbeat();
- this._clearPing();
- this._clearNetMonitor();
- 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();
- led.status.off(); // 进程退出,两灯全灭
- this._sdNotify('STOPPING=1');
- log.info('clawd', '已停止');
- log.close();
- }
-
- // ── WebSocket 连接 ──────────────────────────────────────────────────────────
-
- _connect() {
- if (this._stopped) return;
-
- // AP 模式 + 无网:不建立 WS,5s 后重新检查网络
- if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet()) {
- led.display.showAP();
- log.info('clawd', 'AP 模式无网络,5s 后重新检查...');
- this._backoff = 1_000; // 有网时立即快速重连
- this._wsFailCount = 0; // 不计入失败
- setTimeout(() => this._connect(), 5_000);
- return;
- }
-
- if (!this._hasEverConnected || this._wsFailCount < 3) led.display.showConn();
- log.info('clawd', `正在连接 ${this._cfg.server} ...`);
- const ws = new WebSocket(this._cfg.server, {
- handshakeTimeout: 10_000,
- });
- this._ws = ws;
-
- ws.on('open', () => {
- log.info('clawd', 'WebSocket 已连接');
- this._backoff = 1_000;
- this._wsFailCount = 0; // 连接成功,重置失败计数
- this._hasEverConnected = true; // 标记已成功连接过
- this._sendConnect();
- this._startPing();
- this._startNetMonitor();
- // 显示由 _onConnected 根据 status 设置,不在此处提前 showTime
- });
-
- ws.on('message', (data) => {
- try {
- this._handleMessage(JSON.parse(data.toString()));
- } catch (e) {
- log.error('clawd', '消息解析失败:', e.message);
- }
- });
-
- ws.on('pong', () => {
- this._awaitingPong = false;
- });
-
- ws.on('close', (code, reason) => {
- this._clearHeartbeat();
- this._clearPing();
- this._clearNetMonitor();
- if (!this._stopped) {
- this._wsFailCount++;
- log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount},${this._backoff / 1000}s 后重连...`);
- if (this._hasEverConnected && this._wsFailCount >= 3) {
- const inAp = this._provisionMgr && this._provisionMgr.isApMode();
- if (inAp || !hasInternet()) {
- led.display.showAP(); // AP 模式 或 无网
- } else {
- led.display.showErr0(); // STA 模式 + 有网 但 VPS 不可达
- }
- }
- if (this._certTimeError) {
- // NTP 未同步:固定 5s 重试,等时钟校正
- this._certTimeError = false;
- this._backoff = 5_000;
- log.warn('clawd', '证书时间错误(NTP 未同步),5s 后重试...');
- } else {
- this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS);
- }
- setTimeout(() => this._connect(), this._backoff);
- }
- });
-
- ws.on('error', (err) => {
- log.error('clawd', '连接错误:', err.message);
- // 证书时间错误:NTP 未同步,close 后用固定短间隔重试,不做指数退避
- this._certTimeError = !!(
- err.code === 'CERT_NOT_YET_VALID' ||
- (err.message && err.message.includes('not yet valid'))
- );
- });
- }
-
- // ── WS 层 Ping/Pong 活性检测 ──────────────────────────────────────────────
-
- _startPing() {
- this._clearPing();
- this._pingTimer = setInterval(() => {
- if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
-
- if (this._awaitingPong) {
- log.warn('clawd', 'Pong 超时,连接可能已死,主动关闭重连');
- this._ws.terminate();
- return;
- }
-
- this._awaitingPong = true;
- try { this._ws.ping(); } catch (_) {}
- }, PING_INTERVAL_MS);
- }
-
- _clearPing() {
- if (this._pingTimer) {
- clearInterval(this._pingTimer);
- this._pingTimer = null;
- }
- this._awaitingPong = false;
- }
-
- // ── AP 模式网络监视(拔网线后 ≤5s 感知)────────────────────────────────────
-
- _startNetMonitor() {
- this._clearNetMonitor();
- this._netMonitorTimer = setInterval(() => {
- if (!this._provisionMgr || !this._provisionMgr.isApMode()) return;
- if (hasInternet()) return;
- // AP 模式 + 无网,但 WS 还"活着" → 立即终止,触发 close → _connect() 进入 5s 轮询
- log.warn('clawd', 'AP 模式检测到网络断开,主动关闭 WS');
- led.display.showAP();
- if (this._ws) this._ws.terminate();
- }, NET_MONITOR_MS);
- }
-
- _clearNetMonitor() {
- if (this._netMonitorTimer) {
- clearInterval(this._netMonitorTimer);
- this._netMonitorTimer = null;
- }
- }
-
- // ── 发送 connect ─────────────────────────────────────────────────────────────
-
- _sendConnect() {
- const msg = {
- type: 'connect',
- box_id: this._boxId,
- claw_id: this._cfg.claw_id ?? null,
- token: this._cfg.token ?? null,
- ...this._dashInfo,
- };
- this._send(msg);
- }
-
- // ── 消息处理 ─────────────────────────────────────────────────────────────────
-
- _handleMessage(msg) {
- switch (msg.type) {
- case 'connected':
- this._onConnected(msg);
- break;
- case 'heartbeat_ack':
- break;
- case 'status_update':
- this._applyStatus(msg);
- break;
- case 'error':
- log.error('clawd', `服务器错误: ${msg.msg}`);
- if (msg.msg === 'hardware_mismatch') {
- log.warn('clawd', '硬件指纹不符,清除凭证重新注册...');
- this._cfg.claw_id = null;
- this._cfg.token = null;
- config.save(this._cfg);
- } else if (msg.msg && msg.msg.includes('invalid')) {
- log.warn('clawd', '凭证无效,清除凭证重新注册...');
- this._cfg.claw_id = null;
- this._cfg.token = null;
- config.save(this._cfg);
- }
- break;
- default:
- log.warn('clawd', '未知消息类型:', msg.type);
- }
- }
-
- _onConnected(msg) {
- const isNew = !this._cfg.claw_id;
-
- this._cfg.claw_id = msg.claw_id;
- this._cfg.token = msg.token;
- config.save(this._cfg);
-
- if (isNew) {
- log.info('clawd', `注册成功!claw_id = ${msg.claw_id}`);
- }
-
- this._applyStatus(msg);
-
- if (msg.frp && msg.frp.server && msg.frp.auth_token) {
- this._frpc.start(msg.claw_id, msg.frp).catch(e => {
- log.error('frpc', '启动失败:', e.message);
- });
- }
-
- this._startHeartbeat();
- }
-
- _applyStatus(msg) {
- if (msg.status === 'inactive') {
- led.status.setSetup();
- led.display.showPin(msg.pin);
- const id = String(this._cfg.claw_id || '').padEnd(6);
- const pin = String(msg.pin || '');
- log.info('clawd', '');
- log.info('clawd', '╔════════════════════════════════════╗');
- log.info('clawd', `║ Claw ID : ${id} ║`);
- log.info('clawd', `║ PIN 码 : ${pin} ║`);
- log.info('clawd', '║ 请在网页前台「添加设备」中输入 ║');
- log.info('clawd', '╚════════════════════════════════════╝');
- log.info('clawd', '');
- log.info('clawd', '等待激活,心跳正常运行...');
- } else {
- led.status.setApps();
- led.display.showTime();
- log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
- }
- }
-
- // ── 心跳 ────────────────────────────────────────────────────────────────────
-
- _startHeartbeat() {
- this._clearHeartbeat();
- this._sendHeartbeat();
- this._hbTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
- }
-
- async _sendHeartbeat() {
- if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
- try {
- this._hbCount++;
-
- // 每 30 次心跳(约 5 分钟)刷新一次 dashboard 信息
- if (this._hbCount % 30 === 0) {
- const freshInfo = await getDashboardInfo().catch(() => null);
- if (freshInfo && Object.keys(freshInfo).length > 0) {
- this._dashInfo = freshInfo;
- }
- }
-
- // 每 METRICS_EVERY_N 次心跳(30 秒)采集一次指标,其余发轻量心跳
- const msg = {
- type: 'heartbeat',
- claw_id: this._cfg.claw_id,
- token: this._cfg.token,
- ...this._dashInfo,
- };
- if (this._hbCount % METRICS_EVERY_N === 0) {
- msg.metrics = await collect();
- }
- this._send(msg);
- } catch (e) {
- log.error('clawd', '心跳发送失败:', e.message);
- }
- }
-
- _clearHeartbeat() {
- if (this._hbTimer) {
- clearInterval(this._hbTimer);
- this._hbTimer = null;
- }
- }
-
- // ── 工具 ────────────────────────────────────────────────────────────────────
-
- _send(obj) {
- if (this._ws && this._ws.readyState === WebSocket.OPEN) {
- this._ws.send(JSON.stringify(obj));
- }
- }
-
- // ── systemd Watchdog ────────────────────────────────────────────────────────
-
- _startSdNotify() {
- if (!SD_NOTIFY_INTERVAL) return;
-
- log.debug('clawd', `systemd watchdog 启用,通知间隔 ${SD_NOTIFY_INTERVAL}ms`);
- this._sdNotify('READY=1');
- this._sdTimer = setInterval(() => this._sdNotify('WATCHDOG=1'), SD_NOTIFY_INTERVAL);
- }
-
- _sdNotify(msg) {
- if (!process.env.NOTIFY_SOCKET) return;
- try {
- execFileSync('systemd-notify', ['--pid=' + process.pid, msg], { timeout: 2000 });
- } catch (_) {
- // systemd-notify 不可用时静默忽略
- }
- }
-}
-
-module.exports = { ClawClient };
+'use strict';
+
+const WebSocket = require('ws');
+const { execFileSync } = require('child_process');
+const config = require('./config');
+const log = require('./logger');
+const { getBoxId } = require('./fingerprint');
+const { collect } = require('./metrics');
+const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新
+const { ProvisionManager } = require('./provisioning');
+const { hasInternet, getLocalIps } = require('./network');
+const led = require('./led');
+
+const MAX_BACKOFF_MS = 60_000;
+const PONG_TIMEOUT_MS = 8_000;
+const PING_INTERVAL_MS = 10_000;
+const NET_MONITOR_MS = 5_000; // AP 模式网络监视间隔
+const HEARTBEAT_INTERVAL_MS = 10_000; // 心跳间隔:10 秒,用于快速感知网络状态
+const METRICS_EVERY_N = 3; // 每 N 次心跳采集一次指标(= 30 秒)
+
+// systemd watchdog: 如果 WatchdogSec 存在,定期发 WATCHDOG=1
+const SD_WATCHDOG_USEC = parseInt(process.env.WATCHDOG_USEC || '0', 10);
+const SD_NOTIFY_INTERVAL = SD_WATCHDOG_USEC > 0
+ ? Math.floor(SD_WATCHDOG_USEC / 2 / 1000) // 半周期通知(μs → ms)
+ : 0;
+
+class ClawClient {
+ constructor() {
+ this._cfg = config.load();
+ this._boxId = getBoxId();
+ this._ws = null;
+ this._hbTimer = null;
+ this._backoff = 1_000;
+ this._stopped = false;
+ this._frpc = new FrpcManager();
+ this._dashInfo = {};
+ this._hbCount = 0; // 心跳计数器,用于定期刷新 dashboard 信息
+ this._externalIp = null; // 外网 IP(连接时查询一次,用于服务端地理位置解析)
+
+ // WS 层活性检测
+ this._pingTimer = null;
+ this._awaitingPong = false;
+
+ // AP 模式网络监视(WS 连通后每 5s 检查,断网立即 terminate)
+ this._netMonitorTimer = null;
+
+ // WS 连续失败计数(open 时清零)
+ this._wsFailCount = 0;
+ // 是否曾经成功连接过(首次成功前不显示 Err0/AP)
+ this._hasEverConnected = false;
+ // 最近一次 WS 错误是否是证书时间问题(NTP 未同步)
+ this._certTimeError = false;
+
+ // systemd watchdog
+ this._sdTimer = null;
+
+ this._setupGlobalHandlers();
+ }
+
+ // ── 全局异常兜底 ─────────────────────────────────────────────────────────────
+
+ _setupGlobalHandlers() {
+ process.on('uncaughtException', (err) => {
+ log.error('process', '未捕获异常:', err);
+ // 给日志写盘的时间,然后退出让 systemd 重启
+ setTimeout(() => process.exit(1), 1000);
+ });
+
+ process.on('unhandledRejection', (reason) => {
+ log.error('process', '未处理的 Promise 拒绝:', reason);
+ });
+ }
+
+ // ── 生命周期 ─────────────────────────────────────────────────────────────────
+
+ async start() {
+ log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
+
+ // 启动时全灭,WS 连接后由 _applyStatus() 按实际状态设置
+ led.status.off();
+
+ this._startSdNotify();
+
+ // 启动 AP 配网管理器(等待已保存 WiFi 自动连接,超时再开 AP)
+ this._provisionMgr = new ProvisionManager(this._cfg.claw_id);
+ this._connectionStarted = false;
+
+ // 网络就绪时连接云端(仅触发一次)
+ this._provisionMgr.on('network-ready', () => {
+ if (!this._connectionStarted) {
+ this._connectionStarted = true;
+ this._proceedWithConnection().catch(e => {
+ log.error('clawd', '连接启动失败:', e.message);
+ });
+ }
+ });
+
+ await this._provisionMgr.start();
+
+ // start() 返回后,如果已有网络且尚未启动连接
+ if (hasInternet() && !this._connectionStarted) {
+ this._connectionStarted = true;
+ await this._proceedWithConnection();
+ } else if (!hasInternet()) {
+ log.info('clawd', '等待网络就绪(WiFi 配网或网线接入)...');
+ }
+ }
+
+ async _proceedWithConnection() {
+ const [dashInfo] = await Promise.all([
+ getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }),
+ startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
+ ]);
+ this._dashInfo = dashInfo || {};
+
+ // 查询外网 IP(用于服务端地理位置解析),失败不阻断连接
+ try {
+ const https = require('https');
+ this._externalIp = await new Promise((resolve) => {
+ const req = https.get('https://api.ipify.org?format=json', { timeout: 5000 }, (res) => {
+ let body = '';
+ res.on('data', d => { body += d; });
+ res.on('end', () => {
+ try { resolve(JSON.parse(body).ip || null); } catch { resolve(null); }
+ });
+ });
+ req.on('error', () => resolve(null));
+ req.on('timeout', () => { req.destroy(); resolve(null); });
+ });
+ if (this._externalIp) log.info('clawd', `外网 IP: ${this._externalIp}`);
+ } catch (e) {
+ log.warn('clawd', '外网 IP 查询失败:', e.message);
+ this._externalIp = null;
+ }
+
+ this._connect();
+ }
+
+ stop() {
+ this._stopped = true;
+ this._clearHeartbeat();
+ this._clearPing();
+ this._clearNetMonitor();
+ 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();
+ led.status.off(); // 进程退出,两灯全灭
+ this._sdNotify('STOPPING=1');
+ log.info('clawd', '已停止');
+ log.close();
+ }
+
+ // ── WebSocket 连接 ──────────────────────────────────────────────────────────
+
+ _connect() {
+ if (this._stopped) return;
+
+ // AP 模式 + 无网:不建立 WS,5s 后重新检查网络
+ if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet()) {
+ led.display.showAP();
+ log.info('clawd', 'AP 模式无网络,5s 后重新检查...');
+ this._backoff = 1_000; // 有网时立即快速重连
+ this._wsFailCount = 0; // 不计入失败
+ setTimeout(() => this._connect(), 5_000);
+ return;
+ }
+
+ if (!this._hasEverConnected || this._wsFailCount < 3) led.display.showConn();
+ log.info('clawd', `正在连接 ${this._cfg.server} ...`);
+ const ws = new WebSocket(this._cfg.server, {
+ handshakeTimeout: 10_000,
+ });
+ this._ws = ws;
+
+ ws.on('open', () => {
+ log.info('clawd', 'WebSocket 已连接');
+ this._backoff = 1_000;
+ this._wsFailCount = 0; // 连接成功,重置失败计数
+ this._hasEverConnected = true; // 标记已成功连接过
+ this._sendConnect();
+ this._startPing();
+ this._startNetMonitor();
+ // 显示由 _onConnected 根据 status 设置,不在此处提前 showTime
+ });
+
+ ws.on('message', (data) => {
+ try {
+ this._handleMessage(JSON.parse(data.toString()));
+ } catch (e) {
+ log.error('clawd', '消息解析失败:', e.message);
+ }
+ });
+
+ ws.on('pong', () => {
+ this._awaitingPong = false;
+ });
+
+ ws.on('close', (code, reason) => {
+ this._clearHeartbeat();
+ this._clearPing();
+ this._clearNetMonitor();
+ if (!this._stopped) {
+ this._wsFailCount++;
+ log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount},${this._backoff / 1000}s 后重连...`);
+ if (this._hasEverConnected && this._wsFailCount >= 3) {
+ const inAp = this._provisionMgr && this._provisionMgr.isApMode();
+ if (inAp || !hasInternet()) {
+ led.display.showAP(); // AP 模式 或 无网
+ } else {
+ led.display.showErr0(); // STA 模式 + 有网 但 VPS 不可达
+ }
+ }
+ if (this._certTimeError) {
+ // NTP 未同步:固定 5s 重试,等时钟校正
+ this._certTimeError = false;
+ this._backoff = 5_000;
+ log.warn('clawd', '证书时间错误(NTP 未同步),5s 后重试...');
+ } else {
+ this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS);
+ }
+ setTimeout(() => this._connect(), this._backoff);
+ }
+ });
+
+ ws.on('error', (err) => {
+ log.error('clawd', '连接错误:', err.message);
+ // 证书时间错误:NTP 未同步,close 后用固定短间隔重试,不做指数退避
+ this._certTimeError = !!(
+ err.code === 'CERT_NOT_YET_VALID' ||
+ (err.message && err.message.includes('not yet valid'))
+ );
+ });
+ }
+
+ // ── WS 层 Ping/Pong 活性检测 ──────────────────────────────────────────────
+
+ _startPing() {
+ this._clearPing();
+ this._pingTimer = setInterval(() => {
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
+
+ if (this._awaitingPong) {
+ log.warn('clawd', 'Pong 超时,连接可能已死,主动关闭重连');
+ this._ws.terminate();
+ return;
+ }
+
+ this._awaitingPong = true;
+ try { this._ws.ping(); } catch (_) {}
+ }, PING_INTERVAL_MS);
+ }
+
+ _clearPing() {
+ if (this._pingTimer) {
+ clearInterval(this._pingTimer);
+ this._pingTimer = null;
+ }
+ this._awaitingPong = false;
+ }
+
+ // ── AP 模式网络监视(拔网线后 ≤5s 感知)────────────────────────────────────
+
+ _startNetMonitor() {
+ this._clearNetMonitor();
+ this._netMonitorTimer = setInterval(() => {
+ if (!this._provisionMgr || !this._provisionMgr.isApMode()) return;
+ if (hasInternet()) return;
+ // AP 模式 + 无网,但 WS 还"活着" → 立即终止,触发 close → _connect() 进入 5s 轮询
+ log.warn('clawd', 'AP 模式检测到网络断开,主动关闭 WS');
+ led.display.showAP();
+ if (this._ws) this._ws.terminate();
+ }, NET_MONITOR_MS);
+ }
+
+ _clearNetMonitor() {
+ if (this._netMonitorTimer) {
+ clearInterval(this._netMonitorTimer);
+ this._netMonitorTimer = null;
+ }
+ }
+
+ // ── 发送 connect ─────────────────────────────────────────────────────────────
+
+ _sendConnect() {
+ const msg = {
+ type: 'connect',
+ box_id: this._boxId,
+ claw_id: this._cfg.claw_id ?? null,
+ token: this._cfg.token ?? null,
+ local_ip: getLocalIps(),
+ external_ip: this._externalIp ?? null,
+ ...this._dashInfo,
+ };
+ this._send(msg);
+ }
+
+ // ── 消息处理 ─────────────────────────────────────────────────────────────────
+
+ _handleMessage(msg) {
+ switch (msg.type) {
+ case 'connected':
+ this._onConnected(msg);
+ break;
+ case 'heartbeat_ack':
+ break;
+ case 'status_update':
+ this._applyStatus(msg);
+ break;
+ case 'error':
+ log.error('clawd', `服务器错误: ${msg.msg}`);
+ if (msg.msg === 'hardware_mismatch') {
+ log.warn('clawd', '硬件指纹不符,清除凭证重新注册...');
+ this._cfg.claw_id = null;
+ this._cfg.token = null;
+ config.save(this._cfg);
+ } else if (msg.msg && msg.msg.includes('invalid')) {
+ log.warn('clawd', '凭证无效,清除凭证重新注册...');
+ this._cfg.claw_id = null;
+ this._cfg.token = null;
+ config.save(this._cfg);
+ }
+ break;
+ default:
+ log.warn('clawd', '未知消息类型:', msg.type);
+ }
+ }
+
+ _onConnected(msg) {
+ const isNew = !this._cfg.claw_id;
+
+ this._cfg.claw_id = msg.claw_id;
+ this._cfg.token = msg.token;
+ config.save(this._cfg);
+
+ if (isNew) {
+ log.info('clawd', `注册成功!claw_id = ${msg.claw_id}`);
+ }
+
+ this._applyStatus(msg);
+
+ if (msg.frp && msg.frp.server && msg.frp.auth_token) {
+ this._frpc.start(msg.claw_id, msg.frp).catch(e => {
+ log.error('frpc', '启动失败:', e.message);
+ });
+ }
+
+ this._startHeartbeat();
+ }
+
+ _applyStatus(msg) {
+ if (msg.status === 'inactive') {
+ led.status.setSetup();
+ led.display.showPin(msg.pin);
+ const id = String(this._cfg.claw_id || '').padEnd(6);
+ const pin = String(msg.pin || '');
+ log.info('clawd', '');
+ log.info('clawd', '╔════════════════════════════════════╗');
+ log.info('clawd', `║ Claw ID : ${id} ║`);
+ log.info('clawd', `║ PIN 码 : ${pin} ║`);
+ log.info('clawd', '║ 请在网页前台「添加设备」中输入 ║');
+ log.info('clawd', '╚════════════════════════════════════╝');
+ log.info('clawd', '');
+ log.info('clawd', '等待激活,心跳正常运行...');
+ } else {
+ led.status.setApps();
+ led.display.showTime();
+ log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
+ }
+ }
+
+ // ── 心跳 ────────────────────────────────────────────────────────────────────
+
+ _startHeartbeat() {
+ this._clearHeartbeat();
+ this._sendHeartbeat();
+ this._hbTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
+ }
+
+ async _sendHeartbeat() {
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
+ try {
+ this._hbCount++;
+
+ // 每 30 次心跳(约 5 分钟)刷新一次 dashboard 信息
+ if (this._hbCount % 30 === 0) {
+ const freshInfo = await getDashboardInfo().catch(() => null);
+ if (freshInfo && Object.keys(freshInfo).length > 0) {
+ this._dashInfo = freshInfo;
+ }
+ }
+
+ // 每 METRICS_EVERY_N 次心跳(30 秒)采集一次指标,其余发轻量心跳
+ const msg = {
+ type: 'heartbeat',
+ claw_id: this._cfg.claw_id,
+ token: this._cfg.token,
+ ...this._dashInfo,
+ };
+ if (this._hbCount % METRICS_EVERY_N === 0) {
+ msg.metrics = await collect();
+ }
+ this._send(msg);
+ } catch (e) {
+ log.error('clawd', '心跳发送失败:', e.message);
+ }
+ }
+
+ _clearHeartbeat() {
+ if (this._hbTimer) {
+ clearInterval(this._hbTimer);
+ this._hbTimer = null;
+ }
+ }
+
+ // ── 工具 ────────────────────────────────────────────────────────────────────
+
+ _send(obj) {
+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
+ this._ws.send(JSON.stringify(obj));
+ }
+ }
+
+ // ── systemd Watchdog ────────────────────────────────────────────────────────
+
+ _startSdNotify() {
+ if (!SD_NOTIFY_INTERVAL) return;
+
+ log.debug('clawd', `systemd watchdog 启用,通知间隔 ${SD_NOTIFY_INTERVAL}ms`);
+ this._sdNotify('READY=1');
+ this._sdTimer = setInterval(() => this._sdNotify('WATCHDOG=1'), SD_NOTIFY_INTERVAL);
+ }
+
+ _sdNotify(msg) {
+ if (!process.env.NOTIFY_SOCKET) return;
+ try {
+ execFileSync('systemd-notify', ['--pid=' + process.pid, msg], { timeout: 2000 });
+ } catch (_) {
+ // systemd-notify 不可用时静默忽略
+ }
+ }
+}
+
+module.exports = { ClawClient };
diff --git a/lib/led.js b/lib/led.js
index 8d8f9c4..2119311 100644
--- a/lib/led.js
+++ b/lib/led.js
@@ -1,211 +1,211 @@
-'use strict';
-
-const fs = require('fs');
-const { execSync } = require('child_process');
-const log = require('./logger');
-
-/**
- * 前面板指示灯控制
- *
- * WiFi 灯 (b5): 1 = 亮, 0 = 灭(正逻辑)
- * - WiFi 已连接且互联网畅通 → 常亮
- * - WiFi 连接中(正在尝试) → 闪烁
- * - WiFi 未连接 / 无互联网 → 熄灭
- *
- * SETUP 灯 (b2): 0 = 亮, 1 = 灭(反逻辑,与 APPS 互斥)
- * APPS 灯 (b1): 0 = 亮, 1 = 灭(反逻辑,与 SETUP 互斥)
- * - claw 未激活 → SETUP 亮,APPS 灭
- * - claw 已激活 → APPS 亮,SETUP 灭
- */
-
-const LED_PATH = process.env.CLAWD_LED_PATH || '/sys/devices/platform/openvfd/attr/b5';
-const SETUP_LED_PATH = '/sys/devices/platform/openvfd/attr/b1'; // 物理 SETUP 灯
-const APPS_LED_PATH = '/sys/devices/platform/openvfd/attr/b2'; // 物理 APPS 灯
-const BLINK_INTERVAL_MS = 500; // 闪烁间隔(ms)
-
-class WifiLed {
- constructor() {
- this._blinkTimer = null;
- this._blinkState = false;
- this._current = null; // 'on' | 'off' | 'blink'
- }
-
- /** 常亮 */
- on() {
- if (this._current === 'on') return;
- this._stopBlink();
- this._write(1);
- this._current = 'on';
- log.info('led', 'WiFi 指示灯 → 常亮');
- }
-
- /** 熄灭 */
- off() {
- if (this._current === 'off') return;
- this._stopBlink();
- this._write(0);
- this._current = 'off';
- log.info('led', 'WiFi 指示灯 → 熄灭');
- }
-
- /** 闪烁(连接中) */
- blink(intervalMs = BLINK_INTERVAL_MS) {
- if (this._current === 'blink') return;
- this._stopBlink();
- this._blinkState = true;
- this._write(1);
- this._blinkTimer = setInterval(() => {
- this._blinkState = !this._blinkState;
- this._write(this._blinkState ? 1 : 0);
- }, intervalMs);
- this._current = 'blink';
- log.info('led', 'WiFi 指示灯 → 闪烁');
- }
-
- /** 释放资源,关灯 */
- destroy() {
- this._stopBlink();
- this._write(0);
- this._current = 'off';
- }
-
- // ── 内部 ──────────────────────────────────────────────────────────────────
-
- _stopBlink() {
- if (this._blinkTimer) {
- clearInterval(this._blinkTimer);
- this._blinkTimer = null;
- }
- }
-
- _write(val) {
- try {
- fs.writeFileSync(LED_PATH, String(val));
- } catch (e) {
- log.warn('led', `写入失败 (${LED_PATH}): ${e.message}`);
- }
- }
-}
-
-// ── VFD 显示屏 ────────────────────────────────────────────────────────────────
-
-const DISPLAY_PATH = '/sys/devices/platform/openvfd/attr/led';
-
-/**
- * VFD 显示屏控制。
- * #m3 手动模式,显示指定文字
- * #s1 系统时钟模式,显示当前时间
- */
-class Display {
- constructor() {
- this._blinkTimer = null;
- }
-
- /** 网络断开 / AP 模式 → 显示 "AP " */
- showAP() {
- this._stopBlink();
- this._write('#m3AP ');
- log.info('display', '显示屏 → AP');
- }
-
- /** WS 连接中(失败次数 < 3)→ 显示 "Conn" 闪烁 */
- showConn() {
- this._stopBlink();
- this._write('#m3Conn');
- log.info('display', '显示屏 → Conn(闪烁)');
- let visible = true;
- const blink = () => {
- visible = !visible;
- this._write(visible ? '#m3Conn' : '#c1');
- this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
- };
- this._blinkTimer = setTimeout(blink, 1000);
- }
-
- /** 网络正常但 VPS 不可达 → 显示 "Err0" */
- showErr0() {
- this._stopBlink();
- this._write('#m3Err0');
- log.info('display', '显示屏 → Err0');
- }
-
- /** 网络已连接 → 显示时间 */
- showTime() {
- this._stopBlink();
- this._write('#s1');
- log.info('display', '显示屏 → 时间');
- }
-
- /** 未激活 + 连网 → 显示 PIN 码(4 位数字)并闪烁 */
- showPin(pin) {
- this._stopBlink();
- const s = String(pin || '').padStart(4, '0').slice(-4);
- this._write('#m2' + s);
- log.info('display', `显示屏 → PIN: ${s}(闪烁)`);
- // 亮 1s → 灭 0.5s → 循环
- let visible = true;
- const blink = () => {
- visible = !visible;
- this._write(visible ? '#m2' + s : '#c1');
- this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
- };
- this._blinkTimer = setTimeout(blink, 1000);
- }
-
- _stopBlink() {
- if (this._blinkTimer) {
- clearTimeout(this._blinkTimer);
- clearInterval(this._blinkTimer);
- this._blinkTimer = null;
- }
- }
-
- _write(val) {
- try {
- execSync(`echo "${val}" | tee ${DISPLAY_PATH} > /dev/null`, { timeout: 3000 });
- } catch (e) {
- log.warn('display', `写入失败: ${e.message}`);
- }
- }
-}
-
-// ── SETUP / APPS 状态灯 ───────────────────────────────────────────────────────
-
-/**
- * SETUP 灯(b2)与 APPS 灯(b1)互斥控制。
- * 两灯均为反逻辑:写 0 = 亮,写 1 = 灭。
- */
-class StatusLed {
- /** claw 未激活 → SETUP 亮,APPS 灭 */
- setSetup() {
- this._write(SETUP_LED_PATH, 0); // SETUP 亮
- this._write(APPS_LED_PATH, 1); // APPS 灭
- log.info('led', '状态灯 → SETUP(未激活)');
- }
-
- /** claw 已激活 → APPS 亮,SETUP 灭 */
- setApps() {
- this._write(SETUP_LED_PATH, 1); // SETUP 灭
- this._write(APPS_LED_PATH, 0); // APPS 亮
- log.info('led', '状态灯 → APPS(已激活)');
- }
-
- /** 两灯全灭(进程退出时调用) */
- off() {
- this._write(SETUP_LED_PATH, 1);
- this._write(APPS_LED_PATH, 1);
- }
-
- _write(path, val) {
- try {
- fs.writeFileSync(path, String(val));
- } catch (e) {
- log.warn('led', `写入失败 (${path}): ${e.message}`);
- }
- }
-}
-
-// 全局单例,整个进程共用
-module.exports = new WifiLed();
-module.exports.status = new StatusLed();
-module.exports.display = new Display();
+'use strict';
+
+const fs = require('fs');
+const { execSync } = require('child_process');
+const log = require('./logger');
+
+/**
+ * 前面板指示灯控制
+ *
+ * WiFi 灯 (b5): 1 = 亮, 0 = 灭(正逻辑)
+ * - WiFi 已连接且互联网畅通 → 常亮
+ * - WiFi 连接中(正在尝试) → 闪烁
+ * - WiFi 未连接 / 无互联网 → 熄灭
+ *
+ * SETUP 灯 (b2): 0 = 亮, 1 = 灭(反逻辑,与 APPS 互斥)
+ * APPS 灯 (b1): 0 = 亮, 1 = 灭(反逻辑,与 SETUP 互斥)
+ * - claw 未激活 → SETUP 亮,APPS 灭
+ * - claw 已激活 → APPS 亮,SETUP 灭
+ */
+
+const LED_PATH = process.env.CLAWD_LED_PATH || '/sys/devices/platform/openvfd/attr/b5';
+const SETUP_LED_PATH = '/sys/devices/platform/openvfd/attr/b1'; // 物理 SETUP 灯
+const APPS_LED_PATH = '/sys/devices/platform/openvfd/attr/b2'; // 物理 APPS 灯
+const BLINK_INTERVAL_MS = 500; // 闪烁间隔(ms)
+
+class WifiLed {
+ constructor() {
+ this._blinkTimer = null;
+ this._blinkState = false;
+ this._current = null; // 'on' | 'off' | 'blink'
+ }
+
+ /** 常亮 */
+ on() {
+ if (this._current === 'on') return;
+ this._stopBlink();
+ this._write(1);
+ this._current = 'on';
+ log.info('led', 'WiFi 指示灯 → 常亮');
+ }
+
+ /** 熄灭 */
+ off() {
+ if (this._current === 'off') return;
+ this._stopBlink();
+ this._write(0);
+ this._current = 'off';
+ log.info('led', 'WiFi 指示灯 → 熄灭');
+ }
+
+ /** 闪烁(连接中) */
+ blink(intervalMs = BLINK_INTERVAL_MS) {
+ if (this._current === 'blink') return;
+ this._stopBlink();
+ this._blinkState = true;
+ this._write(1);
+ this._blinkTimer = setInterval(() => {
+ this._blinkState = !this._blinkState;
+ this._write(this._blinkState ? 1 : 0);
+ }, intervalMs);
+ this._current = 'blink';
+ log.info('led', 'WiFi 指示灯 → 闪烁');
+ }
+
+ /** 释放资源,关灯 */
+ destroy() {
+ this._stopBlink();
+ this._write(0);
+ this._current = 'off';
+ }
+
+ // ── 内部 ──────────────────────────────────────────────────────────────────
+
+ _stopBlink() {
+ if (this._blinkTimer) {
+ clearInterval(this._blinkTimer);
+ this._blinkTimer = null;
+ }
+ }
+
+ _write(val) {
+ try {
+ fs.writeFileSync(LED_PATH, String(val));
+ } catch (e) {
+ log.warn('led', `写入失败 (${LED_PATH}): ${e.message}`);
+ }
+ }
+}
+
+// ── VFD 显示屏 ────────────────────────────────────────────────────────────────
+
+const DISPLAY_PATH = '/sys/devices/platform/openvfd/attr/led';
+
+/**
+ * VFD 显示屏控制。
+ * #m3 手动模式,显示指定文字
+ * #s1 系统时钟模式,显示当前时间
+ */
+class Display {
+ constructor() {
+ this._blinkTimer = null;
+ }
+
+ /** 网络断开 / AP 模式 → 显示 "AP " */
+ showAP() {
+ this._stopBlink();
+ this._write('#m3AP ');
+ log.info('display', '显示屏 → AP');
+ }
+
+ /** WS 连接中(失败次数 < 3)→ 显示 "Conn" 闪烁 */
+ showConn() {
+ this._stopBlink();
+ this._write('#m3Conn');
+ log.info('display', '显示屏 → Conn(闪烁)');
+ let visible = true;
+ const blink = () => {
+ visible = !visible;
+ this._write(visible ? '#m3Conn' : '#c1');
+ this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
+ };
+ this._blinkTimer = setTimeout(blink, 1000);
+ }
+
+ /** 网络正常但 VPS 不可达 → 显示 "Err0" */
+ showErr0() {
+ this._stopBlink();
+ this._write('#m3Err0');
+ log.info('display', '显示屏 → Err0');
+ }
+
+ /** 网络已连接 → 显示时间 */
+ showTime() {
+ this._stopBlink();
+ this._write('#s1');
+ log.info('display', '显示屏 → 时间');
+ }
+
+ /** 未激活 + 连网 → 显示 PIN 码(4 位数字)并闪烁 */
+ showPin(pin) {
+ this._stopBlink();
+ const s = String(pin || '').padStart(4, '0').slice(-4);
+ this._write('#m2' + s);
+ log.info('display', `显示屏 → PIN: ${s}(闪烁)`);
+ // 亮 1s → 灭 0.5s → 循环
+ let visible = true;
+ const blink = () => {
+ visible = !visible;
+ this._write(visible ? '#m2' + s : '#c1');
+ this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
+ };
+ this._blinkTimer = setTimeout(blink, 1000);
+ }
+
+ _stopBlink() {
+ if (this._blinkTimer) {
+ clearTimeout(this._blinkTimer);
+ clearInterval(this._blinkTimer);
+ this._blinkTimer = null;
+ }
+ }
+
+ _write(val) {
+ try {
+ execSync(`echo "${val}" | tee ${DISPLAY_PATH} > /dev/null`, { timeout: 3000 });
+ } catch (e) {
+ log.warn('display', `写入失败: ${e.message}`);
+ }
+ }
+}
+
+// ── SETUP / APPS 状态灯 ───────────────────────────────────────────────────────
+
+/**
+ * SETUP 灯(b2)与 APPS 灯(b1)互斥控制。
+ * 两灯均为反逻辑:写 0 = 亮,写 1 = 灭。
+ */
+class StatusLed {
+ /** claw 未激活 → SETUP 亮,APPS 灭 */
+ setSetup() {
+ this._write(SETUP_LED_PATH, 0); // SETUP 亮
+ this._write(APPS_LED_PATH, 1); // APPS 灭
+ log.info('led', '状态灯 → SETUP(未激活)');
+ }
+
+ /** claw 已激活 → APPS 亮,SETUP 灭 */
+ setApps() {
+ this._write(SETUP_LED_PATH, 1); // SETUP 灭
+ this._write(APPS_LED_PATH, 0); // APPS 亮
+ log.info('led', '状态灯 → APPS(已激活)');
+ }
+
+ /** 两灯全灭(进程退出时调用) */
+ off() {
+ this._write(SETUP_LED_PATH, 1);
+ this._write(APPS_LED_PATH, 1);
+ }
+
+ _write(path, val) {
+ try {
+ fs.writeFileSync(path, String(val));
+ } catch (e) {
+ log.warn('led', `写入失败 (${path}): ${e.message}`);
+ }
+ }
+}
+
+// 全局单例,整个进程共用
+module.exports = new WifiLed();
+module.exports.status = new StatusLed();
+module.exports.display = new Display();
diff --git a/lib/network.js b/lib/network.js
index a89114b..19fca75 100644
--- a/lib/network.js
+++ b/lib/network.js
@@ -2,6 +2,7 @@
const { execSync } = require('child_process');
const fs = require('fs');
+const os = require('os');
const log = require('./logger');
const AP_SSID_PREFIX = 'ClawBox-';
@@ -223,6 +224,29 @@ function isWifiStaConnected() {
return false;
}
+/**
+ * 获取本机所有非回环 IPv4 地址,逗号拼接返回
+ * 例:'192.168.1.100' 或 '192.168.1.100,10.0.0.5'
+ */
+function getLocalIps() {
+ try {
+ const ifaces = os.networkInterfaces();
+ const ips = [];
+ for (const [name, addrs] of Object.entries(ifaces)) {
+ if (!addrs) continue;
+ for (const addr of addrs) {
+ if (addr.family === 'IPv4' && !addr.internal) {
+ ips.push(addr.address);
+ }
+ }
+ }
+ return ips.length > 0 ? ips.join(',') : null;
+ } catch (e) {
+ log.warn('network', '获取本机 IP 失败:', e.message);
+ return null;
+ }
+}
+
module.exports = {
hasInternet,
hasWiredCarrier,
@@ -234,4 +258,5 @@ module.exports = {
startAP,
stopAP,
AP_IP,
+ getLocalIps,
};
diff --git a/lib/provisioning.js b/lib/provisioning.js
index b6ced86..4dfde9b 100644
--- a/lib/provisioning.js
+++ b/lib/provisioning.js
@@ -1,240 +1,240 @@
-'use strict';
-
-const EventEmitter = require('events');
-const log = require('./logger');
-const { hasInternet, hasSavedWifiConnection, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, AP_IP } = require('./network');
-const { DnsHijack } = require('./dns-hijack');
-const { CaptiveServer } = require('./captive-server');
-const led = require('./led');
-
-const MONITOR_INTERVAL_MS = 30_000;
-const BOOT_WAIT_MAX_MS = 20_000; // 等待 NM 自动连接的最大时间
-const BOOT_POLL_MS = 2_000; // 轮询间隔
-
-/**
- * AP 常驻配网管理器。
- *
- * 规则:
- * - 启动时:有已保存 WiFi 配置 → 等 NM 自动连接(最多 20 秒)
- * - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页
- * - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP
- * - 运行中 WiFi 断开 → 自动重新开 AP
- * - WiFi 已连接 → AP 关闭
- */
-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;
- }
-
- /** 是否正处于 AP 模式(WiFi 热点广播中) */
- isApMode() { return this._state === 'ap'; }
-
- async start() {
- led.off(); // 初始状态:灭灯
-
- // WiFi STA 已连接 → 直接进入 STA 模式
- if (isWifiStaConnected()) {
- this._state = 'sta';
- log.info('provision', 'WiFi STA 已连接,AP 不启动');
- this._emitNetworkReady();
- this._startMonitor();
- return;
- }
-
- // 有线有网 → 立即通知 WS 连接,后台异步设置 AP 供 WiFi 配网
- if (hasInternet()) {
- log.info('provision', '有线网络就绪,立即启动 WS,AP 后台准备中...');
- this._emitNetworkReady();
- setTimeout(() => {
- this._enterAP();
- this._startMonitor();
- }, 0);
- return;
- }
-
- // 无网:有已保存的 WiFi 配置 → 等 NM 自动连接(重启场景)
- if (hasSavedWifiConnection()) {
- log.info('provision', '发现已保存的 WiFi 配置,等待 NetworkManager 自动连接...');
- led.blink(); // 等待自动重连期间闪烁
- const connected = await this._waitForWifiConnect();
- if (connected) {
- this._state = 'sta';
- log.info('provision', 'WiFi 自动连接成功,AP 不启动');
- this._emitNetworkReady();
- this._startMonitor();
- return;
- }
- log.warn('provision', 'WiFi 自动连接超时,启动 AP');
- }
-
- // 没有已保存 WiFi 或等待超时 → 开 AP
- this._enterAP();
- this._startMonitor();
-
- if (hasInternet()) {
- this._emitNetworkReady();
- }
- }
-
- _emitNetworkReady() {
- if (hasInternet()) {
- // WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮)
- if (this._state === 'sta') led.on();
- this.emit('network-ready');
- } else {
- log.warn('provision', 'hasInternet() 返回 false,LED 保持熄灭');
- }
- }
-
- /**
- * 轮询等待 NM 自动连接 WiFi,最多等 BOOT_WAIT_MAX_MS
- */
- _waitForWifiConnect() {
- return new Promise(resolve => {
- let elapsed = 0;
- const timer = setInterval(() => {
- elapsed += BOOT_POLL_MS;
- if (isWifiStaConnected()) {
- clearInterval(timer);
- resolve(true);
- } else if (elapsed >= BOOT_WAIT_MAX_MS) {
- clearInterval(timer);
- resolve(false);
- }
- }, BOOT_POLL_MS);
- });
- }
-
- stop() {
- this._stopMonitor();
- this._stopAll();
- this._state = 'idle';
- led.destroy(); // 停止时关灯、释放闪烁定时器
- }
-
- // ── 进入 AP 模式 ─────────────────────────────────────────────────────────
-
- _enterAP() {
- if (this._state === 'ap') return;
-
- led.off(); // AP 模式:WiFi 未连接,灭灯
- if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP,有线时等 WS 连接后再定
-
- try {
- // AP 模式下无法扫描 WiFi,必须在开 AP 之前扫描并缓存
- log.info('provision', '扫描周边 WiFi...');
- this._cachedWifiList = scanWifi();
- log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络`);
-
- // 写 DNS 劫持配置(NM 启动热点时加载)
- this._dns = new DnsHijack();
- this._dns.start('wlan0', AP_IP);
-
- const ap = startAP(this._clawId);
-
- this._server = new CaptiveServer({
- clawId: this._clawId,
- cachedWifiList: this._cachedWifiList,
- 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}`);
- led.blink(); // 正在连接 → 闪烁
-
- this._stopAPServices();
-
- const result = connectWifi(ssid, password);
-
- if (result.success) {
- this._state = 'sta';
- log.info('provision', `WiFi 已连接: ${ssid}`);
- led.on(); // 连接成功 → 常亮
- this.emit('network-ready');
- return result;
- }
-
- log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`);
- this._enterAP(); // _enterAP 内部会调用 led.off()
- 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(); // 内部调用 led.off()
- return;
- }
-
- if (this._state === 'ap' && wifiUp) {
- log.info('provision', 'WiFi 已外部连接,关闭 AP');
- this._stopAPServices();
- this._state = 'sta';
- this.emit('network-ready');
- }
-
- // WiFi 灯:只在 STA 模式下反映互联网连通性;AP 模式下始终熄灭
- if (this._state === 'sta') {
- if (hasInternet()) {
- led.on();
- } else {
- led.off(); // WiFi 已连接但无互联网
- }
- }
- // AP 模式下 led 已在 _enterAP() 中熄灭,无需重复操作
- }, MONITOR_INTERVAL_MS);
- }
-
- _stopMonitor() {
- if (this._monitorTimer) {
- clearInterval(this._monitorTimer);
- this._monitorTimer = null;
- }
- }
-
- // ── 清理 ──────────────────────────────────────────────────────────────────
-
- _stopAPServices() {
- if (this._server) {
- this._server.stop();
- this._server = null;
- }
- if (this._dns) {
- this._dns.stop();
- this._dns = null;
- }
- stopAP();
- }
-
- _stopAll() {
- this._stopAPServices();
- }
-}
-
-module.exports = { ProvisionManager };
+'use strict';
+
+const EventEmitter = require('events');
+const log = require('./logger');
+const { hasInternet, hasSavedWifiConnection, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, AP_IP } = require('./network');
+const { DnsHijack } = require('./dns-hijack');
+const { CaptiveServer } = require('./captive-server');
+const led = require('./led');
+
+const MONITOR_INTERVAL_MS = 30_000;
+const BOOT_WAIT_MAX_MS = 20_000; // 等待 NM 自动连接的最大时间
+const BOOT_POLL_MS = 2_000; // 轮询间隔
+
+/**
+ * AP 常驻配网管理器。
+ *
+ * 规则:
+ * - 启动时:有已保存 WiFi 配置 → 等 NM 自动连接(最多 20 秒)
+ * - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页
+ * - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP
+ * - 运行中 WiFi 断开 → 自动重新开 AP
+ * - WiFi 已连接 → AP 关闭
+ */
+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;
+ }
+
+ /** 是否正处于 AP 模式(WiFi 热点广播中) */
+ isApMode() { return this._state === 'ap'; }
+
+ async start() {
+ led.off(); // 初始状态:灭灯
+
+ // WiFi STA 已连接 → 直接进入 STA 模式
+ if (isWifiStaConnected()) {
+ this._state = 'sta';
+ log.info('provision', 'WiFi STA 已连接,AP 不启动');
+ this._emitNetworkReady();
+ this._startMonitor();
+ return;
+ }
+
+ // 有线有网 → 立即通知 WS 连接,后台异步设置 AP 供 WiFi 配网
+ if (hasInternet()) {
+ log.info('provision', '有线网络就绪,立即启动 WS,AP 后台准备中...');
+ this._emitNetworkReady();
+ setTimeout(() => {
+ this._enterAP();
+ this._startMonitor();
+ }, 0);
+ return;
+ }
+
+ // 无网:有已保存的 WiFi 配置 → 等 NM 自动连接(重启场景)
+ if (hasSavedWifiConnection()) {
+ log.info('provision', '发现已保存的 WiFi 配置,等待 NetworkManager 自动连接...');
+ led.blink(); // 等待自动重连期间闪烁
+ const connected = await this._waitForWifiConnect();
+ if (connected) {
+ this._state = 'sta';
+ log.info('provision', 'WiFi 自动连接成功,AP 不启动');
+ this._emitNetworkReady();
+ this._startMonitor();
+ return;
+ }
+ log.warn('provision', 'WiFi 自动连接超时,启动 AP');
+ }
+
+ // 没有已保存 WiFi 或等待超时 → 开 AP
+ this._enterAP();
+ this._startMonitor();
+
+ if (hasInternet()) {
+ this._emitNetworkReady();
+ }
+ }
+
+ _emitNetworkReady() {
+ if (hasInternet()) {
+ // WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮)
+ if (this._state === 'sta') led.on();
+ this.emit('network-ready');
+ } else {
+ log.warn('provision', 'hasInternet() 返回 false,LED 保持熄灭');
+ }
+ }
+
+ /**
+ * 轮询等待 NM 自动连接 WiFi,最多等 BOOT_WAIT_MAX_MS
+ */
+ _waitForWifiConnect() {
+ return new Promise(resolve => {
+ let elapsed = 0;
+ const timer = setInterval(() => {
+ elapsed += BOOT_POLL_MS;
+ if (isWifiStaConnected()) {
+ clearInterval(timer);
+ resolve(true);
+ } else if (elapsed >= BOOT_WAIT_MAX_MS) {
+ clearInterval(timer);
+ resolve(false);
+ }
+ }, BOOT_POLL_MS);
+ });
+ }
+
+ stop() {
+ this._stopMonitor();
+ this._stopAll();
+ this._state = 'idle';
+ led.destroy(); // 停止时关灯、释放闪烁定时器
+ }
+
+ // ── 进入 AP 模式 ─────────────────────────────────────────────────────────
+
+ _enterAP() {
+ if (this._state === 'ap') return;
+
+ led.off(); // AP 模式:WiFi 未连接,灭灯
+ if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP,有线时等 WS 连接后再定
+
+ try {
+ // AP 模式下无法扫描 WiFi,必须在开 AP 之前扫描并缓存
+ log.info('provision', '扫描周边 WiFi...');
+ this._cachedWifiList = scanWifi();
+ log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络`);
+
+ // 写 DNS 劫持配置(NM 启动热点时加载)
+ this._dns = new DnsHijack();
+ this._dns.start('wlan0', AP_IP);
+
+ const ap = startAP(this._clawId);
+
+ this._server = new CaptiveServer({
+ clawId: this._clawId,
+ cachedWifiList: this._cachedWifiList,
+ 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}`);
+ led.blink(); // 正在连接 → 闪烁
+
+ this._stopAPServices();
+
+ const result = connectWifi(ssid, password);
+
+ if (result.success) {
+ this._state = 'sta';
+ log.info('provision', `WiFi 已连接: ${ssid}`);
+ led.on(); // 连接成功 → 常亮
+ this.emit('network-ready');
+ return result;
+ }
+
+ log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`);
+ this._enterAP(); // _enterAP 内部会调用 led.off()
+ 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(); // 内部调用 led.off()
+ return;
+ }
+
+ if (this._state === 'ap' && wifiUp) {
+ log.info('provision', 'WiFi 已外部连接,关闭 AP');
+ this._stopAPServices();
+ this._state = 'sta';
+ this.emit('network-ready');
+ }
+
+ // WiFi 灯:只在 STA 模式下反映互联网连通性;AP 模式下始终熄灭
+ if (this._state === 'sta') {
+ if (hasInternet()) {
+ led.on();
+ } else {
+ led.off(); // WiFi 已连接但无互联网
+ }
+ }
+ // AP 模式下 led 已在 _enterAP() 中熄灭,无需重复操作
+ }, MONITOR_INTERVAL_MS);
+ }
+
+ _stopMonitor() {
+ if (this._monitorTimer) {
+ clearInterval(this._monitorTimer);
+ this._monitorTimer = null;
+ }
+ }
+
+ // ── 清理 ──────────────────────────────────────────────────────────────────
+
+ _stopAPServices() {
+ if (this._server) {
+ this._server.stop();
+ this._server = null;
+ }
+ if (this._dns) {
+ this._dns.stop();
+ this._dns = null;
+ }
+ stopAP();
+ }
+
+ _stopAll() {
+ this._stopAPServices();
+ }
+}
+
+module.exports = { ProvisionManager };
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..3d00e13
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,148 @@
+{
+ "name": "clawd",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "clawd",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "ssh2": "^1.17.0",
+ "systeminformation": "^5.25.0",
+ "ws": "^8.18.0"
+ },
+ "bin": {
+ "clawd": "bin/clawd.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/asn1": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
+ "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": "~2.1.0"
+ }
+ },
+ "node_modules/bcrypt-pbkdf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+ "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tweetnacl": "^0.14.3"
+ }
+ },
+ "node_modules/buildcheck": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
+ "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
+ "optional": true,
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/cpu-features": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
+ "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
+ "hasInstallScript": true,
+ "optional": true,
+ "dependencies": {
+ "buildcheck": "~0.0.6",
+ "nan": "^2.19.0"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/nan": {
+ "version": "2.26.1",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.1.tgz",
+ "integrity": "sha512-vodKprLlmaKmraa9E/TxHQwpH4eKYTJbLdeQE49pb9GOmrLs68zESjJu0LQOz1W6JwJmftOWD5Ls4dpd/elQtQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/ssh2": {
+ "version": "1.17.0",
+ "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
+ "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "asn1": "^0.2.6",
+ "bcrypt-pbkdf": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ },
+ "optionalDependencies": {
+ "cpu-features": "~0.0.10",
+ "nan": "^2.23.0"
+ }
+ },
+ "node_modules/systeminformation": {
+ "version": "5.31.4",
+ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.4.tgz",
+ "integrity": "sha512-lZppDyQx91VdS5zJvAyGkmwe+Mq6xY978BDUG2wRkWE+jkmUF5ti8cvOovFQoN5bvSFKCXVkyKEaU5ec3SJiRg==",
+ "license": "MIT",
+ "os": [
+ "darwin",
+ "linux",
+ "win32",
+ "freebsd",
+ "openbsd",
+ "netbsd",
+ "sunos",
+ "android"
+ ],
+ "bin": {
+ "systeminformation": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "Buy me a coffee",
+ "url": "https://www.buymeacoffee.com/systeminfo"
+ }
+ },
+ "node_modules/tweetnacl": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
+ "license": "Unlicense"
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index ea7ea82..96cbd50 100644
--- a/package.json
+++ b/package.json
@@ -9,14 +9,20 @@
"scripts": {
"start": "node bin/clawd.js"
},
- "keywords": ["claw", "iot", "websocket", "daemon"],
+ "keywords": [
+ "claw",
+ "iot",
+ "websocket",
+ "daemon"
+ ],
"author": "stswangzhiping",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
- "ws": "^8.18.0",
- "systeminformation": "^5.25.0"
+ "ssh2": "^1.17.0",
+ "systeminformation": "^5.25.0",
+ "ws": "^8.18.0"
}
}