feat: connect 消息上报 local_ip 和 external_ip
Made-with: Cursor
This commit is contained in:
398
README.md
398
README.md
@@ -1,199 +1,199 @@
|
|||||||
# clawd
|
# clawd
|
||||||
|
|
||||||
Claw Box 守护进程,将本地 Linux 设备通过 WebSocket 长连接接入 [claw.cutos.ai](https://claw.cutos.ai)。
|
Claw Box 守护进程,将本地 Linux 设备通过 WebSocket 长连接接入 [claw.cutos.ai](https://claw.cutos.ai)。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 自动生成硬件唯一指纹(`box_id`)
|
- 自动生成硬件唯一指纹(`box_id`)
|
||||||
- 首次连接自动注册,获取 `claw_id` + `token` 并持久化
|
- 首次连接自动注册,获取 `claw_id` + `token` 并持久化
|
||||||
- 每 30 秒上报系统指标(CPU、内存、磁盘、温度、负载、运行时间)
|
- 每 30 秒上报系统指标(CPU、内存、磁盘、温度、负载、运行时间)
|
||||||
- 断线自动重连(指数退避,最大 60 秒)
|
- 断线自动重连(指数退避,最大 60 秒)
|
||||||
- WS 层 Ping/Pong 活性检测,连接假死自动重连
|
- WS 层 Ping/Pong 活性检测,连接假死自动重连
|
||||||
- frpc / ttyd 子进程 Watchdog 守护,崩溃自动重启(速率限制)
|
- frpc / ttyd 子进程 Watchdog 守护,崩溃自动重启(速率限制)
|
||||||
- 结构化日志 + 文件轮转(5MB × 5 份)
|
- 结构化日志 + 文件轮转(5MB × 5 份)
|
||||||
- systemd 集成:Watchdog、资源限制、优雅停止
|
- systemd 集成:Watchdog、资源限制、优雅停止
|
||||||
- 全局异常兜底(uncaughtException / unhandledRejection)
|
- 全局异常兜底(uncaughtException / unhandledRejection)
|
||||||
|
|
||||||
## 快速安装(Linux,需要 root)
|
## 快速安装(Linux,需要 root)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/stswangzhiping/clawd/main/install.sh | sudo bash
|
curl -fsSL https://raw.githubusercontent.com/stswangzhiping/clawd/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
- Node.js >= 18
|
- Node.js >= 18
|
||||||
- Linux(systemd)
|
- Linux(systemd)
|
||||||
|
|
||||||
## 手动运行(开发调试)
|
## 手动运行(开发调试)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/stswangzhiping/clawd.git
|
git clone https://github.com/stswangzhiping/clawd.git
|
||||||
cd clawd
|
cd clawd
|
||||||
npm install
|
npm install
|
||||||
node bin/clawd.js
|
node bin/clawd.js
|
||||||
```
|
```
|
||||||
|
|
||||||
## 首次启动输出示例
|
## 首次启动输出示例
|
||||||
|
|
||||||
```
|
```
|
||||||
2026-03-16T10:00:00.000Z INFO [clawd] 启动中... 服务器 = wss://claw.cutos.ai/ws
|
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.000Z INFO [clawd] WebSocket 已连接
|
||||||
2026-03-16T10:00:01.100Z INFO [clawd] 注册成功!claw_id = 1000
|
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] ╔════════════════════════════════════╗
|
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] ║ Claw ID : 1000 ║
|
||||||
2026-03-16T10:00:01.100Z INFO [clawd] ║ PIN 码 : 779413 ║
|
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] ╚════════════════════════════════════╝
|
||||||
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`(普通用户)
|
路径:`/etc/clawd/config.json`(root 运行)或 `~/.clawd/config.json`(普通用户)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"server": "wss://claw.cutos.ai/ws",
|
"server": "wss://claw.cutos.ai/ws",
|
||||||
"claw_id": 1000,
|
"claw_id": 1000,
|
||||||
"token": "6e0c182e...",
|
"token": "6e0c182e...",
|
||||||
"heartbeat_interval": 30
|
"heartbeat_interval": 30
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `CLAWD_LOG_LEVEL` | `info` | 日志级别:debug / info / warn / error |
|
| `CLAWD_LOG_LEVEL` | `info` | 日志级别:debug / info / warn / error |
|
||||||
| `CLAWD_LOG_FILE` | `1` | 是否写日志文件(`0` = 仅 stdout/journald) |
|
| `CLAWD_LOG_FILE` | `1` | 是否写日志文件(`0` = 仅 stdout/journald) |
|
||||||
| `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 |
|
| `CLAWD_LOG_DIR` | `~/.clawd/logs` | 日志文件目录 |
|
||||||
| `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 |
|
| `CLAWD_CONFIG_DIR` | `~/.clawd` | 配置目录 |
|
||||||
|
|
||||||
systemd 安装后环境变量文件位于 `/etc/clawd/env`。
|
systemd 安装后环境变量文件位于 `/etc/clawd/env`。
|
||||||
|
|
||||||
## 服务管理
|
## 服务管理
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl status clawd # 查看状态
|
systemctl status clawd # 查看状态
|
||||||
journalctl -u clawd -f # 实时日志
|
journalctl -u clawd -f # 实时日志
|
||||||
systemctl restart clawd # 重启
|
systemctl restart clawd # 重启
|
||||||
systemctl stop clawd # 停止
|
systemctl stop clawd # 停止
|
||||||
systemctl disable clawd # 取消开机自启
|
systemctl disable clawd # 取消开机自启
|
||||||
```
|
```
|
||||||
|
|
||||||
## 更新
|
## 更新
|
||||||
|
|
||||||
clawd 安装在 `/opt/clawd`,更新时需在该目录执行 `git pull`:
|
clawd 安装在 `/opt/clawd`,更新时需在该目录执行 `git pull`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/clawd && sudo git pull && sudo systemctl restart clawd
|
cd /opt/clawd && sudo git pull && sudo systemctl restart clawd
|
||||||
```
|
```
|
||||||
|
|
||||||
## 日志
|
## 日志
|
||||||
|
|
||||||
- **stdout/journald**:所有日志同时输出到标准输出(systemd 自动采集到 journald)
|
- **stdout/journald**:所有日志同时输出到标准输出(systemd 自动采集到 journald)
|
||||||
- **文件日志**:`/etc/clawd/logs/clawd.log`,单文件 5MB,保留 5 份轮转
|
- **文件日志**:`/etc/clawd/logs/clawd.log`,单文件 5MB,保留 5 份轮转
|
||||||
|
|
||||||
## 心跳上报字段
|
## 心跳上报字段
|
||||||
|
|
||||||
| 字段 | 说明 | 单位 |
|
| 字段 | 说明 | 单位 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `cpu` | CPU 使用率 | % |
|
| `cpu` | CPU 使用率 | % |
|
||||||
| `mem_total` / `mem_used` | 内存总量 / 已用 | KB |
|
| `mem_total` / `mem_used` | 内存总量 / 已用 | KB |
|
||||||
| `disk_total` / `disk_used` | 磁盘总量 / 已用 | KB |
|
| `disk_total` / `disk_used` | 磁盘总量 / 已用 | KB |
|
||||||
| `temperature` | CPU 温度 | °C |
|
| `temperature` | CPU 温度 | °C |
|
||||||
| `load_1m` / `load_5m` / `load_15m` | 系统负载 | — |
|
| `load_1m` / `load_5m` / `load_15m` | 系统负载 | — |
|
||||||
| `uptime` | 运行时间 | 秒 |
|
| `uptime` | 运行时间 | 秒 |
|
||||||
|
|
||||||
## WiFi 配网(用户手册)
|
## WiFi 配网(用户手册)
|
||||||
|
|
||||||
Claw Box 是无屏设备,通过 WiFi 热点完成网络配置。
|
Claw Box 是无屏设备,通过 WiFi 热点完成网络配置。
|
||||||
|
|
||||||
### 什么时候会出现热点?
|
### 什么时候会出现热点?
|
||||||
|
|
||||||
| 场景 | 热点状态 |
|
| 场景 | 热点状态 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 首次开机,从未配过 WiFi | 立即开启 |
|
| 首次开机,从未配过 WiFi | 立即开启 |
|
||||||
| 配过 WiFi,但信号范围外或密码已改 | 等待约 20 秒后自动开启 |
|
| 配过 WiFi,但信号范围外或密码已改 | 等待约 20 秒后自动开启 |
|
||||||
| WiFi 正常连接中 | 不开启 |
|
| WiFi 正常连接中 | 不开启 |
|
||||||
| 运行中 WiFi 突然断开 | 约 30 秒后自动开启 |
|
| 运行中 WiFi 突然断开 | 约 30 秒后自动开启 |
|
||||||
|
|
||||||
### 配网步骤
|
### 配网步骤
|
||||||
|
|
||||||
**第一步:找到热点**
|
**第一步:找到热点**
|
||||||
|
|
||||||
打开手机 WiFi 设置,找到名为 **ClawBox-{设备ID}** 的热点(例如 `ClawBox-1002`)。
|
打开手机 WiFi 设置,找到名为 **ClawBox-{设备ID}** 的热点(例如 `ClawBox-1002`)。
|
||||||
设备 ID 印在机身标签上。
|
设备 ID 印在机身标签上。
|
||||||
|
|
||||||
**第二步:连接热点**
|
**第二步:连接热点**
|
||||||
|
|
||||||
- 热点名称:`ClawBox-{设备ID}`
|
- 热点名称:`ClawBox-{设备ID}`
|
||||||
- 密码:**`12345678`**
|
- 密码:**`12345678`**
|
||||||
|
|
||||||
**第三步:打开配网页面**
|
**第三步:打开配网页面**
|
||||||
|
|
||||||
连接成功后,手机通常会**自动弹出配网页面**。
|
连接成功后,手机通常会**自动弹出配网页面**。
|
||||||
|
|
||||||
如果没有弹出,请手动打开浏览器访问:
|
如果没有弹出,请手动打开浏览器访问:
|
||||||
- `http://ap.cutos.ai`
|
- `http://ap.cutos.ai`
|
||||||
- 或 `http://10.42.0.1`
|
- 或 `http://10.42.0.1`
|
||||||
|
|
||||||
**第四步:选择 WiFi 并连接**
|
**第四步:选择 WiFi 并连接**
|
||||||
|
|
||||||
1. 点击 **「扫描 WiFi」** 按钮,等待扫描完成
|
1. 点击 **「扫描 WiFi」** 按钮,等待扫描完成
|
||||||
2. 从下拉列表中选择您的 WiFi(或勾选「手动输入 SSID」)
|
2. 从下拉列表中选择您的 WiFi(或勾选「手动输入 SSID」)
|
||||||
3. 输入 WiFi 密码
|
3. 输入 WiFi 密码
|
||||||
4. 点击 **「连接」**
|
4. 点击 **「连接」**
|
||||||
|
|
||||||
**第五步:等待连接**
|
**第五步:等待连接**
|
||||||
|
|
||||||
- 设备会临时关闭热点,尝试连接您选择的 WiFi
|
- 设备会临时关闭热点,尝试连接您选择的 WiFi
|
||||||
- **连接成功**:热点不再出现,设备自动接入云端
|
- **连接成功**:热点不再出现,设备自动接入云端
|
||||||
- **连接失败**:热点会在几秒后重新出现,请重新连接热点再试
|
- **连接失败**:热点会在几秒后重新出现,请重新连接热点再试
|
||||||
|
|
||||||
### 更换 WiFi
|
### 更换 WiFi
|
||||||
|
|
||||||
如果需要更换 WiFi(例如搬到新环境),只需等待设备检测到网络断开,
|
如果需要更换 WiFi(例如搬到新环境),只需等待设备检测到网络断开,
|
||||||
热点会自动重新出现,按上述步骤重新配网即可。
|
热点会自动重新出现,按上述步骤重新配网即可。
|
||||||
|
|
||||||
### 常见问题
|
### 常见问题
|
||||||
|
|
||||||
| 问题 | 解决方法 |
|
| 问题 | 解决方法 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 找不到 ClawBox 热点 | 等待 30 秒;确认设备已通电且指示灯正常 |
|
| 找不到 ClawBox 热点 | 等待 30 秒;确认设备已通电且指示灯正常 |
|
||||||
| 连上热点但页面打不开 | 手动访问 `http://10.42.0.1` |
|
| 连上热点但页面打不开 | 手动访问 `http://10.42.0.1` |
|
||||||
| 扫描不到我的 WiFi | 点击刷新重试;确认路由器开启且距离不太远 |
|
| 扫描不到我的 WiFi | 点击刷新重试;确认路由器开启且距离不太远 |
|
||||||
| 输入密码后连接失败 | 检查密码是否正确;热点恢复后重试 |
|
| 输入密码后连接失败 | 检查密码是否正确;热点恢复后重试 |
|
||||||
| 配网成功但设备仍离线 | 检查路由器是否能上外网;稍等 1 分钟 |
|
| 配网成功但设备仍离线 | 检查路由器是否能上外网;稍等 1 分钟 |
|
||||||
|
|
||||||
### 系统要求
|
### 系统要求
|
||||||
|
|
||||||
- `NetworkManager`(安装脚本自动启用)
|
- `NetworkManager`(安装脚本自动启用)
|
||||||
- WiFi 硬件(wlan0)
|
- WiFi 硬件(wlan0)
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
clawd/
|
clawd/
|
||||||
├── bin/clawd.js ← 入口,优雅停止
|
├── bin/clawd.js ← 入口,优雅停止
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── client.js ← 核心:WS 连接、心跳、Ping/Pong、sd-notify
|
│ ├── client.js ← 核心:WS 连接、心跳、Ping/Pong、sd-notify
|
||||||
│ ├── config.js ← 配置读写
|
│ ├── config.js ← 配置读写
|
||||||
│ ├── fingerprint.js ← 硬件指纹生成
|
│ ├── fingerprint.js ← 硬件指纹生成
|
||||||
│ ├── frpc.js ← frpc/ttyd/dashboard 管理(Watchdog 守护)
|
│ ├── frpc.js ← frpc/ttyd/dashboard 管理(Watchdog 守护)
|
||||||
│ ├── logger.js ← 结构化日志 + 文件轮转
|
│ ├── logger.js ← 结构化日志 + 文件轮转
|
||||||
│ ├── metrics.js ← 系统指标采集
|
│ ├── metrics.js ← 系统指标采集
|
||||||
│ ├── watchdog.js ← 通用子进程守护(速率限制重启)
|
│ ├── watchdog.js ← 通用子进程守护(速率限制重启)
|
||||||
│ ├── network.js ← 网络检测、WiFi 扫描/连接、AP 模式
|
│ ├── network.js ← 网络检测、WiFi 扫描/连接、AP 模式
|
||||||
│ ├── dns-hijack.js ← DNS 劫持(NM dnsmasq-shared.d 配置)
|
│ ├── dns-hijack.js ← DNS 劫持(NM dnsmasq-shared.d 配置)
|
||||||
│ ├── captive-server.js ← 配网 HTTP 页面(Captive Portal)
|
│ ├── captive-server.js ← 配网 HTTP 页面(Captive Portal)
|
||||||
│ └── provisioning.js ← AP 常驻管理器(WiFi 状态监控)
|
│ └── provisioning.js ← AP 常驻管理器(WiFi 状态监控)
|
||||||
├── install.sh ← 一键安装(含 systemd + dnsmasq)
|
├── install.sh ← 一键安装(含 systemd + dnsmasq)
|
||||||
└── package.json
|
└── package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -1,319 +1,319 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const log = require('./logger');
|
const log = require('./logger');
|
||||||
// scanWifi 不再在此调用(AP 模式下无法扫描),改用缓存
|
// scanWifi 不再在此调用(AP 模式下无法扫描),改用缓存
|
||||||
const { CAPTIVE_DOMAIN } = require('./dns-hijack');
|
const { CAPTIVE_DOMAIN } = require('./dns-hijack');
|
||||||
|
|
||||||
const PORT = 80;
|
const PORT = 80;
|
||||||
|
|
||||||
const CAPTIVE_DETECT_PATHS = new Set([
|
const CAPTIVE_DETECT_PATHS = new Set([
|
||||||
'/hotspot-detect.html', // iOS
|
'/hotspot-detect.html', // iOS
|
||||||
'/library/test/success.html', // iOS older
|
'/library/test/success.html', // iOS older
|
||||||
'/generate_204', // Android
|
'/generate_204', // Android
|
||||||
'/gen_204', // Android alt
|
'/gen_204', // Android alt
|
||||||
'/connecttest.txt', // Windows
|
'/connecttest.txt', // Windows
|
||||||
'/ncsi.txt', // Windows alt
|
'/ncsi.txt', // Windows alt
|
||||||
'/redirect', // Windows 11
|
'/redirect', // Windows 11
|
||||||
'/canonical.html', // Firefox
|
'/canonical.html', // Firefox
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 配网 HTTP 服务器(回调模式)。
|
* 配网 HTTP 服务器(回调模式)。
|
||||||
*
|
*
|
||||||
* 路由:
|
* 路由:
|
||||||
* GET / → 配网页面(HTML)
|
* GET / → 配网页面(HTML)
|
||||||
* GET /api/scan → WiFi 扫描结果 JSON
|
* GET /api/scan → WiFi 扫描结果 JSON
|
||||||
* POST /api/connect → 提交 WiFi 凭证,触发 onConnect 回调
|
* POST /api/connect → 提交 WiFi 凭证,触发 onConnect 回调
|
||||||
* GET /api/status → 当前连接状态
|
* GET /api/status → 当前连接状态
|
||||||
* Captive Portal 检测 → 302 重定向到配网页
|
* Captive Portal 检测 → 302 重定向到配网页
|
||||||
*/
|
*/
|
||||||
class CaptiveServer {
|
class CaptiveServer {
|
||||||
constructor(opts = {}) {
|
constructor(opts = {}) {
|
||||||
this._server = null;
|
this._server = null;
|
||||||
this._clawId = opts.clawId || '???';
|
this._clawId = opts.clawId || '???';
|
||||||
this._onConnect = opts.onConnect || null;
|
this._onConnect = opts.onConnect || null;
|
||||||
this._cachedWifiList = opts.cachedWifiList || [];
|
this._cachedWifiList = opts.cachedWifiList || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
startListening() {
|
startListening() {
|
||||||
this._server = http.createServer((req, res) => {
|
this._server = http.createServer((req, res) => {
|
||||||
this._handle(req, res).catch(e => {
|
this._handle(req, res).catch(e => {
|
||||||
log.error('http', `${req.method} ${req.url} 异常:`, e.message);
|
log.error('http', `${req.method} ${req.url} 异常:`, e.message);
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: '内部错误' }));
|
res.end(JSON.stringify({ error: '内部错误' }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this._server.listen(PORT, '0.0.0.0', () => {
|
this._server.listen(PORT, '0.0.0.0', () => {
|
||||||
log.info('http', `配网页面就绪: http://${CAPTIVE_DOMAIN} (端口 ${PORT})`);
|
log.info('http', `配网页面就绪: http://${CAPTIVE_DOMAIN} (端口 ${PORT})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
this._server.on('error', (e) => {
|
this._server.on('error', (e) => {
|
||||||
if (e.code === 'EACCES') {
|
if (e.code === 'EACCES') {
|
||||||
log.error('http', `端口 ${PORT} 无权限,请以 root 运行`);
|
log.error('http', `端口 ${PORT} 无权限,请以 root 运行`);
|
||||||
} else if (e.code === 'EADDRINUSE') {
|
} else if (e.code === 'EADDRINUSE') {
|
||||||
log.error('http', `端口 ${PORT} 已被占用`);
|
log.error('http', `端口 ${PORT} 已被占用`);
|
||||||
} else {
|
} else {
|
||||||
log.error('http', '服务器错误:', e.message);
|
log.error('http', '服务器错误:', e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if (this._server) {
|
if (this._server) {
|
||||||
this._server.close();
|
this._server.close();
|
||||||
this._server = null;
|
this._server = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _handle(req, res) {
|
async _handle(req, res) {
|
||||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
|
|
||||||
if (CAPTIVE_DETECT_PATHS.has(pathname)) {
|
if (CAPTIVE_DETECT_PATHS.has(pathname)) {
|
||||||
res.writeHead(302, { Location: `http://${CAPTIVE_DOMAIN}/` });
|
res.writeHead(302, { Location: `http://${CAPTIVE_DOMAIN}/` });
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === '/api/scan' && req.method === 'GET') {
|
if (pathname === '/api/scan' && req.method === 'GET') {
|
||||||
return this._apiScan(req, res);
|
return this._apiScan(req, res);
|
||||||
}
|
}
|
||||||
if (pathname === '/api/connect' && req.method === 'POST') {
|
if (pathname === '/api/connect' && req.method === 'POST') {
|
||||||
return this._apiConnect(req, res);
|
return this._apiConnect(req, res);
|
||||||
}
|
}
|
||||||
if (pathname === '/api/status' && req.method === 'GET') {
|
if (pathname === '/api/status' && req.method === 'GET') {
|
||||||
return this._apiStatus(req, res);
|
return this._apiStatus(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
});
|
});
|
||||||
res.end(this._renderPage());
|
res.end(this._renderPage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── API ──────────────────────────────────────────────────────────────────
|
// ── API ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_apiScan(req, res) {
|
_apiScan(req, res) {
|
||||||
// AP 模式下 wlan0 无法扫描,返回开 AP 前的缓存结果
|
// AP 模式下 wlan0 无法扫描,返回开 AP 前的缓存结果
|
||||||
this._json(res, { wifi: this._cachedWifiList, cached: true });
|
this._json(res, { wifi: this._cachedWifiList, cached: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async _apiConnect(req, res) {
|
async _apiConnect(req, res) {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
let data;
|
let data;
|
||||||
try { data = JSON.parse(body); } catch (_) {
|
try { data = JSON.parse(body); } catch (_) {
|
||||||
this._json(res, { success: false, error: 'JSON 格式错误' }, 400);
|
this._json(res, { success: false, error: 'JSON 格式错误' }, 400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ssid, password } = data;
|
const { ssid, password } = data;
|
||||||
if (!ssid) {
|
if (!ssid) {
|
||||||
this._json(res, { success: false, error: '请选择 WiFi' }, 400);
|
this._json(res, { success: false, error: '请选择 WiFi' }, 400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('http', `用户提交配网: ssid=${ssid}`);
|
log.info('http', `用户提交配网: ssid=${ssid}`);
|
||||||
|
|
||||||
// 先返回响应,让手机端知道设备收到了请求
|
// 先返回响应,让手机端知道设备收到了请求
|
||||||
// (AP 即将关闭,手机会断开连接)
|
// (AP 即将关闭,手机会断开连接)
|
||||||
this._json(res, { success: true, message: '正在连接,AP 将临时关闭...' });
|
this._json(res, { success: true, message: '正在连接,AP 将临时关闭...' });
|
||||||
|
|
||||||
// 延迟执行,确保 HTTP 响应送达
|
// 延迟执行,确保 HTTP 响应送达
|
||||||
if (this._onConnect) {
|
if (this._onConnect) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this._onConnect(ssid, password || '');
|
this._onConnect(ssid, password || '');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_apiStatus(req, res) {
|
_apiStatus(req, res) {
|
||||||
const { hasInternet } = require('./network');
|
const { hasInternet } = require('./network');
|
||||||
this._json(res, { connected: hasInternet() });
|
this._json(res, { connected: hasInternet() });
|
||||||
}
|
}
|
||||||
|
|
||||||
_json(res, data, code = 200) {
|
_json(res, data, code = 200) {
|
||||||
res.writeHead(code, {
|
res.writeHead(code, {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
});
|
});
|
||||||
res.end(JSON.stringify(data));
|
res.end(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 配网页面 HTML ───────────────────────────────────────────────────────
|
// ── 配网页面 HTML ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
_renderPage() {
|
_renderPage() {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||||||
<title>Claw Box 配网</title>
|
<title>Claw Box 配网</title>
|
||||||
<style>
|
<style>
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{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}
|
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}
|
.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{text-align:center;margin-bottom:24px}
|
||||||
.logo span{font-size:40px}
|
.logo span{font-size:40px}
|
||||||
.logo h1{font-size:20px;margin-top:8px;color:#1a1a2e}
|
.logo h1{font-size:20px;margin-top:8px;color:#1a1a2e}
|
||||||
.logo p{font-size:13px;color:#888;margin-top:4px}
|
.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{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}
|
.device-id strong{color:#1a1a2e;font-size:16px}
|
||||||
label{display:block;font-size:14px;font-weight:500;margin-bottom:6px;color:#555}
|
label{display:block;font-size:14px;font-weight:500;margin-bottom:6px;color:#555}
|
||||||
input{width:100%;padding:12px;border:1.5px solid #ddd;border-radius:8px;font-size:15px;outline:none;transition:border-color .2s}
|
input{width:100%;padding:12px;border:1.5px solid #ddd;border-radius:8px;font-size:15px;outline:none;transition:border-color .2s}
|
||||||
input:focus{border-color:#4a6cf7}
|
input:focus{border-color:#4a6cf7}
|
||||||
.pw-wrap{position:relative}
|
.pw-wrap{position:relative}
|
||||||
.pw-wrap input{padding-right:42px}
|
.pw-wrap input{padding-right:42px}
|
||||||
.pw-eye{position:absolute;right:12px;top:50%;transform:translateY(-50%);cursor:pointer;color:#aaa;line-height:1;font-size:18px;user-select:none}
|
.pw-eye{position:absolute;right:12px;top:50%;transform:translateY(-50%);cursor:pointer;color:#aaa;line-height:1;font-size:18px;user-select:none}
|
||||||
.pw-eye:hover{color:#555}
|
.pw-eye:hover{color:#555}
|
||||||
.field{margin-bottom:16px}
|
.field{margin-bottom:16px}
|
||||||
.wifi-list{max-height:220px;overflow-y:auto;border:1.5px solid #ddd;border-radius:8px;margin-bottom:16px}
|
.wifi-list{max-height:220px;overflow-y:auto;border:1.5px solid #ddd;border-radius:8px;margin-bottom:16px}
|
||||||
.wifi-item{display:flex;align-items:center;padding:10px 12px;border-bottom:1px solid #f0f0f0;cursor:pointer;font-size:13px;transition:background .15s}
|
.wifi-item{display:flex;align-items:center;padding:10px 12px;border-bottom:1px solid #f0f0f0;cursor:pointer;font-size:13px;transition:background .15s}
|
||||||
.wifi-item:last-child{border-bottom:none}
|
.wifi-item:last-child{border-bottom:none}
|
||||||
.wifi-item:hover,.wifi-item.active{background:#e8f0fe}
|
.wifi-item:hover,.wifi-item.active{background:#e8f0fe}
|
||||||
.wifi-item.active{font-weight:600;color:#1a1a2e}
|
.wifi-item.active{font-weight:600;color:#1a1a2e}
|
||||||
.wifi-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.wifi-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.wifi-signal{display:flex;align-items:flex-end;gap:1.5px;height:14px;margin-left:8px;flex-shrink:0}
|
.wifi-signal{display:flex;align-items:flex-end;gap:1.5px;height:14px;margin-left:8px;flex-shrink:0}
|
||||||
.wifi-signal i{display:block;width:3px;background:#ccc;border-radius:1px}
|
.wifi-signal i{display:block;width:3px;background:#ccc;border-radius:1px}
|
||||||
.wifi-signal i.on{background:#4a6cf7}
|
.wifi-signal i.on{background:#4a6cf7}
|
||||||
.wifi-lock{margin-left:6px;font-size:11px;color:#888;flex-shrink:0}
|
.wifi-lock{margin-left:6px;font-size:11px;color:#888;flex-shrink:0}
|
||||||
.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{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:hover{opacity:.9}
|
||||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
.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{background:#f0f2f5;color:#555;font-size:13px;padding:8px;margin-bottom:16px;font-weight:400}
|
||||||
.btn-scan:hover{background:#e8eaed}
|
.btn-scan:hover{background:#e8eaed}
|
||||||
.status{text-align:center;margin-top:16px;padding:12px;border-radius:8px;font-size:14px;display:none}
|
.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.ok{display:block;background:#e8f5e9;color:#2e7d32}
|
||||||
.status.err{display:block;background:#ffeaea;color:#c62828}
|
.status.err{display:block;background:#ffeaea;color:#c62828}
|
||||||
.status.info{display:block;background:#e3f2fd;color:#1565c0}
|
.status.info{display:block;background:#e3f2fd;color:#1565c0}
|
||||||
.manual{margin-top:8px}
|
.manual{margin-top:8px}
|
||||||
.manual input{display:none}
|
.manual input{display:none}
|
||||||
.manual label{font-size:13px;color:#4a6cf7;cursor:pointer;text-align:center;display:block}
|
.manual label{font-size:13px;color:#4a6cf7;cursor:pointer;text-align:center;display:block}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<span>🦀</span>
|
<span>🦀</span>
|
||||||
<h1>Claw Box 配网</h1>
|
<h1>Claw Box 配网</h1>
|
||||||
<p>将设备连接到您的 WiFi</p>
|
<p>将设备连接到您的 WiFi</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-id">设备 ID: <strong>${this._clawId}</strong></div>
|
<div class="device-id">设备 ID: <strong>${this._clawId}</strong></div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>请选择下列 WiFi 网络</label>
|
<label>请选择下列 WiFi 网络</label>
|
||||||
<div class="wifi-list" id="wifiList"><div style="padding:12px;text-align:center;color:#999;font-size:13px">加载中...</div></div>
|
<div class="wifi-list" id="wifiList"><div style="padding:12px;text-align:center;color:#999;font-size:13px">加载中...</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="manual">
|
<div class="manual">
|
||||||
<input type="checkbox" id="manualToggle" onchange="toggleManual()">
|
<input type="checkbox" id="manualToggle" onchange="toggleManual()">
|
||||||
<label for="manualToggle">手动输入 SSID</label>
|
<label for="manualToggle">手动输入 SSID</label>
|
||||||
<input type="text" id="manualSsid" placeholder="输入 WiFi 名称" style="display:none;margin-top:8px">
|
<input type="text" id="manualSsid" placeholder="输入 WiFi 名称" style="display:none;margin-top:8px">
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="selectedSsid" value="">
|
<input type="hidden" id="selectedSsid" value="">
|
||||||
|
|
||||||
<div class="field" style="margin-top:16px">
|
<div class="field" style="margin-top:16px">
|
||||||
<label for="password">密码</label>
|
<label for="password">密码</label>
|
||||||
<div class="pw-wrap">
|
<div class="pw-wrap">
|
||||||
<input type="password" id="password" placeholder="WiFi 密码(开放网络留空)">
|
<input type="password" id="password" placeholder="WiFi 密码(开放网络留空)">
|
||||||
<span class="pw-eye" id="pwEye" style="opacity:0.4" onclick="togglePw()">👁</span>
|
<span class="pw-eye" id="pwEye" style="opacity:0.4" onclick="togglePw()">👁</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn" id="connectBtn" onclick="doConnect()">连接</button>
|
<button class="btn" id="connectBtn" onclick="doConnect()">连接</button>
|
||||||
<div class="status" id="status"></div>
|
<div class="status" id="status"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function $(id){return document.getElementById(id)}
|
function $(id){return document.getElementById(id)}
|
||||||
function setStatus(msg,type){var s=$('status');s.textContent=msg;s.className='status '+type}
|
function setStatus(msg,type){var s=$('status');s.textContent=msg;s.className='status '+type}
|
||||||
function togglePw(){var i=$('password');var show=i.type==='password';i.type=show?'text':'password';$('pwEye').style.opacity=show?'1':'0.4'}
|
function togglePw(){var i=$('password');var show=i.type==='password';i.type=show?'text':'password';$('pwEye').style.opacity=show?'1':'0.4'}
|
||||||
|
|
||||||
function signalBars(pct){
|
function signalBars(pct){
|
||||||
var bars=[4,7,10,14];
|
var bars=[4,7,10,14];
|
||||||
var on=pct>=80?4:pct>=60?3:pct>=40?2:1;
|
var on=pct>=80?4:pct>=60?3:pct>=40?2:1;
|
||||||
return bars.map(function(h,i){
|
return bars.map(function(h,i){
|
||||||
return '<i style="height:'+h+'px" class="'+(i<on?'on':'')+'"></i>';
|
return '<i style="height:'+h+'px" class="'+(i<on?'on':'')+'"></i>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectWifi(ssid){
|
function selectWifi(ssid){
|
||||||
$('selectedSsid').value=ssid;
|
$('selectedSsid').value=ssid;
|
||||||
var items=document.querySelectorAll('.wifi-item');
|
var items=document.querySelectorAll('.wifi-item');
|
||||||
items.forEach(function(el){el.classList.toggle('active',el.dataset.ssid===ssid)});
|
items.forEach(function(el){el.classList.toggle('active',el.dataset.ssid===ssid)});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doScan(){
|
async function doScan(){
|
||||||
var list=$('wifiList');
|
var list=$('wifiList');
|
||||||
list.innerHTML='<div style="padding:12px;text-align:center;color:#999;font-size:13px">加载中...</div>';
|
list.innerHTML='<div style="padding:12px;text-align:center;color:#999;font-size:13px">加载中...</div>';
|
||||||
try{
|
try{
|
||||||
var r=await fetch('/api/scan');
|
var r=await fetch('/api/scan');
|
||||||
var d=await r.json();
|
var d=await r.json();
|
||||||
var arr=d.wifi||[];
|
var arr=d.wifi||[];
|
||||||
if(arr.length===0){
|
if(arr.length===0){
|
||||||
list.innerHTML='<div style="padding:12px;text-align:center;color:#999;font-size:13px">未发现网络,请手动输入</div>';
|
list.innerHTML='<div style="padding:12px;text-align:center;color:#999;font-size:13px">未发现网络,请手动输入</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML='';
|
list.innerHTML='';
|
||||||
arr.forEach(function(w){
|
arr.forEach(function(w){
|
||||||
var div=document.createElement('div');
|
var div=document.createElement('div');
|
||||||
div.className='wifi-item';
|
div.className='wifi-item';
|
||||||
div.dataset.ssid=w.ssid;
|
div.dataset.ssid=w.ssid;
|
||||||
div.onclick=function(){selectWifi(w.ssid)};
|
div.onclick=function(){selectWifi(w.ssid)};
|
||||||
var lock=w.security&&w.security!=='Open'?'🔒':'';
|
var lock=w.security&&w.security!=='Open'?'🔒':'';
|
||||||
div.innerHTML='<span class="wifi-name">'+w.ssid+'</span>'
|
div.innerHTML='<span class="wifi-name">'+w.ssid+'</span>'
|
||||||
+'<span class="wifi-signal">'+signalBars(w.signal)+'</span>'
|
+'<span class="wifi-signal">'+signalBars(w.signal)+'</span>'
|
||||||
+'<span class="wifi-lock">'+lock+'</span>';
|
+'<span class="wifi-lock">'+lock+'</span>';
|
||||||
list.appendChild(div);
|
list.appendChild(div);
|
||||||
});
|
});
|
||||||
}catch(e){
|
}catch(e){
|
||||||
list.innerHTML='<div style="padding:12px;text-align:center;color:#c62828;font-size:13px">加载失败</div>';
|
list.innerHTML='<div style="padding:12px;text-align:center;color:#c62828;font-size:13px">加载失败</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleManual(){
|
function toggleManual(){
|
||||||
var on=$('manualToggle').checked;
|
var on=$('manualToggle').checked;
|
||||||
$('manualSsid').style.display=on?'block':'none';
|
$('manualSsid').style.display=on?'block':'none';
|
||||||
$('wifiList').style.display=on?'none':'block';
|
$('wifiList').style.display=on?'none':'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doConnect(){
|
async function doConnect(){
|
||||||
var ssid=$('manualToggle').checked?$('manualSsid').value:$('selectedSsid').value;
|
var ssid=$('manualToggle').checked?$('manualSsid').value:$('selectedSsid').value;
|
||||||
var pw=$('password').value;
|
var pw=$('password').value;
|
||||||
if(!ssid){setStatus('请选择或输入 WiFi','err');return}
|
if(!ssid){setStatus('请选择或输入 WiFi','err');return}
|
||||||
$('connectBtn').disabled=true;
|
$('connectBtn').disabled=true;
|
||||||
setStatus('正在连接 '+ssid+' ... 热点将临时关闭','info');
|
setStatus('正在连接 '+ssid+' ... 热点将临时关闭','info');
|
||||||
try{
|
try{
|
||||||
var r=await fetch('/api/connect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ssid:ssid,password:pw})});
|
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();
|
var d=await r.json();
|
||||||
if(d.success){
|
if(d.success){
|
||||||
setStatus('✓ 设备正在连接 WiFi,热点将关闭。如连接失败,热点会自动恢复。','ok');
|
setStatus('✓ 设备正在连接 WiFi,热点将关闭。如连接失败,热点会自动恢复。','ok');
|
||||||
}else{
|
}else{
|
||||||
setStatus('失败: '+(d.error||'未知错误'),'err');
|
setStatus('失败: '+(d.error||'未知错误'),'err');
|
||||||
$('connectBtn').disabled=false;
|
$('connectBtn').disabled=false;
|
||||||
}
|
}
|
||||||
}catch(e){
|
}catch(e){
|
||||||
setStatus('请求失败: '+e.message,'err');
|
setStatus('请求失败: '+e.message,'err');
|
||||||
$('connectBtn').disabled=false;
|
$('connectBtn').disabled=false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
doScan();
|
doScan();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBody(req) {
|
function readBody(req) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let data = '';
|
let data = '';
|
||||||
req.on('data', c => { data += c; if (data.length > 4096) req.destroy(); });
|
req.on('data', c => { data += c; if (data.length > 4096) req.destroy(); });
|
||||||
req.on('end', () => resolve(data));
|
req.on('end', () => resolve(data));
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { CaptiveServer };
|
module.exports = { CaptiveServer };
|
||||||
|
|||||||
864
lib/client.js
864
lib/client.js
@@ -1,420 +1,444 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const { execFileSync } = require('child_process');
|
const { execFileSync } = require('child_process');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const log = require('./logger');
|
const log = require('./logger');
|
||||||
const { getBoxId } = require('./fingerprint');
|
const { getBoxId } = require('./fingerprint');
|
||||||
const { collect } = require('./metrics');
|
const { collect } = require('./metrics');
|
||||||
const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新
|
const { getDashboardInfo, startTtyd, FrpcManager } = require('./frpc'); // getDashboardInfo 也用于心跳中定期刷新
|
||||||
const { ProvisionManager } = require('./provisioning');
|
const { ProvisionManager } = require('./provisioning');
|
||||||
const { hasInternet } = require('./network');
|
const { hasInternet, getLocalIps } = require('./network');
|
||||||
const led = require('./led');
|
const led = require('./led');
|
||||||
|
|
||||||
const MAX_BACKOFF_MS = 60_000;
|
const MAX_BACKOFF_MS = 60_000;
|
||||||
const PONG_TIMEOUT_MS = 8_000;
|
const PONG_TIMEOUT_MS = 8_000;
|
||||||
const PING_INTERVAL_MS = 10_000;
|
const PING_INTERVAL_MS = 10_000;
|
||||||
const NET_MONITOR_MS = 5_000; // AP 模式网络监视间隔
|
const NET_MONITOR_MS = 5_000; // AP 模式网络监视间隔
|
||||||
const HEARTBEAT_INTERVAL_MS = 10_000; // 心跳间隔:10 秒,用于快速感知网络状态
|
const HEARTBEAT_INTERVAL_MS = 10_000; // 心跳间隔:10 秒,用于快速感知网络状态
|
||||||
const METRICS_EVERY_N = 3; // 每 N 次心跳采集一次指标(= 30 秒)
|
const METRICS_EVERY_N = 3; // 每 N 次心跳采集一次指标(= 30 秒)
|
||||||
|
|
||||||
// systemd watchdog: 如果 WatchdogSec 存在,定期发 WATCHDOG=1
|
// systemd watchdog: 如果 WatchdogSec 存在,定期发 WATCHDOG=1
|
||||||
const SD_WATCHDOG_USEC = parseInt(process.env.WATCHDOG_USEC || '0', 10);
|
const SD_WATCHDOG_USEC = parseInt(process.env.WATCHDOG_USEC || '0', 10);
|
||||||
const SD_NOTIFY_INTERVAL = SD_WATCHDOG_USEC > 0
|
const SD_NOTIFY_INTERVAL = SD_WATCHDOG_USEC > 0
|
||||||
? Math.floor(SD_WATCHDOG_USEC / 2 / 1000) // 半周期通知(μs → ms)
|
? Math.floor(SD_WATCHDOG_USEC / 2 / 1000) // 半周期通知(μs → ms)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
class ClawClient {
|
class ClawClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._cfg = config.load();
|
this._cfg = config.load();
|
||||||
this._boxId = getBoxId();
|
this._boxId = getBoxId();
|
||||||
this._ws = null;
|
this._ws = null;
|
||||||
this._hbTimer = null;
|
this._hbTimer = null;
|
||||||
this._backoff = 1_000;
|
this._backoff = 1_000;
|
||||||
this._stopped = false;
|
this._stopped = false;
|
||||||
this._frpc = new FrpcManager();
|
this._frpc = new FrpcManager();
|
||||||
this._dashInfo = {};
|
this._dashInfo = {};
|
||||||
this._hbCount = 0; // 心跳计数器,用于定期刷新 dashboard 信息
|
this._hbCount = 0; // 心跳计数器,用于定期刷新 dashboard 信息
|
||||||
|
this._externalIp = null; // 外网 IP(连接时查询一次,用于服务端地理位置解析)
|
||||||
// WS 层活性检测
|
|
||||||
this._pingTimer = null;
|
// WS 层活性检测
|
||||||
this._awaitingPong = false;
|
this._pingTimer = null;
|
||||||
|
this._awaitingPong = false;
|
||||||
// AP 模式网络监视(WS 连通后每 5s 检查,断网立即 terminate)
|
|
||||||
this._netMonitorTimer = null;
|
// AP 模式网络监视(WS 连通后每 5s 检查,断网立即 terminate)
|
||||||
|
this._netMonitorTimer = null;
|
||||||
// WS 连续失败计数(open 时清零)
|
|
||||||
this._wsFailCount = 0;
|
// WS 连续失败计数(open 时清零)
|
||||||
// 是否曾经成功连接过(首次成功前不显示 Err0/AP)
|
this._wsFailCount = 0;
|
||||||
this._hasEverConnected = false;
|
// 是否曾经成功连接过(首次成功前不显示 Err0/AP)
|
||||||
// 最近一次 WS 错误是否是证书时间问题(NTP 未同步)
|
this._hasEverConnected = false;
|
||||||
this._certTimeError = false;
|
// 最近一次 WS 错误是否是证书时间问题(NTP 未同步)
|
||||||
|
this._certTimeError = false;
|
||||||
// systemd watchdog
|
|
||||||
this._sdTimer = null;
|
// systemd watchdog
|
||||||
|
this._sdTimer = null;
|
||||||
this._setupGlobalHandlers();
|
|
||||||
}
|
this._setupGlobalHandlers();
|
||||||
|
}
|
||||||
// ── 全局异常兜底 ─────────────────────────────────────────────────────────────
|
|
||||||
|
// ── 全局异常兜底 ─────────────────────────────────────────────────────────────
|
||||||
_setupGlobalHandlers() {
|
|
||||||
process.on('uncaughtException', (err) => {
|
_setupGlobalHandlers() {
|
||||||
log.error('process', '未捕获异常:', err);
|
process.on('uncaughtException', (err) => {
|
||||||
// 给日志写盘的时间,然后退出让 systemd 重启
|
log.error('process', '未捕获异常:', err);
|
||||||
setTimeout(() => process.exit(1), 1000);
|
// 给日志写盘的时间,然后退出让 systemd 重启
|
||||||
});
|
setTimeout(() => process.exit(1), 1000);
|
||||||
|
});
|
||||||
process.on('unhandledRejection', (reason) => {
|
|
||||||
log.error('process', '未处理的 Promise 拒绝:', reason);
|
process.on('unhandledRejection', (reason) => {
|
||||||
});
|
log.error('process', '未处理的 Promise 拒绝:', reason);
|
||||||
}
|
});
|
||||||
|
}
|
||||||
// ── 生命周期 ─────────────────────────────────────────────────────────────────
|
|
||||||
|
// ── 生命周期 ─────────────────────────────────────────────────────────────────
|
||||||
async start() {
|
|
||||||
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
|
async start() {
|
||||||
|
log.info('clawd', `启动中... 服务器 = ${this._cfg.server}`);
|
||||||
// 启动时全灭,WS 连接后由 _applyStatus() 按实际状态设置
|
|
||||||
led.status.off();
|
// 启动时全灭,WS 连接后由 _applyStatus() 按实际状态设置
|
||||||
|
led.status.off();
|
||||||
this._startSdNotify();
|
|
||||||
|
this._startSdNotify();
|
||||||
// 启动 AP 配网管理器(等待已保存 WiFi 自动连接,超时再开 AP)
|
|
||||||
this._provisionMgr = new ProvisionManager(this._cfg.claw_id);
|
// 启动 AP 配网管理器(等待已保存 WiFi 自动连接,超时再开 AP)
|
||||||
this._connectionStarted = false;
|
this._provisionMgr = new ProvisionManager(this._cfg.claw_id);
|
||||||
|
this._connectionStarted = false;
|
||||||
// 网络就绪时连接云端(仅触发一次)
|
|
||||||
this._provisionMgr.on('network-ready', () => {
|
// 网络就绪时连接云端(仅触发一次)
|
||||||
if (!this._connectionStarted) {
|
this._provisionMgr.on('network-ready', () => {
|
||||||
this._connectionStarted = true;
|
if (!this._connectionStarted) {
|
||||||
this._proceedWithConnection().catch(e => {
|
this._connectionStarted = true;
|
||||||
log.error('clawd', '连接启动失败:', e.message);
|
this._proceedWithConnection().catch(e => {
|
||||||
});
|
log.error('clawd', '连接启动失败:', e.message);
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
});
|
||||||
await this._provisionMgr.start();
|
|
||||||
|
await this._provisionMgr.start();
|
||||||
// start() 返回后,如果已有网络且尚未启动连接
|
|
||||||
if (hasInternet() && !this._connectionStarted) {
|
// start() 返回后,如果已有网络且尚未启动连接
|
||||||
this._connectionStarted = true;
|
if (hasInternet() && !this._connectionStarted) {
|
||||||
await this._proceedWithConnection();
|
this._connectionStarted = true;
|
||||||
} else if (!hasInternet()) {
|
await this._proceedWithConnection();
|
||||||
log.info('clawd', '等待网络就绪(WiFi 配网或网线接入)...');
|
} else if (!hasInternet()) {
|
||||||
}
|
log.info('clawd', '等待网络就绪(WiFi 配网或网线接入)...');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
async _proceedWithConnection() {
|
|
||||||
const [dashInfo] = await Promise.all([
|
async _proceedWithConnection() {
|
||||||
getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }),
|
const [dashInfo] = await Promise.all([
|
||||||
startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
|
getDashboardInfo().catch(e => { log.warn('clawd', 'dashboard 信息获取失败:', e.message); return null; }),
|
||||||
]);
|
startTtyd().catch(e => log.warn('ttyd', '启动失败:', e.message)),
|
||||||
this._dashInfo = dashInfo || {};
|
]);
|
||||||
this._connect();
|
this._dashInfo = dashInfo || {};
|
||||||
}
|
|
||||||
|
// 查询外网 IP(用于服务端地理位置解析),失败不阻断连接
|
||||||
stop() {
|
try {
|
||||||
this._stopped = true;
|
const https = require('https');
|
||||||
this._clearHeartbeat();
|
this._externalIp = await new Promise((resolve) => {
|
||||||
this._clearPing();
|
const req = https.get('https://api.ipify.org?format=json', { timeout: 5000 }, (res) => {
|
||||||
this._clearNetMonitor();
|
let body = '';
|
||||||
if (this._sdTimer) { clearInterval(this._sdTimer); this._sdTimer = null; }
|
res.on('data', d => { body += d; });
|
||||||
if (this._provisionMgr) { this._provisionMgr.stop(); this._provisionMgr = null; }
|
res.on('end', () => {
|
||||||
this._frpc.stop();
|
try { resolve(JSON.parse(body).ip || null); } catch { resolve(null); }
|
||||||
if (this._ws) this._ws.terminate();
|
});
|
||||||
led.status.off(); // 进程退出,两灯全灭
|
});
|
||||||
this._sdNotify('STOPPING=1');
|
req.on('error', () => resolve(null));
|
||||||
log.info('clawd', '已停止');
|
req.on('timeout', () => { req.destroy(); resolve(null); });
|
||||||
log.close();
|
});
|
||||||
}
|
if (this._externalIp) log.info('clawd', `外网 IP: ${this._externalIp}`);
|
||||||
|
} catch (e) {
|
||||||
// ── WebSocket 连接 ──────────────────────────────────────────────────────────
|
log.warn('clawd', '外网 IP 查询失败:', e.message);
|
||||||
|
this._externalIp = null;
|
||||||
_connect() {
|
}
|
||||||
if (this._stopped) return;
|
|
||||||
|
this._connect();
|
||||||
// AP 模式 + 无网:不建立 WS,5s 后重新检查网络
|
}
|
||||||
if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet()) {
|
|
||||||
led.display.showAP();
|
stop() {
|
||||||
log.info('clawd', 'AP 模式无网络,5s 后重新检查...');
|
this._stopped = true;
|
||||||
this._backoff = 1_000; // 有网时立即快速重连
|
this._clearHeartbeat();
|
||||||
this._wsFailCount = 0; // 不计入失败
|
this._clearPing();
|
||||||
setTimeout(() => this._connect(), 5_000);
|
this._clearNetMonitor();
|
||||||
return;
|
if (this._sdTimer) { clearInterval(this._sdTimer); this._sdTimer = null; }
|
||||||
}
|
if (this._provisionMgr) { this._provisionMgr.stop(); this._provisionMgr = null; }
|
||||||
|
this._frpc.stop();
|
||||||
if (!this._hasEverConnected || this._wsFailCount < 3) led.display.showConn();
|
if (this._ws) this._ws.terminate();
|
||||||
log.info('clawd', `正在连接 ${this._cfg.server} ...`);
|
led.status.off(); // 进程退出,两灯全灭
|
||||||
const ws = new WebSocket(this._cfg.server, {
|
this._sdNotify('STOPPING=1');
|
||||||
handshakeTimeout: 10_000,
|
log.info('clawd', '已停止');
|
||||||
});
|
log.close();
|
||||||
this._ws = ws;
|
}
|
||||||
|
|
||||||
ws.on('open', () => {
|
// ── WebSocket 连接 ──────────────────────────────────────────────────────────
|
||||||
log.info('clawd', 'WebSocket 已连接');
|
|
||||||
this._backoff = 1_000;
|
_connect() {
|
||||||
this._wsFailCount = 0; // 连接成功,重置失败计数
|
if (this._stopped) return;
|
||||||
this._hasEverConnected = true; // 标记已成功连接过
|
|
||||||
this._sendConnect();
|
// AP 模式 + 无网:不建立 WS,5s 后重新检查网络
|
||||||
this._startPing();
|
if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet()) {
|
||||||
this._startNetMonitor();
|
led.display.showAP();
|
||||||
// 显示由 _onConnected 根据 status 设置,不在此处提前 showTime
|
log.info('clawd', 'AP 模式无网络,5s 后重新检查...');
|
||||||
});
|
this._backoff = 1_000; // 有网时立即快速重连
|
||||||
|
this._wsFailCount = 0; // 不计入失败
|
||||||
ws.on('message', (data) => {
|
setTimeout(() => this._connect(), 5_000);
|
||||||
try {
|
return;
|
||||||
this._handleMessage(JSON.parse(data.toString()));
|
}
|
||||||
} catch (e) {
|
|
||||||
log.error('clawd', '消息解析失败:', e.message);
|
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,
|
||||||
ws.on('pong', () => {
|
});
|
||||||
this._awaitingPong = false;
|
this._ws = ws;
|
||||||
});
|
|
||||||
|
ws.on('open', () => {
|
||||||
ws.on('close', (code, reason) => {
|
log.info('clawd', 'WebSocket 已连接');
|
||||||
this._clearHeartbeat();
|
this._backoff = 1_000;
|
||||||
this._clearPing();
|
this._wsFailCount = 0; // 连接成功,重置失败计数
|
||||||
this._clearNetMonitor();
|
this._hasEverConnected = true; // 标记已成功连接过
|
||||||
if (!this._stopped) {
|
this._sendConnect();
|
||||||
this._wsFailCount++;
|
this._startPing();
|
||||||
log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount},${this._backoff / 1000}s 后重连...`);
|
this._startNetMonitor();
|
||||||
if (this._hasEverConnected && this._wsFailCount >= 3) {
|
// 显示由 _onConnected 根据 status 设置,不在此处提前 showTime
|
||||||
const inAp = this._provisionMgr && this._provisionMgr.isApMode();
|
});
|
||||||
if (inAp || !hasInternet()) {
|
|
||||||
led.display.showAP(); // AP 模式 或 无网
|
ws.on('message', (data) => {
|
||||||
} else {
|
try {
|
||||||
led.display.showErr0(); // STA 模式 + 有网 但 VPS 不可达
|
this._handleMessage(JSON.parse(data.toString()));
|
||||||
}
|
} catch (e) {
|
||||||
}
|
log.error('clawd', '消息解析失败:', e.message);
|
||||||
if (this._certTimeError) {
|
}
|
||||||
// NTP 未同步:固定 5s 重试,等时钟校正
|
});
|
||||||
this._certTimeError = false;
|
|
||||||
this._backoff = 5_000;
|
ws.on('pong', () => {
|
||||||
log.warn('clawd', '证书时间错误(NTP 未同步),5s 后重试...');
|
this._awaitingPong = false;
|
||||||
} else {
|
});
|
||||||
this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS);
|
|
||||||
}
|
ws.on('close', (code, reason) => {
|
||||||
setTimeout(() => this._connect(), this._backoff);
|
this._clearHeartbeat();
|
||||||
}
|
this._clearPing();
|
||||||
});
|
this._clearNetMonitor();
|
||||||
|
if (!this._stopped) {
|
||||||
ws.on('error', (err) => {
|
this._wsFailCount++;
|
||||||
log.error('clawd', '连接错误:', err.message);
|
log.warn('clawd', `连接断开 (${code}),失败次数=${this._wsFailCount},${this._backoff / 1000}s 后重连...`);
|
||||||
// 证书时间错误:NTP 未同步,close 后用固定短间隔重试,不做指数退避
|
if (this._hasEverConnected && this._wsFailCount >= 3) {
|
||||||
this._certTimeError = !!(
|
const inAp = this._provisionMgr && this._provisionMgr.isApMode();
|
||||||
err.code === 'CERT_NOT_YET_VALID' ||
|
if (inAp || !hasInternet()) {
|
||||||
(err.message && err.message.includes('not yet valid'))
|
led.display.showAP(); // AP 模式 或 无网
|
||||||
);
|
} else {
|
||||||
});
|
led.display.showErr0(); // STA 模式 + 有网 但 VPS 不可达
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// ── WS 层 Ping/Pong 活性检测 ──────────────────────────────────────────────
|
if (this._certTimeError) {
|
||||||
|
// NTP 未同步:固定 5s 重试,等时钟校正
|
||||||
_startPing() {
|
this._certTimeError = false;
|
||||||
this._clearPing();
|
this._backoff = 5_000;
|
||||||
this._pingTimer = setInterval(() => {
|
log.warn('clawd', '证书时间错误(NTP 未同步),5s 后重试...');
|
||||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
} else {
|
||||||
|
this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS);
|
||||||
if (this._awaitingPong) {
|
}
|
||||||
log.warn('clawd', 'Pong 超时,连接可能已死,主动关闭重连');
|
setTimeout(() => this._connect(), this._backoff);
|
||||||
this._ws.terminate();
|
}
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
this._awaitingPong = true;
|
log.error('clawd', '连接错误:', err.message);
|
||||||
try { this._ws.ping(); } catch (_) {}
|
// 证书时间错误:NTP 未同步,close 后用固定短间隔重试,不做指数退避
|
||||||
}, PING_INTERVAL_MS);
|
this._certTimeError = !!(
|
||||||
}
|
err.code === 'CERT_NOT_YET_VALID' ||
|
||||||
|
(err.message && err.message.includes('not yet valid'))
|
||||||
_clearPing() {
|
);
|
||||||
if (this._pingTimer) {
|
});
|
||||||
clearInterval(this._pingTimer);
|
}
|
||||||
this._pingTimer = null;
|
|
||||||
}
|
// ── WS 层 Ping/Pong 活性检测 ──────────────────────────────────────────────
|
||||||
this._awaitingPong = false;
|
|
||||||
}
|
_startPing() {
|
||||||
|
this._clearPing();
|
||||||
// ── AP 模式网络监视(拔网线后 ≤5s 感知)────────────────────────────────────
|
this._pingTimer = setInterval(() => {
|
||||||
|
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
||||||
_startNetMonitor() {
|
|
||||||
this._clearNetMonitor();
|
if (this._awaitingPong) {
|
||||||
this._netMonitorTimer = setInterval(() => {
|
log.warn('clawd', 'Pong 超时,连接可能已死,主动关闭重连');
|
||||||
if (!this._provisionMgr || !this._provisionMgr.isApMode()) return;
|
this._ws.terminate();
|
||||||
if (hasInternet()) return;
|
return;
|
||||||
// AP 模式 + 无网,但 WS 还"活着" → 立即终止,触发 close → _connect() 进入 5s 轮询
|
}
|
||||||
log.warn('clawd', 'AP 模式检测到网络断开,主动关闭 WS');
|
|
||||||
led.display.showAP();
|
this._awaitingPong = true;
|
||||||
if (this._ws) this._ws.terminate();
|
try { this._ws.ping(); } catch (_) {}
|
||||||
}, NET_MONITOR_MS);
|
}, PING_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
_clearNetMonitor() {
|
_clearPing() {
|
||||||
if (this._netMonitorTimer) {
|
if (this._pingTimer) {
|
||||||
clearInterval(this._netMonitorTimer);
|
clearInterval(this._pingTimer);
|
||||||
this._netMonitorTimer = null;
|
this._pingTimer = null;
|
||||||
}
|
}
|
||||||
}
|
this._awaitingPong = false;
|
||||||
|
}
|
||||||
// ── 发送 connect ─────────────────────────────────────────────────────────────
|
|
||||||
|
// ── AP 模式网络监视(拔网线后 ≤5s 感知)────────────────────────────────────
|
||||||
_sendConnect() {
|
|
||||||
const msg = {
|
_startNetMonitor() {
|
||||||
type: 'connect',
|
this._clearNetMonitor();
|
||||||
box_id: this._boxId,
|
this._netMonitorTimer = setInterval(() => {
|
||||||
claw_id: this._cfg.claw_id ?? null,
|
if (!this._provisionMgr || !this._provisionMgr.isApMode()) return;
|
||||||
token: this._cfg.token ?? null,
|
if (hasInternet()) return;
|
||||||
...this._dashInfo,
|
// AP 模式 + 无网,但 WS 还"活着" → 立即终止,触发 close → _connect() 进入 5s 轮询
|
||||||
};
|
log.warn('clawd', 'AP 模式检测到网络断开,主动关闭 WS');
|
||||||
this._send(msg);
|
led.display.showAP();
|
||||||
}
|
if (this._ws) this._ws.terminate();
|
||||||
|
}, NET_MONITOR_MS);
|
||||||
// ── 消息处理 ─────────────────────────────────────────────────────────────────
|
}
|
||||||
|
|
||||||
_handleMessage(msg) {
|
_clearNetMonitor() {
|
||||||
switch (msg.type) {
|
if (this._netMonitorTimer) {
|
||||||
case 'connected':
|
clearInterval(this._netMonitorTimer);
|
||||||
this._onConnected(msg);
|
this._netMonitorTimer = null;
|
||||||
break;
|
}
|
||||||
case 'heartbeat_ack':
|
}
|
||||||
break;
|
|
||||||
case 'status_update':
|
// ── 发送 connect ─────────────────────────────────────────────────────────────
|
||||||
this._applyStatus(msg);
|
|
||||||
break;
|
_sendConnect() {
|
||||||
case 'error':
|
const msg = {
|
||||||
log.error('clawd', `服务器错误: ${msg.msg}`);
|
type: 'connect',
|
||||||
if (msg.msg === 'hardware_mismatch') {
|
box_id: this._boxId,
|
||||||
log.warn('clawd', '硬件指纹不符,清除凭证重新注册...');
|
claw_id: this._cfg.claw_id ?? null,
|
||||||
this._cfg.claw_id = null;
|
token: this._cfg.token ?? null,
|
||||||
this._cfg.token = null;
|
local_ip: getLocalIps(),
|
||||||
config.save(this._cfg);
|
external_ip: this._externalIp ?? null,
|
||||||
} else if (msg.msg && msg.msg.includes('invalid')) {
|
...this._dashInfo,
|
||||||
log.warn('clawd', '凭证无效,清除凭证重新注册...');
|
};
|
||||||
this._cfg.claw_id = null;
|
this._send(msg);
|
||||||
this._cfg.token = null;
|
}
|
||||||
config.save(this._cfg);
|
|
||||||
}
|
// ── 消息处理 ─────────────────────────────────────────────────────────────────
|
||||||
break;
|
|
||||||
default:
|
_handleMessage(msg) {
|
||||||
log.warn('clawd', '未知消息类型:', msg.type);
|
switch (msg.type) {
|
||||||
}
|
case 'connected':
|
||||||
}
|
this._onConnected(msg);
|
||||||
|
break;
|
||||||
_onConnected(msg) {
|
case 'heartbeat_ack':
|
||||||
const isNew = !this._cfg.claw_id;
|
break;
|
||||||
|
case 'status_update':
|
||||||
this._cfg.claw_id = msg.claw_id;
|
this._applyStatus(msg);
|
||||||
this._cfg.token = msg.token;
|
break;
|
||||||
config.save(this._cfg);
|
case 'error':
|
||||||
|
log.error('clawd', `服务器错误: ${msg.msg}`);
|
||||||
if (isNew) {
|
if (msg.msg === 'hardware_mismatch') {
|
||||||
log.info('clawd', `注册成功!claw_id = ${msg.claw_id}`);
|
log.warn('clawd', '硬件指纹不符,清除凭证重新注册...');
|
||||||
}
|
this._cfg.claw_id = null;
|
||||||
|
this._cfg.token = null;
|
||||||
this._applyStatus(msg);
|
config.save(this._cfg);
|
||||||
|
} else if (msg.msg && msg.msg.includes('invalid')) {
|
||||||
if (msg.frp && msg.frp.server && msg.frp.auth_token) {
|
log.warn('clawd', '凭证无效,清除凭证重新注册...');
|
||||||
this._frpc.start(msg.claw_id, msg.frp).catch(e => {
|
this._cfg.claw_id = null;
|
||||||
log.error('frpc', '启动失败:', e.message);
|
this._cfg.token = null;
|
||||||
});
|
config.save(this._cfg);
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
this._startHeartbeat();
|
default:
|
||||||
}
|
log.warn('clawd', '未知消息类型:', msg.type);
|
||||||
|
}
|
||||||
_applyStatus(msg) {
|
}
|
||||||
if (msg.status === 'inactive') {
|
|
||||||
led.status.setSetup();
|
_onConnected(msg) {
|
||||||
led.display.showPin(msg.pin);
|
const isNew = !this._cfg.claw_id;
|
||||||
const id = String(this._cfg.claw_id || '').padEnd(6);
|
|
||||||
const pin = String(msg.pin || '');
|
this._cfg.claw_id = msg.claw_id;
|
||||||
log.info('clawd', '');
|
this._cfg.token = msg.token;
|
||||||
log.info('clawd', '╔════════════════════════════════════╗');
|
config.save(this._cfg);
|
||||||
log.info('clawd', `║ Claw ID : ${id} ║`);
|
|
||||||
log.info('clawd', `║ PIN 码 : ${pin} ║`);
|
if (isNew) {
|
||||||
log.info('clawd', '║ 请在网页前台「添加设备」中输入 ║');
|
log.info('clawd', `注册成功!claw_id = ${msg.claw_id}`);
|
||||||
log.info('clawd', '╚════════════════════════════════════╝');
|
}
|
||||||
log.info('clawd', '');
|
|
||||||
log.info('clawd', '等待激活,心跳正常运行...');
|
this._applyStatus(msg);
|
||||||
} else {
|
|
||||||
led.status.setApps();
|
if (msg.frp && msg.frp.server && msg.frp.auth_token) {
|
||||||
led.display.showTime();
|
this._frpc.start(msg.claw_id, msg.frp).catch(e => {
|
||||||
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
|
log.error('frpc', '启动失败:', e.message);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 心跳 ────────────────────────────────────────────────────────────────────
|
this._startHeartbeat();
|
||||||
|
}
|
||||||
_startHeartbeat() {
|
|
||||||
this._clearHeartbeat();
|
_applyStatus(msg) {
|
||||||
this._sendHeartbeat();
|
if (msg.status === 'inactive') {
|
||||||
this._hbTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
led.status.setSetup();
|
||||||
}
|
led.display.showPin(msg.pin);
|
||||||
|
const id = String(this._cfg.claw_id || '').padEnd(6);
|
||||||
async _sendHeartbeat() {
|
const pin = String(msg.pin || '');
|
||||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
log.info('clawd', '');
|
||||||
try {
|
log.info('clawd', '╔════════════════════════════════════╗');
|
||||||
this._hbCount++;
|
log.info('clawd', `║ Claw ID : ${id} ║`);
|
||||||
|
log.info('clawd', `║ PIN 码 : ${pin} ║`);
|
||||||
// 每 30 次心跳(约 5 分钟)刷新一次 dashboard 信息
|
log.info('clawd', '║ 请在网页前台「添加设备」中输入 ║');
|
||||||
if (this._hbCount % 30 === 0) {
|
log.info('clawd', '╚════════════════════════════════════╝');
|
||||||
const freshInfo = await getDashboardInfo().catch(() => null);
|
log.info('clawd', '');
|
||||||
if (freshInfo && Object.keys(freshInfo).length > 0) {
|
log.info('clawd', '等待激活,心跳正常运行...');
|
||||||
this._dashInfo = freshInfo;
|
} else {
|
||||||
}
|
led.status.setApps();
|
||||||
}
|
led.display.showTime();
|
||||||
|
log.info('clawd', `已激活 claw_id = ${this._cfg.claw_id}`);
|
||||||
// 每 METRICS_EVERY_N 次心跳(30 秒)采集一次指标,其余发轻量心跳
|
}
|
||||||
const msg = {
|
}
|
||||||
type: 'heartbeat',
|
|
||||||
claw_id: this._cfg.claw_id,
|
// ── 心跳 ────────────────────────────────────────────────────────────────────
|
||||||
token: this._cfg.token,
|
|
||||||
...this._dashInfo,
|
_startHeartbeat() {
|
||||||
};
|
this._clearHeartbeat();
|
||||||
if (this._hbCount % METRICS_EVERY_N === 0) {
|
this._sendHeartbeat();
|
||||||
msg.metrics = await collect();
|
this._hbTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
this._send(msg);
|
|
||||||
} catch (e) {
|
async _sendHeartbeat() {
|
||||||
log.error('clawd', '心跳发送失败:', e.message);
|
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
||||||
}
|
try {
|
||||||
}
|
this._hbCount++;
|
||||||
|
|
||||||
_clearHeartbeat() {
|
// 每 30 次心跳(约 5 分钟)刷新一次 dashboard 信息
|
||||||
if (this._hbTimer) {
|
if (this._hbCount % 30 === 0) {
|
||||||
clearInterval(this._hbTimer);
|
const freshInfo = await getDashboardInfo().catch(() => null);
|
||||||
this._hbTimer = null;
|
if (freshInfo && Object.keys(freshInfo).length > 0) {
|
||||||
}
|
this._dashInfo = freshInfo;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// ── 工具 ────────────────────────────────────────────────────────────────────
|
|
||||||
|
// 每 METRICS_EVERY_N 次心跳(30 秒)采集一次指标,其余发轻量心跳
|
||||||
_send(obj) {
|
const msg = {
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
type: 'heartbeat',
|
||||||
this._ws.send(JSON.stringify(obj));
|
claw_id: this._cfg.claw_id,
|
||||||
}
|
token: this._cfg.token,
|
||||||
}
|
...this._dashInfo,
|
||||||
|
};
|
||||||
// ── systemd Watchdog ────────────────────────────────────────────────────────
|
if (this._hbCount % METRICS_EVERY_N === 0) {
|
||||||
|
msg.metrics = await collect();
|
||||||
_startSdNotify() {
|
}
|
||||||
if (!SD_NOTIFY_INTERVAL) return;
|
this._send(msg);
|
||||||
|
} catch (e) {
|
||||||
log.debug('clawd', `systemd watchdog 启用,通知间隔 ${SD_NOTIFY_INTERVAL}ms`);
|
log.error('clawd', '心跳发送失败:', e.message);
|
||||||
this._sdNotify('READY=1');
|
}
|
||||||
this._sdTimer = setInterval(() => this._sdNotify('WATCHDOG=1'), SD_NOTIFY_INTERVAL);
|
}
|
||||||
}
|
|
||||||
|
_clearHeartbeat() {
|
||||||
_sdNotify(msg) {
|
if (this._hbTimer) {
|
||||||
if (!process.env.NOTIFY_SOCKET) return;
|
clearInterval(this._hbTimer);
|
||||||
try {
|
this._hbTimer = null;
|
||||||
execFileSync('systemd-notify', ['--pid=' + process.pid, msg], { timeout: 2000 });
|
}
|
||||||
} catch (_) {
|
}
|
||||||
// systemd-notify 不可用时静默忽略
|
|
||||||
}
|
// ── 工具 ────────────────────────────────────────────────────────────────────
|
||||||
}
|
|
||||||
}
|
_send(obj) {
|
||||||
|
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||||
module.exports = { ClawClient };
|
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 };
|
||||||
|
|||||||
422
lib/led.js
422
lib/led.js
@@ -1,211 +1,211 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const log = require('./logger');
|
const log = require('./logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 前面板指示灯控制
|
* 前面板指示灯控制
|
||||||
*
|
*
|
||||||
* WiFi 灯 (b5): 1 = 亮, 0 = 灭(正逻辑)
|
* WiFi 灯 (b5): 1 = 亮, 0 = 灭(正逻辑)
|
||||||
* - WiFi 已连接且互联网畅通 → 常亮
|
* - WiFi 已连接且互联网畅通 → 常亮
|
||||||
* - WiFi 连接中(正在尝试) → 闪烁
|
* - WiFi 连接中(正在尝试) → 闪烁
|
||||||
* - WiFi 未连接 / 无互联网 → 熄灭
|
* - WiFi 未连接 / 无互联网 → 熄灭
|
||||||
*
|
*
|
||||||
* SETUP 灯 (b2): 0 = 亮, 1 = 灭(反逻辑,与 APPS 互斥)
|
* SETUP 灯 (b2): 0 = 亮, 1 = 灭(反逻辑,与 APPS 互斥)
|
||||||
* APPS 灯 (b1): 0 = 亮, 1 = 灭(反逻辑,与 SETUP 互斥)
|
* APPS 灯 (b1): 0 = 亮, 1 = 灭(反逻辑,与 SETUP 互斥)
|
||||||
* - claw 未激活 → SETUP 亮,APPS 灭
|
* - claw 未激活 → SETUP 亮,APPS 灭
|
||||||
* - claw 已激活 → APPS 亮,SETUP 灭
|
* - claw 已激活 → APPS 亮,SETUP 灭
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const LED_PATH = process.env.CLAWD_LED_PATH || '/sys/devices/platform/openvfd/attr/b5';
|
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 SETUP_LED_PATH = '/sys/devices/platform/openvfd/attr/b1'; // 物理 SETUP 灯
|
||||||
const APPS_LED_PATH = '/sys/devices/platform/openvfd/attr/b2'; // 物理 APPS 灯
|
const APPS_LED_PATH = '/sys/devices/platform/openvfd/attr/b2'; // 物理 APPS 灯
|
||||||
const BLINK_INTERVAL_MS = 500; // 闪烁间隔(ms)
|
const BLINK_INTERVAL_MS = 500; // 闪烁间隔(ms)
|
||||||
|
|
||||||
class WifiLed {
|
class WifiLed {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._blinkTimer = null;
|
this._blinkTimer = null;
|
||||||
this._blinkState = false;
|
this._blinkState = false;
|
||||||
this._current = null; // 'on' | 'off' | 'blink'
|
this._current = null; // 'on' | 'off' | 'blink'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 常亮 */
|
/** 常亮 */
|
||||||
on() {
|
on() {
|
||||||
if (this._current === 'on') return;
|
if (this._current === 'on') return;
|
||||||
this._stopBlink();
|
this._stopBlink();
|
||||||
this._write(1);
|
this._write(1);
|
||||||
this._current = 'on';
|
this._current = 'on';
|
||||||
log.info('led', 'WiFi 指示灯 → 常亮');
|
log.info('led', 'WiFi 指示灯 → 常亮');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 熄灭 */
|
/** 熄灭 */
|
||||||
off() {
|
off() {
|
||||||
if (this._current === 'off') return;
|
if (this._current === 'off') return;
|
||||||
this._stopBlink();
|
this._stopBlink();
|
||||||
this._write(0);
|
this._write(0);
|
||||||
this._current = 'off';
|
this._current = 'off';
|
||||||
log.info('led', 'WiFi 指示灯 → 熄灭');
|
log.info('led', 'WiFi 指示灯 → 熄灭');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 闪烁(连接中) */
|
/** 闪烁(连接中) */
|
||||||
blink(intervalMs = BLINK_INTERVAL_MS) {
|
blink(intervalMs = BLINK_INTERVAL_MS) {
|
||||||
if (this._current === 'blink') return;
|
if (this._current === 'blink') return;
|
||||||
this._stopBlink();
|
this._stopBlink();
|
||||||
this._blinkState = true;
|
this._blinkState = true;
|
||||||
this._write(1);
|
this._write(1);
|
||||||
this._blinkTimer = setInterval(() => {
|
this._blinkTimer = setInterval(() => {
|
||||||
this._blinkState = !this._blinkState;
|
this._blinkState = !this._blinkState;
|
||||||
this._write(this._blinkState ? 1 : 0);
|
this._write(this._blinkState ? 1 : 0);
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
this._current = 'blink';
|
this._current = 'blink';
|
||||||
log.info('led', 'WiFi 指示灯 → 闪烁');
|
log.info('led', 'WiFi 指示灯 → 闪烁');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 释放资源,关灯 */
|
/** 释放资源,关灯 */
|
||||||
destroy() {
|
destroy() {
|
||||||
this._stopBlink();
|
this._stopBlink();
|
||||||
this._write(0);
|
this._write(0);
|
||||||
this._current = 'off';
|
this._current = 'off';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 内部 ──────────────────────────────────────────────────────────────────
|
// ── 内部 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_stopBlink() {
|
_stopBlink() {
|
||||||
if (this._blinkTimer) {
|
if (this._blinkTimer) {
|
||||||
clearInterval(this._blinkTimer);
|
clearInterval(this._blinkTimer);
|
||||||
this._blinkTimer = null;
|
this._blinkTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_write(val) {
|
_write(val) {
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(LED_PATH, String(val));
|
fs.writeFileSync(LED_PATH, String(val));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn('led', `写入失败 (${LED_PATH}): ${e.message}`);
|
log.warn('led', `写入失败 (${LED_PATH}): ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── VFD 显示屏 ────────────────────────────────────────────────────────────────
|
// ── VFD 显示屏 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DISPLAY_PATH = '/sys/devices/platform/openvfd/attr/led';
|
const DISPLAY_PATH = '/sys/devices/platform/openvfd/attr/led';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VFD 显示屏控制。
|
* VFD 显示屏控制。
|
||||||
* #m3 <text> 手动模式,显示指定文字
|
* #m3 <text> 手动模式,显示指定文字
|
||||||
* #s1 系统时钟模式,显示当前时间
|
* #s1 系统时钟模式,显示当前时间
|
||||||
*/
|
*/
|
||||||
class Display {
|
class Display {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._blinkTimer = null;
|
this._blinkTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 网络断开 / AP 模式 → 显示 "AP " */
|
/** 网络断开 / AP 模式 → 显示 "AP " */
|
||||||
showAP() {
|
showAP() {
|
||||||
this._stopBlink();
|
this._stopBlink();
|
||||||
this._write('#m3AP ');
|
this._write('#m3AP ');
|
||||||
log.info('display', '显示屏 → AP');
|
log.info('display', '显示屏 → AP');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WS 连接中(失败次数 < 3)→ 显示 "Conn" 闪烁 */
|
/** WS 连接中(失败次数 < 3)→ 显示 "Conn" 闪烁 */
|
||||||
showConn() {
|
showConn() {
|
||||||
this._stopBlink();
|
this._stopBlink();
|
||||||
this._write('#m3Conn');
|
this._write('#m3Conn');
|
||||||
log.info('display', '显示屏 → Conn(闪烁)');
|
log.info('display', '显示屏 → Conn(闪烁)');
|
||||||
let visible = true;
|
let visible = true;
|
||||||
const blink = () => {
|
const blink = () => {
|
||||||
visible = !visible;
|
visible = !visible;
|
||||||
this._write(visible ? '#m3Conn' : '#c1');
|
this._write(visible ? '#m3Conn' : '#c1');
|
||||||
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
||||||
};
|
};
|
||||||
this._blinkTimer = setTimeout(blink, 1000);
|
this._blinkTimer = setTimeout(blink, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 网络正常但 VPS 不可达 → 显示 "Err0" */
|
/** 网络正常但 VPS 不可达 → 显示 "Err0" */
|
||||||
showErr0() {
|
showErr0() {
|
||||||
this._stopBlink();
|
this._stopBlink();
|
||||||
this._write('#m3Err0');
|
this._write('#m3Err0');
|
||||||
log.info('display', '显示屏 → Err0');
|
log.info('display', '显示屏 → Err0');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 网络已连接 → 显示时间 */
|
/** 网络已连接 → 显示时间 */
|
||||||
showTime() {
|
showTime() {
|
||||||
this._stopBlink();
|
this._stopBlink();
|
||||||
this._write('#s1');
|
this._write('#s1');
|
||||||
log.info('display', '显示屏 → 时间');
|
log.info('display', '显示屏 → 时间');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 未激活 + 连网 → 显示 PIN 码(4 位数字)并闪烁 */
|
/** 未激活 + 连网 → 显示 PIN 码(4 位数字)并闪烁 */
|
||||||
showPin(pin) {
|
showPin(pin) {
|
||||||
this._stopBlink();
|
this._stopBlink();
|
||||||
const s = String(pin || '').padStart(4, '0').slice(-4);
|
const s = String(pin || '').padStart(4, '0').slice(-4);
|
||||||
this._write('#m2' + s);
|
this._write('#m2' + s);
|
||||||
log.info('display', `显示屏 → PIN: ${s}(闪烁)`);
|
log.info('display', `显示屏 → PIN: ${s}(闪烁)`);
|
||||||
// 亮 1s → 灭 0.5s → 循环
|
// 亮 1s → 灭 0.5s → 循环
|
||||||
let visible = true;
|
let visible = true;
|
||||||
const blink = () => {
|
const blink = () => {
|
||||||
visible = !visible;
|
visible = !visible;
|
||||||
this._write(visible ? '#m2' + s : '#c1');
|
this._write(visible ? '#m2' + s : '#c1');
|
||||||
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
||||||
};
|
};
|
||||||
this._blinkTimer = setTimeout(blink, 1000);
|
this._blinkTimer = setTimeout(blink, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
_stopBlink() {
|
_stopBlink() {
|
||||||
if (this._blinkTimer) {
|
if (this._blinkTimer) {
|
||||||
clearTimeout(this._blinkTimer);
|
clearTimeout(this._blinkTimer);
|
||||||
clearInterval(this._blinkTimer);
|
clearInterval(this._blinkTimer);
|
||||||
this._blinkTimer = null;
|
this._blinkTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_write(val) {
|
_write(val) {
|
||||||
try {
|
try {
|
||||||
execSync(`echo "${val}" | tee ${DISPLAY_PATH} > /dev/null`, { timeout: 3000 });
|
execSync(`echo "${val}" | tee ${DISPLAY_PATH} > /dev/null`, { timeout: 3000 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn('display', `写入失败: ${e.message}`);
|
log.warn('display', `写入失败: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SETUP / APPS 状态灯 ───────────────────────────────────────────────────────
|
// ── SETUP / APPS 状态灯 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SETUP 灯(b2)与 APPS 灯(b1)互斥控制。
|
* SETUP 灯(b2)与 APPS 灯(b1)互斥控制。
|
||||||
* 两灯均为反逻辑:写 0 = 亮,写 1 = 灭。
|
* 两灯均为反逻辑:写 0 = 亮,写 1 = 灭。
|
||||||
*/
|
*/
|
||||||
class StatusLed {
|
class StatusLed {
|
||||||
/** claw 未激活 → SETUP 亮,APPS 灭 */
|
/** claw 未激活 → SETUP 亮,APPS 灭 */
|
||||||
setSetup() {
|
setSetup() {
|
||||||
this._write(SETUP_LED_PATH, 0); // SETUP 亮
|
this._write(SETUP_LED_PATH, 0); // SETUP 亮
|
||||||
this._write(APPS_LED_PATH, 1); // APPS 灭
|
this._write(APPS_LED_PATH, 1); // APPS 灭
|
||||||
log.info('led', '状态灯 → SETUP(未激活)');
|
log.info('led', '状态灯 → SETUP(未激活)');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** claw 已激活 → APPS 亮,SETUP 灭 */
|
/** claw 已激活 → APPS 亮,SETUP 灭 */
|
||||||
setApps() {
|
setApps() {
|
||||||
this._write(SETUP_LED_PATH, 1); // SETUP 灭
|
this._write(SETUP_LED_PATH, 1); // SETUP 灭
|
||||||
this._write(APPS_LED_PATH, 0); // APPS 亮
|
this._write(APPS_LED_PATH, 0); // APPS 亮
|
||||||
log.info('led', '状态灯 → APPS(已激活)');
|
log.info('led', '状态灯 → APPS(已激活)');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 两灯全灭(进程退出时调用) */
|
/** 两灯全灭(进程退出时调用) */
|
||||||
off() {
|
off() {
|
||||||
this._write(SETUP_LED_PATH, 1);
|
this._write(SETUP_LED_PATH, 1);
|
||||||
this._write(APPS_LED_PATH, 1);
|
this._write(APPS_LED_PATH, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
_write(path, val) {
|
_write(path, val) {
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(path, String(val));
|
fs.writeFileSync(path, String(val));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn('led', `写入失败 (${path}): ${e.message}`);
|
log.warn('led', `写入失败 (${path}): ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局单例,整个进程共用
|
// 全局单例,整个进程共用
|
||||||
module.exports = new WifiLed();
|
module.exports = new WifiLed();
|
||||||
module.exports.status = new StatusLed();
|
module.exports.status = new StatusLed();
|
||||||
module.exports.display = new Display();
|
module.exports.display = new Display();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
const log = require('./logger');
|
const log = require('./logger');
|
||||||
|
|
||||||
const AP_SSID_PREFIX = 'ClawBox-';
|
const AP_SSID_PREFIX = 'ClawBox-';
|
||||||
@@ -223,6 +224,29 @@ function isWifiStaConnected() {
|
|||||||
return false;
|
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 = {
|
module.exports = {
|
||||||
hasInternet,
|
hasInternet,
|
||||||
hasWiredCarrier,
|
hasWiredCarrier,
|
||||||
@@ -234,4 +258,5 @@ module.exports = {
|
|||||||
startAP,
|
startAP,
|
||||||
stopAP,
|
stopAP,
|
||||||
AP_IP,
|
AP_IP,
|
||||||
|
getLocalIps,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,240 +1,240 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const log = require('./logger');
|
const log = require('./logger');
|
||||||
const { hasInternet, hasSavedWifiConnection, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, AP_IP } = require('./network');
|
const { hasInternet, hasSavedWifiConnection, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, AP_IP } = require('./network');
|
||||||
const { DnsHijack } = require('./dns-hijack');
|
const { DnsHijack } = require('./dns-hijack');
|
||||||
const { CaptiveServer } = require('./captive-server');
|
const { CaptiveServer } = require('./captive-server');
|
||||||
const led = require('./led');
|
const led = require('./led');
|
||||||
|
|
||||||
const MONITOR_INTERVAL_MS = 30_000;
|
const MONITOR_INTERVAL_MS = 30_000;
|
||||||
const BOOT_WAIT_MAX_MS = 20_000; // 等待 NM 自动连接的最大时间
|
const BOOT_WAIT_MAX_MS = 20_000; // 等待 NM 自动连接的最大时间
|
||||||
const BOOT_POLL_MS = 2_000; // 轮询间隔
|
const BOOT_POLL_MS = 2_000; // 轮询间隔
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AP 常驻配网管理器。
|
* AP 常驻配网管理器。
|
||||||
*
|
*
|
||||||
* 规则:
|
* 规则:
|
||||||
* - 启动时:有已保存 WiFi 配置 → 等 NM 自动连接(最多 20 秒)
|
* - 启动时:有已保存 WiFi 配置 → 等 NM 自动连接(最多 20 秒)
|
||||||
* - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页
|
* - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页
|
||||||
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP
|
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP
|
||||||
* - 运行中 WiFi 断开 → 自动重新开 AP
|
* - 运行中 WiFi 断开 → 自动重新开 AP
|
||||||
* - WiFi 已连接 → AP 关闭
|
* - WiFi 已连接 → AP 关闭
|
||||||
*/
|
*/
|
||||||
class ProvisionManager extends EventEmitter {
|
class ProvisionManager extends EventEmitter {
|
||||||
constructor(clawId) {
|
constructor(clawId) {
|
||||||
super();
|
super();
|
||||||
this._clawId = clawId || 'Setup';
|
this._clawId = clawId || 'Setup';
|
||||||
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta'
|
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta'
|
||||||
this._dns = null;
|
this._dns = null;
|
||||||
this._server = null;
|
this._server = null;
|
||||||
this._monitorTimer = null;
|
this._monitorTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否正处于 AP 模式(WiFi 热点广播中) */
|
/** 是否正处于 AP 模式(WiFi 热点广播中) */
|
||||||
isApMode() { return this._state === 'ap'; }
|
isApMode() { return this._state === 'ap'; }
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
led.off(); // 初始状态:灭灯
|
led.off(); // 初始状态:灭灯
|
||||||
|
|
||||||
// WiFi STA 已连接 → 直接进入 STA 模式
|
// WiFi STA 已连接 → 直接进入 STA 模式
|
||||||
if (isWifiStaConnected()) {
|
if (isWifiStaConnected()) {
|
||||||
this._state = 'sta';
|
this._state = 'sta';
|
||||||
log.info('provision', 'WiFi STA 已连接,AP 不启动');
|
log.info('provision', 'WiFi STA 已连接,AP 不启动');
|
||||||
this._emitNetworkReady();
|
this._emitNetworkReady();
|
||||||
this._startMonitor();
|
this._startMonitor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 有线有网 → 立即通知 WS 连接,后台异步设置 AP 供 WiFi 配网
|
// 有线有网 → 立即通知 WS 连接,后台异步设置 AP 供 WiFi 配网
|
||||||
if (hasInternet()) {
|
if (hasInternet()) {
|
||||||
log.info('provision', '有线网络就绪,立即启动 WS,AP 后台准备中...');
|
log.info('provision', '有线网络就绪,立即启动 WS,AP 后台准备中...');
|
||||||
this._emitNetworkReady();
|
this._emitNetworkReady();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this._enterAP();
|
this._enterAP();
|
||||||
this._startMonitor();
|
this._startMonitor();
|
||||||
}, 0);
|
}, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无网:有已保存的 WiFi 配置 → 等 NM 自动连接(重启场景)
|
// 无网:有已保存的 WiFi 配置 → 等 NM 自动连接(重启场景)
|
||||||
if (hasSavedWifiConnection()) {
|
if (hasSavedWifiConnection()) {
|
||||||
log.info('provision', '发现已保存的 WiFi 配置,等待 NetworkManager 自动连接...');
|
log.info('provision', '发现已保存的 WiFi 配置,等待 NetworkManager 自动连接...');
|
||||||
led.blink(); // 等待自动重连期间闪烁
|
led.blink(); // 等待自动重连期间闪烁
|
||||||
const connected = await this._waitForWifiConnect();
|
const connected = await this._waitForWifiConnect();
|
||||||
if (connected) {
|
if (connected) {
|
||||||
this._state = 'sta';
|
this._state = 'sta';
|
||||||
log.info('provision', 'WiFi 自动连接成功,AP 不启动');
|
log.info('provision', 'WiFi 自动连接成功,AP 不启动');
|
||||||
this._emitNetworkReady();
|
this._emitNetworkReady();
|
||||||
this._startMonitor();
|
this._startMonitor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.warn('provision', 'WiFi 自动连接超时,启动 AP');
|
log.warn('provision', 'WiFi 自动连接超时,启动 AP');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 没有已保存 WiFi 或等待超时 → 开 AP
|
// 没有已保存 WiFi 或等待超时 → 开 AP
|
||||||
this._enterAP();
|
this._enterAP();
|
||||||
this._startMonitor();
|
this._startMonitor();
|
||||||
|
|
||||||
if (hasInternet()) {
|
if (hasInternet()) {
|
||||||
this._emitNetworkReady();
|
this._emitNetworkReady();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_emitNetworkReady() {
|
_emitNetworkReady() {
|
||||||
if (hasInternet()) {
|
if (hasInternet()) {
|
||||||
// WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮)
|
// WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮)
|
||||||
if (this._state === 'sta') led.on();
|
if (this._state === 'sta') led.on();
|
||||||
this.emit('network-ready');
|
this.emit('network-ready');
|
||||||
} else {
|
} else {
|
||||||
log.warn('provision', 'hasInternet() 返回 false,LED 保持熄灭');
|
log.warn('provision', 'hasInternet() 返回 false,LED 保持熄灭');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轮询等待 NM 自动连接 WiFi,最多等 BOOT_WAIT_MAX_MS
|
* 轮询等待 NM 自动连接 WiFi,最多等 BOOT_WAIT_MAX_MS
|
||||||
*/
|
*/
|
||||||
_waitForWifiConnect() {
|
_waitForWifiConnect() {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
elapsed += BOOT_POLL_MS;
|
elapsed += BOOT_POLL_MS;
|
||||||
if (isWifiStaConnected()) {
|
if (isWifiStaConnected()) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
} else if (elapsed >= BOOT_WAIT_MAX_MS) {
|
} else if (elapsed >= BOOT_WAIT_MAX_MS) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
}, BOOT_POLL_MS);
|
}, BOOT_POLL_MS);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this._stopMonitor();
|
this._stopMonitor();
|
||||||
this._stopAll();
|
this._stopAll();
|
||||||
this._state = 'idle';
|
this._state = 'idle';
|
||||||
led.destroy(); // 停止时关灯、释放闪烁定时器
|
led.destroy(); // 停止时关灯、释放闪烁定时器
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 进入 AP 模式 ─────────────────────────────────────────────────────────
|
// ── 进入 AP 模式 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_enterAP() {
|
_enterAP() {
|
||||||
if (this._state === 'ap') return;
|
if (this._state === 'ap') return;
|
||||||
|
|
||||||
led.off(); // AP 模式:WiFi 未连接,灭灯
|
led.off(); // AP 模式:WiFi 未连接,灭灯
|
||||||
if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP,有线时等 WS 连接后再定
|
if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP,有线时等 WS 连接后再定
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// AP 模式下无法扫描 WiFi,必须在开 AP 之前扫描并缓存
|
// AP 模式下无法扫描 WiFi,必须在开 AP 之前扫描并缓存
|
||||||
log.info('provision', '扫描周边 WiFi...');
|
log.info('provision', '扫描周边 WiFi...');
|
||||||
this._cachedWifiList = scanWifi();
|
this._cachedWifiList = scanWifi();
|
||||||
log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络`);
|
log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络`);
|
||||||
|
|
||||||
// 写 DNS 劫持配置(NM 启动热点时加载)
|
// 写 DNS 劫持配置(NM 启动热点时加载)
|
||||||
this._dns = new DnsHijack();
|
this._dns = new DnsHijack();
|
||||||
this._dns.start('wlan0', AP_IP);
|
this._dns.start('wlan0', AP_IP);
|
||||||
|
|
||||||
const ap = startAP(this._clawId);
|
const ap = startAP(this._clawId);
|
||||||
|
|
||||||
this._server = new CaptiveServer({
|
this._server = new CaptiveServer({
|
||||||
clawId: this._clawId,
|
clawId: this._clawId,
|
||||||
cachedWifiList: this._cachedWifiList,
|
cachedWifiList: this._cachedWifiList,
|
||||||
onConnect: (ssid, password) => this._handleWifiConnect(ssid, password),
|
onConnect: (ssid, password) => this._handleWifiConnect(ssid, password),
|
||||||
});
|
});
|
||||||
this._server.startListening();
|
this._server.startListening();
|
||||||
|
|
||||||
this._state = 'ap';
|
this._state = 'ap';
|
||||||
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
|
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
|
||||||
log.info('provision', `配网地址: http://ap.cutos.ai`);
|
log.info('provision', `配网地址: http://ap.cutos.ai`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('provision', `AP 启动失败: ${e.message}`);
|
log.error('provision', `AP 启动失败: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
|
// ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async _handleWifiConnect(ssid, password) {
|
async _handleWifiConnect(ssid, password) {
|
||||||
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
|
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
|
||||||
|
|
||||||
this._state = 'connecting';
|
this._state = 'connecting';
|
||||||
log.info('provision', `用户请求连接 WiFi: ${ssid}`);
|
log.info('provision', `用户请求连接 WiFi: ${ssid}`);
|
||||||
led.blink(); // 正在连接 → 闪烁
|
led.blink(); // 正在连接 → 闪烁
|
||||||
|
|
||||||
this._stopAPServices();
|
this._stopAPServices();
|
||||||
|
|
||||||
const result = connectWifi(ssid, password);
|
const result = connectWifi(ssid, password);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
this._state = 'sta';
|
this._state = 'sta';
|
||||||
log.info('provision', `WiFi 已连接: ${ssid}`);
|
log.info('provision', `WiFi 已连接: ${ssid}`);
|
||||||
led.on(); // 连接成功 → 常亮
|
led.on(); // 连接成功 → 常亮
|
||||||
this.emit('network-ready');
|
this.emit('network-ready');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`);
|
log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`);
|
||||||
this._enterAP(); // _enterAP 内部会调用 led.off()
|
this._enterAP(); // _enterAP 内部会调用 led.off()
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WiFi 状态监控 ─────────────────────────────────────────────────────────
|
// ── WiFi 状态监控 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_startMonitor() {
|
_startMonitor() {
|
||||||
this._monitorTimer = setInterval(() => {
|
this._monitorTimer = setInterval(() => {
|
||||||
if (this._state === 'connecting') return;
|
if (this._state === 'connecting') return;
|
||||||
|
|
||||||
const wifiUp = isWifiStaConnected();
|
const wifiUp = isWifiStaConnected();
|
||||||
|
|
||||||
if (this._state === 'sta' && !wifiUp) {
|
if (this._state === 'sta' && !wifiUp) {
|
||||||
log.warn('provision', 'WiFi 连接已断开,重新启动 AP');
|
log.warn('provision', 'WiFi 连接已断开,重新启动 AP');
|
||||||
this._enterAP(); // 内部调用 led.off()
|
this._enterAP(); // 内部调用 led.off()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._state === 'ap' && wifiUp) {
|
if (this._state === 'ap' && wifiUp) {
|
||||||
log.info('provision', 'WiFi 已外部连接,关闭 AP');
|
log.info('provision', 'WiFi 已外部连接,关闭 AP');
|
||||||
this._stopAPServices();
|
this._stopAPServices();
|
||||||
this._state = 'sta';
|
this._state = 'sta';
|
||||||
this.emit('network-ready');
|
this.emit('network-ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
// WiFi 灯:只在 STA 模式下反映互联网连通性;AP 模式下始终熄灭
|
// WiFi 灯:只在 STA 模式下反映互联网连通性;AP 模式下始终熄灭
|
||||||
if (this._state === 'sta') {
|
if (this._state === 'sta') {
|
||||||
if (hasInternet()) {
|
if (hasInternet()) {
|
||||||
led.on();
|
led.on();
|
||||||
} else {
|
} else {
|
||||||
led.off(); // WiFi 已连接但无互联网
|
led.off(); // WiFi 已连接但无互联网
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// AP 模式下 led 已在 _enterAP() 中熄灭,无需重复操作
|
// AP 模式下 led 已在 _enterAP() 中熄灭,无需重复操作
|
||||||
}, MONITOR_INTERVAL_MS);
|
}, MONITOR_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
_stopMonitor() {
|
_stopMonitor() {
|
||||||
if (this._monitorTimer) {
|
if (this._monitorTimer) {
|
||||||
clearInterval(this._monitorTimer);
|
clearInterval(this._monitorTimer);
|
||||||
this._monitorTimer = null;
|
this._monitorTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 清理 ──────────────────────────────────────────────────────────────────
|
// ── 清理 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_stopAPServices() {
|
_stopAPServices() {
|
||||||
if (this._server) {
|
if (this._server) {
|
||||||
this._server.stop();
|
this._server.stop();
|
||||||
this._server = null;
|
this._server = null;
|
||||||
}
|
}
|
||||||
if (this._dns) {
|
if (this._dns) {
|
||||||
this._dns.stop();
|
this._dns.stop();
|
||||||
this._dns = null;
|
this._dns = null;
|
||||||
}
|
}
|
||||||
stopAP();
|
stopAP();
|
||||||
}
|
}
|
||||||
|
|
||||||
_stopAll() {
|
_stopAll() {
|
||||||
this._stopAPServices();
|
this._stopAPServices();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ProvisionManager };
|
module.exports = { ProvisionManager };
|
||||||
|
|||||||
148
package-lock.json
generated
Normal file
148
package-lock.json
generated
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
package.json
12
package.json
@@ -9,14 +9,20 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node bin/clawd.js"
|
"start": "node bin/clawd.js"
|
||||||
},
|
},
|
||||||
"keywords": ["claw", "iot", "websocket", "daemon"],
|
"keywords": [
|
||||||
|
"claw",
|
||||||
|
"iot",
|
||||||
|
"websocket",
|
||||||
|
"daemon"
|
||||||
|
],
|
||||||
"author": "stswangzhiping",
|
"author": "stswangzhiping",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.18.0",
|
"ssh2": "^1.17.0",
|
||||||
"systeminformation": "^5.25.0"
|
"systeminformation": "^5.25.0",
|
||||||
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user