Compare commits
79 Commits
d9f826f978
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c4b9d03e14 | |||
| afbf4cad61 | |||
| e8218a2ab5 | |||
| d6bc57dbab | |||
| 464160c1f7 | |||
| efca4a6b7a | |||
| 000301355f | |||
| a85732aa80 | |||
| 306243eb6a | |||
| 161e0e654c | |||
| 5347a728da | |||
| 9eddc702b6 | |||
| 2d2bd69780 | |||
| 48f64a6858 | |||
|
|
7e1f0bef36 | ||
|
|
d91a309419 | ||
|
|
6da91c7d26 | ||
|
|
f52ad363a2 | ||
|
|
796c8d3431 | ||
|
|
06036c6c73 | ||
|
|
eeb984ebfe | ||
|
|
80e1c97000 | ||
|
|
3dba9fde32 | ||
|
|
8cebf062a2 | ||
|
|
c9597cf1a0 | ||
|
|
cdf2a5f5ac | ||
|
|
000dc4a46c | ||
|
|
be49f32b50 | ||
|
|
6c1c0cf955 | ||
|
|
d89c2340da | ||
|
|
7e44744c31 | ||
|
|
684e9728dd | ||
|
|
f61a0a4305 | ||
|
|
4d13fdec8c | ||
|
|
811c1be3b9 | ||
|
|
c3dd87f635 | ||
|
|
18a949464e | ||
|
|
f363836712 | ||
|
|
e1e3fa95cd | ||
|
|
43117a6a04 | ||
|
|
45e1370ca5 | ||
|
|
7761b438d3 | ||
|
|
8990d48d51 | ||
|
|
9e67969fd1 | ||
|
|
6a97f68255 | ||
|
|
f1c24f75b5 | ||
|
|
f71d448047 | ||
|
|
e4e99c9aed | ||
|
|
844d3c89ae | ||
|
|
f8789876f5 | ||
|
|
4be305d0e2 | ||
|
|
7ad45c0cdc | ||
|
|
b4164689a6 | ||
|
|
a6bca1349c | ||
|
|
29c158f837 | ||
|
|
5e82505c6a | ||
|
|
1a7e0a9738 | ||
|
|
7fe1ee64d8 | ||
|
|
46153b5a5e | ||
|
|
44f69a99b7 | ||
|
|
679c1e2051 | ||
|
|
2b8ebbc86e | ||
|
|
9bb35220a8 | ||
|
|
c5b2d2d480 | ||
|
|
e8c7d3ebc1 | ||
|
|
b4e0388c71 | ||
|
|
5a5c3ca4b5 | ||
|
|
87c219c426 | ||
|
|
ad4d195b52 | ||
|
|
8518232978 | ||
|
|
03dc7c2527 | ||
|
|
c9ce87c93a | ||
|
|
a1c9cc9657 | ||
|
|
f6aad310a8 | ||
|
|
18bea4ae38 | ||
|
|
4cf0e4e948 | ||
|
|
c64aeab3b2 | ||
|
|
8f6e7c55e9 | ||
|
|
3d2e5d477a |
46
bin/clawd.js
Normal file → Executable file
46
bin/clawd.js
Normal file → Executable file
@@ -4,18 +4,64 @@
|
||||
// 先于其它模块:摘掉 NOTIFY_SOCKET,避免任意子进程误发 systemd notify
|
||||
require('../lib/systemd-env');
|
||||
|
||||
const path = require('path');
|
||||
const { exec } = require('child_process');
|
||||
const { ClawClient } = require('../lib/client');
|
||||
const config = require('../lib/config');
|
||||
const log = require('../lib/logger');
|
||||
const { pollSms } = require('../drivers/sim/sms-reader');
|
||||
|
||||
// 每次启动绑定 Quectel 串口驱动(失败不影响主流程)
|
||||
const bindScript = path.join(__dirname, '..', 'tools', 'bind-quectel-serial.sh');
|
||||
exec(`bash "${bindScript}"`, (err, stdout, stderr) => {
|
||||
if (err) log.warn('clawd', `bind-quectel-serial: ${stderr || err.message}`);
|
||||
else log.info('clawd', `bind-quectel-serial: ok`);
|
||||
});
|
||||
|
||||
// 同步 Samba 共享密码(idempotent,失败不影响主流程)
|
||||
const cfg = config.load();
|
||||
if (cfg.share_key) {
|
||||
const shareKey = cfg.share_key.replace(/'/g, "'\\''");
|
||||
exec(`printf '%s\\n%s\\n' '${shareKey}' '${shareKey}' | smbpasswd -a sts -s 2>/dev/null`, (err) => {
|
||||
if (err) log.warn('clawd', `smbpasswd sync failed (samba not installed?): ${err.message}`);
|
||||
else log.info('clawd', 'smbpasswd synced for user sts');
|
||||
});
|
||||
}
|
||||
|
||||
let smsTimer = null;
|
||||
let smsPolling = false;
|
||||
|
||||
async function pollSmsSafe() {
|
||||
if (smsPolling) return;
|
||||
smsPolling = true;
|
||||
try {
|
||||
const text = await pollSms();
|
||||
process.stdout.write(text);
|
||||
} catch (err) {
|
||||
log.warn('clawd', `sms-reader poll failed: ${err.message}`);
|
||||
} finally {
|
||||
smsPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new ClawClient();
|
||||
client.start();
|
||||
|
||||
pollSmsSafe();
|
||||
smsTimer = setInterval(pollSmsSafe, 15_000);
|
||||
|
||||
let stopping = false;
|
||||
|
||||
function shutdown(signal) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
log.info('clawd', `收到 ${signal},正在停止...`);
|
||||
|
||||
if (smsTimer) {
|
||||
clearInterval(smsTimer);
|
||||
smsTimer = null;
|
||||
}
|
||||
|
||||
client.stop();
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
}
|
||||
|
||||
332
drivers/sim/sms-reader.js
Normal file
332
drivers/sim/sms-reader.js
Normal file
@@ -0,0 +1,332 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* SIM 卡短信读取器
|
||||
* - 扫描 /dev/serial/by-path 中以 :1.3-port0 结尾的 Quectel AT 串口
|
||||
* - 单次执行:读取并覆盖写入 /home/sts/sms-messages.txt
|
||||
* - 不去重
|
||||
* - 显示短信真实时间(取自 +CMGL 头)
|
||||
* - 只保留最近 15 分钟内的短信
|
||||
* - 多卡安全长短信拼接:按 IMSI + From + timestampRaw 合并相邻分片
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const BY_PATH_DIR = '/dev/serial/by-path';
|
||||
const TARGET_SUFFIX = ':1.3-port0';
|
||||
const OUT_FILE = '/home/sts/sms-messages.txt';
|
||||
const BAUD = 9600;
|
||||
const WINDOW_MINUTES = 15;
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function setupPort(dev) {
|
||||
execSync(
|
||||
`stty -F "${dev}" ${BAUD} cs8 -cstopb -parenb cread clocal -crtscts raw ` +
|
||||
`-echo -echoe -echok -echonl min 0 time 5`,
|
||||
{ stdio: 'ignore' }
|
||||
);
|
||||
}
|
||||
|
||||
async function readRaw(fd, timeoutMs = 2000, stopOnOk = true) {
|
||||
const buf = Buffer.alloc(4096);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let text = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
let n = 0;
|
||||
try {
|
||||
n = fs.readSync(fd, buf, 0, buf.length, null);
|
||||
} catch (e) {
|
||||
if (e.code !== 'EAGAIN' && e.code !== 'EWOULDBLOCK') throw e;
|
||||
}
|
||||
|
||||
if (n > 0) {
|
||||
text += buf.slice(0, n).toString('utf8');
|
||||
if (stopOnOk && (/\nOK|\rOK|ERROR/.test(text))) {
|
||||
await sleep(120);
|
||||
try {
|
||||
while (true) {
|
||||
const n2 = fs.readSync(fd, buf, 0, buf.length, null);
|
||||
if (n2 <= 0) break;
|
||||
text += buf.slice(0, n2).toString('utf8');
|
||||
}
|
||||
} catch (_) {}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
await sleep(50);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function atCmd(fd, command, waitMs = 2000) {
|
||||
try {
|
||||
while (fs.readSync(fd, Buffer.alloc(256), 0, 256, null) > 0) {}
|
||||
} catch (_) {}
|
||||
fs.writeSync(fd, Buffer.from(command + '\r'));
|
||||
const raw = await readRaw(fd, waitMs, true);
|
||||
return cleanLines(raw);
|
||||
}
|
||||
|
||||
function cleanLines(raw) {
|
||||
return raw
|
||||
.replace(/\r/g, '\n')
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(l => {
|
||||
if (!l) return false;
|
||||
if (l === 'OK' || l === 'ERROR') return false;
|
||||
if (l.startsWith('AT')) return false;
|
||||
const printable = [...l].filter(c => c >= ' ').length;
|
||||
return printable / Math.max(1, l.length) >= 0.85;
|
||||
});
|
||||
}
|
||||
|
||||
function discoverPorts() {
|
||||
if (!fs.existsSync(BY_PATH_DIR)) return [];
|
||||
return fs.readdirSync(BY_PATH_DIR)
|
||||
.filter(n => n.endsWith(TARGET_SUFFIX))
|
||||
.sort()
|
||||
.map(n => {
|
||||
const link = path.join(BY_PATH_DIR, n);
|
||||
try {
|
||||
const real = fs.realpathSync(link);
|
||||
return fs.existsSync(real) ? { link, real } : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function maybeDecodeUcs2(text) {
|
||||
const s = (text || '').replace(/\s+/g, '');
|
||||
if (!s) return '';
|
||||
if (/^\d+$/.test(s)) return '';
|
||||
if (s.length % 4 !== 0) return '';
|
||||
if (!/^[0-9A-Fa-f]+$/.test(s)) return '';
|
||||
try {
|
||||
return Buffer.from(s, 'hex').swap16().toString('utf16le').trim();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function extractOtp(text) {
|
||||
const m = (text || '').match(/(?<!\d)(\d{4,8})(?!\d)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function parseSmsTimestamp(raw) {
|
||||
if (!raw) return null;
|
||||
const m = raw.match(/^(\d{2})\/(\d{2})\/(\d{2}),(\d{2}):(\d{2}):(\d{2})([+-]\d{2})$/);
|
||||
if (!m) return null;
|
||||
|
||||
const [, yy, mo, dd, hh, mi, ss, tzq] = m;
|
||||
const year = 2000 + Number(yy);
|
||||
const offsetMinutes = Number(tzq) * 15;
|
||||
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||
const abs = Math.abs(offsetMinutes);
|
||||
const offH = String(Math.floor(abs / 60)).padStart(2, '0');
|
||||
const offM = String(abs % 60).padStart(2, '0');
|
||||
|
||||
const iso = `${year}-${mo}-${dd}T${hh}:${mi}:${ss}${sign}${offH}:${offM}`;
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d || Number.isNaN(d.getTime())) return 'UNKNOWN';
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const y = d.getFullYear();
|
||||
const mo = pad(d.getMonth() + 1);
|
||||
const da = pad(d.getDate());
|
||||
const hh = pad(d.getHours());
|
||||
const mi = pad(d.getMinutes());
|
||||
const ss = pad(d.getSeconds());
|
||||
|
||||
const offset = -d.getTimezoneOffset();
|
||||
const sign = offset >= 0 ? '+' : '-';
|
||||
const abs = Math.abs(offset);
|
||||
const offH = pad(Math.floor(abs / 60));
|
||||
const offM = pad(abs % 60);
|
||||
|
||||
return `${y}-${mo}-${da} ${hh}:${mi}:${ss} ${sign}${offH}${offM}`;
|
||||
}
|
||||
|
||||
function recentOnly(messages, minutes = WINDOW_MINUTES) {
|
||||
const cutoff = Date.now() - minutes * 60 * 1000;
|
||||
return messages.filter(msg => {
|
||||
if (!msg.timestamp || Number.isNaN(msg.timestamp.getTime())) return false;
|
||||
return msg.timestamp.getTime() >= cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
function parseSms(lines) {
|
||||
const messages = [];
|
||||
let current = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('+CMGL:')) {
|
||||
const m = line.match(/^\+CMGL:\s*\d+,"[^"]*","([^"]*)",,"(\d{2}\/\d{2}\/\d{2},\d{2}:\d{2}:\d{2}[+-]\d{2})"$/);
|
||||
const sender = m ? (m[1] || 'UNKNOWN') : 'UNKNOWN';
|
||||
const timestampRaw = m ? m[2] : '';
|
||||
current = {
|
||||
sender,
|
||||
timestampRaw,
|
||||
timestamp: parseSmsTimestamp(timestampRaw),
|
||||
content: '',
|
||||
decoded: '',
|
||||
code: '',
|
||||
};
|
||||
messages.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+CMGR:')) {
|
||||
const parts = line.split(',').map(p => p.trim().replace(/^\"|\"$/g, ''));
|
||||
const sender = parts[1] || 'UNKNOWN';
|
||||
current = {
|
||||
sender,
|
||||
timestampRaw: '',
|
||||
timestamp: null,
|
||||
content: '',
|
||||
decoded: '',
|
||||
code: '',
|
||||
};
|
||||
messages.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current && !line.startsWith('+')) {
|
||||
const raw = (current.content + ' ' + line).trim();
|
||||
current.content = raw;
|
||||
current.decoded = maybeDecodeUcs2(raw);
|
||||
current.code = extractOtp(current.decoded || current.content);
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function mergeLongSms(imsi, messages) {
|
||||
const merged = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const prev = merged[merged.length - 1];
|
||||
const sameGroup =
|
||||
prev &&
|
||||
(prev._mergeImsi === imsi) &&
|
||||
(prev.sender === msg.sender) &&
|
||||
(prev.timestampRaw === msg.timestampRaw);
|
||||
|
||||
if (!sameGroup) {
|
||||
merged.push({
|
||||
...msg,
|
||||
_mergeImsi: imsi,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
prev.content = [prev.content, msg.content].filter(Boolean).join('');
|
||||
const prevText = prev.decoded || prev.content || '';
|
||||
const nextText = msg.decoded || msg.content || '';
|
||||
const joinedText = [prevText, nextText].filter(Boolean).join('');
|
||||
|
||||
// 合并后优先重新按整体判断是否可解码;否则用拼接后的文本
|
||||
const redecoded = maybeDecodeUcs2((prev.content || ''));
|
||||
prev.decoded = redecoded || joinedText;
|
||||
prev.code = extractOtp(prev.decoded || prev.content);
|
||||
}
|
||||
|
||||
return merged.map(({ _mergeImsi, ...rest }) => rest);
|
||||
}
|
||||
|
||||
async function readPort({ link, real }) {
|
||||
let fd;
|
||||
try {
|
||||
setupPort(real);
|
||||
fd = fs.openSync(real, fs.constants.O_RDWR | fs.constants.O_NOCTTY | fs.constants.O_NONBLOCK);
|
||||
await readRaw(fd, 400, false);
|
||||
|
||||
const cimiLines = await atCmd(fd, 'AT+CIMI');
|
||||
const imsiMatch = cimiLines.join('\n').match(/\b(\d{15})\b/);
|
||||
const imsi = imsiMatch ? imsiMatch[1] : 'UNKNOWN';
|
||||
|
||||
await atCmd(fd, 'AT+CMGF=1');
|
||||
const smsLines = await atCmd(fd, 'AT+CMGL="ALL"', 4000);
|
||||
|
||||
const parsed = parseSms(smsLines);
|
||||
const merged = mergeLongSms(imsi, parsed);
|
||||
const messages = recentOnly(merged, WINDOW_MINUTES);
|
||||
|
||||
return { link, device: real, imsi, messages };
|
||||
} catch (e) {
|
||||
return { link, device: real, imsi: 'UNKNOWN', messages: [], error: e.message };
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try { fs.closeSync(fd); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(results) {
|
||||
const now = new Date().toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
const lines = [`=== ${now} ===`];
|
||||
|
||||
if (!results.length) {
|
||||
lines.push('No matching ports found.');
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
let anySms = false;
|
||||
|
||||
for (const item of results) {
|
||||
const errStr = item.error ? ` ERROR: ${item.error}` : '';
|
||||
lines.push(`Port: ${item.device} IMSI: ${item.imsi} Messages: ${item.messages.length}${errStr}`);
|
||||
|
||||
for (const msg of item.messages) {
|
||||
anySms = true;
|
||||
lines.push(`SmsTime: ${formatDate(msg.timestamp)}`);
|
||||
lines.push(`IMSI: ${item.imsi}`);
|
||||
lines.push(`From: ${msg.sender}`);
|
||||
lines.push(`Content: ${msg.decoded || msg.content}`);
|
||||
if (msg.code) lines.push(`Code: ${msg.code}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
if (!anySms) lines.push(`No SMS found in last ${WINDOW_MINUTES} minutes.`);
|
||||
return lines.join('\n').trimEnd() + '\n';
|
||||
}
|
||||
|
||||
async function pollSms() {
|
||||
const ports = discoverPorts();
|
||||
const results = await Promise.all(ports.map(readPort));
|
||||
const text = render(results);
|
||||
fs.writeFileSync(OUT_FILE, text, 'utf8');
|
||||
return text;
|
||||
}
|
||||
|
||||
module.exports = { pollSms };
|
||||
|
||||
if (require.main === module) {
|
||||
pollSms()
|
||||
.then(text => process.stdout.write(text))
|
||||
.catch(err => {
|
||||
console.error(`sms-reader error: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
181
install.sh
181
install.sh
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# clawd 一键安装脚本
|
||||
# 用法:curl -fsSL https://raw.githubusercontent.com/stswangzhiping/clawd/main/install.sh | bash
|
||||
# 需要 root 权限,需要已安装 Node.js >= 18
|
||||
# clawd installer
|
||||
# Run: curl -fsSL https://git.cutos.ai/claw-daemon/clawd/raw/branch/main/install.sh | sudo bash
|
||||
# Requires root and Node.js >= 18
|
||||
|
||||
set -e
|
||||
|
||||
@@ -11,26 +11,26 @@ info() { echo -e "${GREEN}[clawd]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[clawd]${NC} $*"; }
|
||||
error() { echo -e "${RED}[clawd]${NC} $*"; exit 1; }
|
||||
|
||||
# ── 检查 root ────────────────────────────────────────────────────────────────
|
||||
# Check root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "请以 root 身份运行(sudo bash install.sh)"
|
||||
error "Please run as root: sudo bash install.sh"
|
||||
fi
|
||||
|
||||
# ── 检查 Node.js ─────────────────────────────────────────────────────────────
|
||||
# Check Node.js
|
||||
if ! command -v node &>/dev/null; then
|
||||
error "未找到 Node.js,请先安装 Node.js >= 18"
|
||||
error "Node.js not found. Please install Node.js >= 18"
|
||||
fi
|
||||
|
||||
NODE_VER=$(node -e "process.stdout.write(process.versions.node)")
|
||||
MAJOR=$(echo "$NODE_VER" | cut -d. -f1)
|
||||
if [ "$MAJOR" -lt 18 ]; then
|
||||
error "Node.js 版本过低(当前 $NODE_VER),需要 >= 18"
|
||||
error "Node.js version $NODE_VER is too old. Requires >= 18"
|
||||
fi
|
||||
info "Node.js $NODE_VER ✓"
|
||||
info "Node.js $NODE_VER OK"
|
||||
|
||||
# ── 检查/安装 dnsmasq(WiFi 配网需要)──────────────────────────────────────
|
||||
# Install dnsmasq (required for WiFi captive portal)
|
||||
if ! command -v dnsmasq &>/dev/null; then
|
||||
info "安装 dnsmasq(WiFi 配网所需)..."
|
||||
info "Installing dnsmasq for WiFi captive portal..."
|
||||
if command -v apt-get &>/dev/null; then
|
||||
apt-get install -y -qq dnsmasq >/dev/null 2>&1
|
||||
elif command -v yum &>/dev/null; then
|
||||
@@ -38,25 +38,25 @@ if ! command -v dnsmasq &>/dev/null; then
|
||||
elif command -v apk &>/dev/null; then
|
||||
apk add --quiet dnsmasq >/dev/null 2>&1
|
||||
else
|
||||
warn "无法自动安装 dnsmasq,WiFi 配网功能可能不可用"
|
||||
warn "Cannot install dnsmasq. WiFi captive portal may not work."
|
||||
fi
|
||||
# 禁止 dnsmasq 系统服务自启(clawd 自己管理)
|
||||
# Disable system dnsmasq; clawd manages it directly
|
||||
systemctl disable dnsmasq 2>/dev/null || true
|
||||
systemctl stop dnsmasq 2>/dev/null || true
|
||||
fi
|
||||
if command -v dnsmasq &>/dev/null; then
|
||||
info "dnsmasq ✓"
|
||||
info "dnsmasq OK"
|
||||
fi
|
||||
|
||||
# ── 启用 NetworkManager(WiFi 配网需要)──────────────────────────────────────
|
||||
# Configure NetworkManager for WiFi
|
||||
if command -v nmcli &>/dev/null; then
|
||||
if ! systemctl is-active --quiet NetworkManager 2>/dev/null; then
|
||||
info "启用 NetworkManager..."
|
||||
info "Starting NetworkManager..."
|
||||
systemctl enable --now NetworkManager 2>/dev/null || true
|
||||
fi
|
||||
info "NetworkManager ✓"
|
||||
info "NetworkManager OK"
|
||||
|
||||
# 预写 DNS 劫持配置(运行时 /etc 可能为只读)
|
||||
# Write captive-portal DNS config
|
||||
NM_DNSMASQ_DIR="/etc/NetworkManager/dnsmasq-shared.d"
|
||||
mkdir -p "$NM_DNSMASQ_DIR"
|
||||
cat > "$NM_DNSMASQ_DIR/clawd-captive.conf" << 'DNSCONF'
|
||||
@@ -64,20 +64,20 @@ if command -v nmcli &>/dev/null; then
|
||||
# All DNS queries resolve to gateway to trigger captive portal
|
||||
address=/#/10.42.0.1
|
||||
DNSCONF
|
||||
info "DNS 劫持配置已写入 $NM_DNSMASQ_DIR ✓"
|
||||
info "DNS captive config written to $NM_DNSMASQ_DIR"
|
||||
fi
|
||||
|
||||
# ── WiFi rfkill 解锁(部分设备默认禁用 WiFi)────────────────────────────────
|
||||
# Unblock WiFi via rfkill
|
||||
for rf in /sys/class/rfkill/rfkill*; do
|
||||
if [ -f "$rf/type" ] && [ "$(cat "$rf/type")" = "wlan" ]; then
|
||||
if [ "$(cat "$rf/soft")" = "1" ]; then
|
||||
info "解锁 WiFi ($(basename "$rf"))..."
|
||||
info "Unblocking WiFi ($(basename "$rf"))..."
|
||||
echo 0 > "$rf/soft"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 持久化:独立脚本 + systemd 服务,确保开机自动解锁 WiFi
|
||||
# Install rfkill unblock script + systemd unit for persistence
|
||||
RFKILL_SCRIPT="/usr/local/bin/clawd-unblock-wifi.sh"
|
||||
cat > "$RFKILL_SCRIPT" << 'SCRIPT'
|
||||
#!/bin/sh
|
||||
@@ -108,51 +108,74 @@ WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable clawd-rfkill
|
||||
info "WiFi rfkill 解锁服务已创建 ✓"
|
||||
info "WiFi rfkill service installed"
|
||||
|
||||
# ── 安装 ttyd(Web 终端)────────────────────────────────────────────────────
|
||||
info "安装 ttyd..."
|
||||
# Install ttyd (Web terminal)
|
||||
info "Installing ttyd..."
|
||||
if apt-get install -y ttyd >/dev/null 2>&1; then
|
||||
info "ttyd 已安装 ✓"
|
||||
info "ttyd installed OK"
|
||||
else
|
||||
warn "ttyd 安装失败,Web 终端功能将不可用"
|
||||
warn "ttyd install failed. Web terminal will not be available."
|
||||
fi
|
||||
|
||||
# ── 安装 clawd ───────────────────────────────────────────────────────────────
|
||||
# Clone / update clawd
|
||||
INSTALL_DIR="/opt/clawd"
|
||||
CONFIG_DIR="/etc/clawd"
|
||||
ENV_FILE="$CONFIG_DIR/env"
|
||||
info "安装到 $INSTALL_DIR ..."
|
||||
info "Setting up $INSTALL_DIR ..."
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# 下载源码(若目录已有 package.json,视为离线/已解压部署,跳过 git/tarball;避免设备无法访问 github.com)
|
||||
if [ -f "package.json" ]; then
|
||||
info "检测到已有源码,跳过 git/tarball 下载"
|
||||
elif command -v git &>/dev/null; then
|
||||
if [ -d ".git" ]; then
|
||||
git pull --quiet
|
||||
else
|
||||
git clone --depth=1 https://github.com/stswangzhiping/clawd.git .
|
||||
# Use git if available, fall back to tarball
|
||||
CUTOS_REPO="https://git.cutos.ai/claw-daemon/clawd.git"
|
||||
if command -v git &>/dev/null && [ -d ".git" ]; then
|
||||
CURRENT_REMOTE=$(git remote get-url origin 2>/dev/null || echo "")
|
||||
if echo "$CURRENT_REMOTE" | grep -q "github.com"; then
|
||||
info "Migrating git remote to git.cutos.ai ..."
|
||||
git remote set-url origin "$CUTOS_REPO"
|
||||
fi
|
||||
info "Pulling latest code..."
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
git clean -fd
|
||||
elif [ -f "package.json" ]; then
|
||||
info "Files already present, skipping git clone"
|
||||
elif command -v git &>/dev/null; then
|
||||
git clone --depth=1 "$CUTOS_REPO" .
|
||||
else
|
||||
TARBALL_URL="https://github.com/stswangzhiping/clawd/archive/refs/heads/main.tar.gz"
|
||||
TARBALL_URL="https://git.cutos.ai/claw-daemon/clawd/archive/main.tar.gz"
|
||||
curl -fsSL "$TARBALL_URL" | tar -xz --strip-components=1
|
||||
fi
|
||||
|
||||
# 安装依赖
|
||||
info "安装 npm 依赖..."
|
||||
# Install npm dependencies
|
||||
info "Running npm install..."
|
||||
npm install --omit=dev --silent
|
||||
|
||||
# 创建可执行链接
|
||||
# Create symlink
|
||||
ln -sf "$INSTALL_DIR/bin/clawd.js" /usr/local/bin/clawd
|
||||
chmod +x "$INSTALL_DIR/bin/clawd.js"
|
||||
|
||||
info "clawd 已安装到 /usr/local/bin/clawd ✓"
|
||||
info "clawd symlinked to /usr/local/bin/clawd"
|
||||
|
||||
# Install RK3588S LVGL demo
|
||||
DEVICE_MODEL="$(tr -d '\0' </proc/device-tree/model 2>/dev/null || true)"
|
||||
if echo "$DEVICE_MODEL" | grep -qi 'RK3588S'; then
|
||||
DEMO_SRC="$INSTALL_DIR/lib/resource/3588s/demo"
|
||||
DEMO_DST="/usr/bin/demo"
|
||||
if [ -f "$DEMO_SRC" ]; then
|
||||
info "RK3588S detected, installing LVGL demo to $DEMO_DST"
|
||||
if [ -f "$DEMO_DST" ] && [ ! -f "${DEMO_DST}.clawd-bak" ]; then
|
||||
cp "$DEMO_DST" "${DEMO_DST}.clawd-bak"
|
||||
info "Backup created: ${DEMO_DST}.clawd-bak"
|
||||
fi
|
||||
install -m 0755 "$DEMO_SRC" "$DEMO_DST"
|
||||
else
|
||||
warn "RK3588S demo binary not found: $DEMO_SRC"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 创建配置目录 + 环境变量文件 ──────────────────────────────────────────────
|
||||
# Write default config files
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
|
||||
if [ ! -f "$CONFIG_DIR/config.json" ]; then
|
||||
@@ -164,120 +187,116 @@ if [ ! -f "$CONFIG_DIR/config.json" ]; then
|
||||
"heartbeat_interval": 30
|
||||
}
|
||||
EOF
|
||||
info "配置文件已创建:$CONFIG_DIR/config.json ✓"
|
||||
info "Default config written to $CONFIG_DIR/config.json"
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# clawd 环境变量(systemd EnvironmentFile)
|
||||
# 日志级别: debug / info / warn / error
|
||||
# clawd environment (loaded by systemd EnvironmentFile)
|
||||
# Log level: debug / info / warn / error
|
||||
CLAWD_LOG_LEVEL=info
|
||||
# 是否写日志文件(0=仅 journald)
|
||||
# Log to file (0 = journald only)
|
||||
CLAWD_LOG_FILE=1
|
||||
# 自定义服务器地址(留空则读 config.json)
|
||||
# Override server URL (default from config.json)
|
||||
# CLAWD_SERVER=wss://claw.cutos.ai/ws
|
||||
# BtMonitor(bluetoothctl)默认在程序内关闭,无需在此写 CLAWD_DISABLE_BT。
|
||||
# 若产品需要蓝牙指示灯,取消下一行注释:
|
||||
# Enable Bluetooth monitor (bluetoothctl); disabled by default
|
||||
# CLAWD_ENABLE_BT=1
|
||||
# OpenVFD sysfs 根路径(默认 /sys/class/leds/openvfd)
|
||||
# OpenVFD sysfs path (default: /sys/class/leds/openvfd)
|
||||
# CLAWD_OPENVFD_PATH=/sys/class/leds/openvfd
|
||||
# 数码管 vfdservice 管道(默认 /tmp/openvfd_service)
|
||||
# vfdservice pipe path (default: /tmp/openvfd_service)
|
||||
# CLAWD_VFD_PIPE=/tmp/openvfd_service
|
||||
# 多网口/特殊板型可固定 LAN 灯监控的以太网口(默认由 clawd 自动锁定首次 carrier 口)
|
||||
# Wired LAN interface for carrier detection
|
||||
# CLAWD_ETH_IFACE=end0
|
||||
EOF
|
||||
info "环境变量文件已创建:$ENV_FILE ✓"
|
||||
info "Default env file written to $ENV_FILE"
|
||||
fi
|
||||
|
||||
# ── 创建日志目录 ─────────────────────────────────────────────────────────────
|
||||
# Create log directory
|
||||
mkdir -p "$CONFIG_DIR/logs"
|
||||
info "日志目录:$CONFIG_DIR/logs ✓"
|
||||
info "Log directory: $CONFIG_DIR/logs"
|
||||
|
||||
# ── 创建 systemd service ────────────────────────────────────────────────────
|
||||
# Write systemd service file
|
||||
NODE_BIN=$(command -v node)
|
||||
SERVICE_FILE="/etc/systemd/system/clawd.service"
|
||||
|
||||
cat > "$SERVICE_FILE" <<EOF
|
||||
[Unit]
|
||||
Description=Claw Box Daemon
|
||||
Documentation=https://github.com/stswangzhiping/clawd
|
||||
Documentation=https://git.cutos.ai/claw-daemon/clawd
|
||||
After=NetworkManager.service
|
||||
Wants=NetworkManager.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# systemd-notify 由子进程执行,默认 NotifyAccess=main 会拒收;需 all 才能喂 WatchdogSec
|
||||
# NotifyAccess=all required for systemd-notify with WatchdogSec
|
||||
NotifyAccess=all
|
||||
EnvironmentFile=$ENV_FILE
|
||||
ExecStart=$NODE_BIN $INSTALL_DIR/bin/clawd.js
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
|
||||
# 重启策略
|
||||
# Restart policy
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# 旧版 systemd 不认 StartLimitIntervalSec,用 StartLimitInterval=(秒)
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=10
|
||||
|
||||
# 优雅停止(10s 内 SIGTERM,超时 SIGKILL)
|
||||
# Allow 10s for graceful shutdown before SIGKILL
|
||||
TimeoutStopSec=10
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
|
||||
# 资源限制(防止失控)
|
||||
# Resource limits
|
||||
MemoryMax=256M
|
||||
CPUQuota=50%
|
||||
TasksMax=64
|
||||
|
||||
# 安全加固(ttyd 子进程需要 setuid sudo,不能用 NoNewPrivileges/strict)
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=$CONFIG_DIR /tmp
|
||||
# Sandbox disabled: clawd needs to write system/config files on some devices
|
||||
|
||||
# 日志
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=clawd
|
||||
|
||||
# systemd Watchdog(60s 无响应视为挂死)
|
||||
# systemd Watchdog: restart if no heartbeat within 60s
|
||||
WatchdogSec=60
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
info "systemd 服务文件已创建 ✓"
|
||||
info "systemd service file written"
|
||||
|
||||
# ── journald 日志限制(可选) ────────────────────────────────────────────────
|
||||
# Configure journald retention
|
||||
JOURNAL_CONF="/etc/systemd/journald.conf.d/clawd.conf"
|
||||
if [ ! -f "$JOURNAL_CONF" ]; then
|
||||
mkdir -p /etc/systemd/journald.conf.d
|
||||
cat > "$JOURNAL_CONF" <<EOF
|
||||
# clawd journald 限制
|
||||
# clawd journald limits
|
||||
[Journal]
|
||||
SystemMaxUse=100M
|
||||
MaxFileSec=7day
|
||||
EOF
|
||||
systemctl restart systemd-journald 2>/dev/null || true
|
||||
info "journald 日志限制已配置 ✓"
|
||||
info "journald config written"
|
||||
fi
|
||||
|
||||
# ── 启用并启动 ──────────────────────────────────────────────────────────────
|
||||
# Enable and start clawd
|
||||
systemctl daemon-reload
|
||||
systemctl enable clawd
|
||||
systemctl restart clawd
|
||||
|
||||
sleep 2
|
||||
if systemctl is-active --quiet clawd; then
|
||||
info "clawd 服务运行中 ✓"
|
||||
info "clawd is running"
|
||||
echo ""
|
||||
echo " 查看日志: journalctl -u clawd -f"
|
||||
echo " 查看状态: systemctl status clawd"
|
||||
echo " 停止服务: systemctl stop clawd"
|
||||
echo " 配置文件: $CONFIG_DIR/config.json"
|
||||
echo " 环境变量: $ENV_FILE"
|
||||
echo " 文件日志: $CONFIG_DIR/logs/clawd.log"
|
||||
echo " Logs: journalctl -u clawd -f"
|
||||
echo " Status: systemctl status clawd"
|
||||
echo " Stop: systemctl stop clawd"
|
||||
echo " Config: $CONFIG_DIR/config.json"
|
||||
echo " Env: $ENV_FILE"
|
||||
echo " Log dir: $CONFIG_DIR/logs/clawd.log"
|
||||
echo ""
|
||||
else
|
||||
warn "服务启动失败,请检查日志:"
|
||||
warn "clawd failed to start. Check logs:"
|
||||
echo " journalctl -u clawd -n 50 --no-pager"
|
||||
fi
|
||||
|
||||
454
lib/channel/weixin.js
Normal file
454
lib/channel/weixin.js
Normal file
@@ -0,0 +1,454 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* channel.weixin — WeChat login via ilinkai.weixin.qq.com API.
|
||||
*
|
||||
* Ported from the reference weixin-login.js script.
|
||||
* Requires Node.js >= 18 (global fetch).
|
||||
*
|
||||
* method: login
|
||||
* params : { callId, timeout, emit }
|
||||
* returns: { abort }
|
||||
*
|
||||
* emit(payload) sends a sys-call reply upstream:
|
||||
* { action:'event', event:'qrcode', data:{ url, expire:30, index } }
|
||||
* { action:'progress', event:'scanned', data:{ status:'waiting_confirm' } }
|
||||
* { action:'finish', event:'success', data:{ accountId } }
|
||||
* { action:'finish', event:'failed', code, message }
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const log = require('../logger');
|
||||
|
||||
// ── Constants (from reference script) ────────────────────────────────────────
|
||||
|
||||
const FIXED_BASE_URL = 'https://ilinkai.weixin.qq.com';
|
||||
const DEFAULT_BOT_TYPE = '3';
|
||||
const QR_LONG_POLL_TIMEOUT_MS = 35_000;
|
||||
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
|
||||
const MAX_QR_REFRESH_COUNT = 3;
|
||||
const CHANNEL_VERSION = '2.4.3';
|
||||
const ILINK_APP_ID = 'bot';
|
||||
const ILINK_APP_CLIENT_VERSION = String(_buildClientVersion(CHANNEL_VERSION));
|
||||
|
||||
function _buildClientVersion(version) {
|
||||
const parts = String(version).split('.').map(p => parseInt(p, 10));
|
||||
const major = parts[0] || 0;
|
||||
const minor = parts[1] || 0;
|
||||
const patch = parts[2] || 0;
|
||||
return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
|
||||
}
|
||||
|
||||
// ── State-dir helpers (mirrors reference script) ─────────────────────────────
|
||||
|
||||
function _resolveStateDir() {
|
||||
return path.join('/home/sts', '.openclaw');
|
||||
}
|
||||
|
||||
function _ensureStsOwnership(filePath) {
|
||||
try {
|
||||
execFileSync('chown', ['sts:sts', filePath], { stdio: 'ignore' });
|
||||
} catch (err) {
|
||||
log.warn('weixin', `chown sts:sts failed for ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function _resolveWeixinStateDir() { return path.join(_resolveStateDir(), 'openclaw-weixin'); }
|
||||
function _resolveAccountIndexPath(){ return path.join(_resolveWeixinStateDir(), 'accounts.json'); }
|
||||
function _resolveAccountsDir() { return path.join(_resolveWeixinStateDir(), 'accounts'); }
|
||||
function _resolveAccountPath(id) { return path.join(_resolveAccountsDir(), `${id}.json`); }
|
||||
|
||||
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
||||
const INVALID_CHARS = /[^a-z0-9_-]+/g;
|
||||
|
||||
function _normalizeAccountId(value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) return 'default';
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (VALID_ID_RE.test(trimmed)) return lower;
|
||||
return lower.replace(INVALID_CHARS, '-').replace(/^-+/, '').replace(/-+$/, '').slice(0, 64) || 'default';
|
||||
}
|
||||
|
||||
function _listIndexedAccountIds() {
|
||||
try {
|
||||
const p = _resolveAccountIndexPath();
|
||||
if (!fs.existsSync(p)) return [];
|
||||
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
return Array.isArray(parsed) ? parsed.filter(id => typeof id === 'string' && id.trim()) : [];
|
||||
} catch (_) { return []; }
|
||||
}
|
||||
|
||||
function _registerAccountId(accountId) {
|
||||
fs.mkdirSync(_resolveWeixinStateDir(), { recursive: true });
|
||||
const existing = _listIndexedAccountIds();
|
||||
if (existing.includes(accountId)) return;
|
||||
const indexPath = _resolveAccountIndexPath();
|
||||
fs.writeFileSync(indexPath, JSON.stringify([...existing, accountId], null, 2), 'utf8');
|
||||
_ensureStsOwnership(indexPath);
|
||||
}
|
||||
|
||||
function _loadAccount(accountId) {
|
||||
try {
|
||||
const p = _resolveAccountPath(accountId);
|
||||
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
} catch (_) {}
|
||||
// legacy token fallback
|
||||
try {
|
||||
const legacyPath = path.join(_resolveStateDir(), 'credentials', 'openclaw-weixin', 'credentials.json');
|
||||
if (fs.existsSync(legacyPath)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(legacyPath, 'utf8'));
|
||||
if (typeof parsed.token === 'string') return { token: parsed.token };
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _saveAccount(accountId, update) {
|
||||
fs.mkdirSync(_resolveAccountsDir(), { recursive: true });
|
||||
const existing = _loadAccount(accountId) || {};
|
||||
const token = (update.token || '').trim() || existing.token;
|
||||
const baseUrl = (update.baseUrl || '').trim() || existing.baseUrl;
|
||||
const userId = (update.userId || '').trim() || (existing.userId || '').trim() || undefined;
|
||||
const data = {
|
||||
...(token ? { token, savedAt: new Date().toISOString() } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(userId ? { userId } : {}),
|
||||
};
|
||||
const filePath = _resolveAccountPath(accountId);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
_ensureStsOwnership(filePath);
|
||||
try { fs.chmodSync(filePath, 0o600); } catch (_) {}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function _clearStaleAccountsForUserId(currentAccountId, userId) {
|
||||
if (!userId) return;
|
||||
for (const id of _listIndexedAccountIds()) {
|
||||
if (id === currentAccountId) continue;
|
||||
const data = _loadAccount(id);
|
||||
if (data && (data.userId || '').trim() === userId) {
|
||||
log.info('weixin', `removing stale account with same userId: ${id}`);
|
||||
try { fs.unlinkSync(_resolveAccountPath(id)); } catch (_) {}
|
||||
const existing = _listIndexedAccountIds();
|
||||
const indexPath = _resolveAccountIndexPath();
|
||||
fs.writeFileSync(indexPath, JSON.stringify(existing.filter(x => x !== id), null, 2), 'utf8');
|
||||
_ensureStsOwnership(indexPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _getLocalBotTokenList() {
|
||||
const tokens = [];
|
||||
const ids = _listIndexedAccountIds();
|
||||
for (let i = ids.length - 1; i >= 0 && tokens.length < 10; i--) {
|
||||
const token = (_loadAccount(ids[i]) || {}).token;
|
||||
if (token && token.trim()) tokens.push(token.trim());
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _commonHeaders() {
|
||||
return {
|
||||
'iLink-App-Id': ILINK_APP_ID,
|
||||
'iLink-App-ClientVersion': ILINK_APP_CLIENT_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
function _randomWechatUin() {
|
||||
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
||||
return Buffer.from(String(uint32), 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
function _postHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'AuthorizationType': 'ilink_bot_token',
|
||||
'X-WECHAT-UIN': _randomWechatUin(),
|
||||
..._commonHeaders(),
|
||||
};
|
||||
}
|
||||
|
||||
async function _postJson(baseUrl, endpoint, body, timeoutMs) {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/${endpoint}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs || 15_000);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: _postHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
|
||||
return JSON.parse(text);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function _getJson(baseUrl, endpoint, timeoutMs) {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/${endpoint}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs || QR_LONG_POLL_TIMEOUT_MS + 5_000);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: _commonHeaders(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
|
||||
return JSON.parse(text);
|
||||
} catch (err) {
|
||||
if (err && err.name === 'AbortError') return { status: 'wait' };
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ── WeChat API calls ──────────────────────────────────────────────────────────
|
||||
|
||||
async function _fetchQRCode(botType) {
|
||||
const localTokenList = _getLocalBotTokenList();
|
||||
const data = await _postJson(
|
||||
FIXED_BASE_URL,
|
||||
`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
|
||||
{ local_token_list: localTokenList },
|
||||
15_000
|
||||
);
|
||||
if (data.ret !== undefined && data.ret !== 0) {
|
||||
throw new Error(`get_bot_qrcode ret=${data.ret} errmsg=${data.errmsg || ''}`);
|
||||
}
|
||||
if (!data.qrcode || !data.qrcode_img_content) {
|
||||
throw new Error(`get_bot_qrcode response missing qrcode/qrcode_img_content`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function _pollQRStatus(apiBaseUrl, qrcode) {
|
||||
try {
|
||||
const endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
|
||||
const data = await _getJson(apiBaseUrl, endpoint, QR_LONG_POLL_TIMEOUT_MS);
|
||||
if (data.ret !== undefined && data.ret !== 0) {
|
||||
throw new Error(`get_qrcode_status ret=${data.ret} errmsg=${data.errmsg || ''}`);
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err && err.name === 'AbortError') return { status: 'wait' };
|
||||
log.warn('weixin', `pollQRStatus error (will retry): ${err.message}`);
|
||||
return { status: 'wait' };
|
||||
}
|
||||
}
|
||||
|
||||
function _bumpOpenClawConfigTimestamp() {
|
||||
const stateDir = _resolveStateDir();
|
||||
const candidates = [
|
||||
process.env.OPENCLAW_CONFIG || '',
|
||||
path.join(stateDir, 'openclaw.json'),
|
||||
].filter(Boolean);
|
||||
const configPath = candidates.find(p => { try { return fs.existsSync(p); } catch (_) { return false; } });
|
||||
if (!configPath) return;
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (!cfg.channels) cfg.channels = {};
|
||||
if (!cfg.channels['openclaw-weixin']) cfg.channels['openclaw-weixin'] = {};
|
||||
cfg.channels['openclaw-weixin'].channelConfigUpdatedAt = new Date().toISOString();
|
||||
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
|
||||
log.info('weixin', `bumped openclaw.json timestamp: ${configPath}`);
|
||||
} catch (err) {
|
||||
log.warn('weixin', `bump timestamp failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── login() ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start WeChat QR login.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.callId - sys-call id (for logging)
|
||||
* @param {number} opts.timeout - overall timeout in seconds (default 180)
|
||||
* @param {string} opts.botType - ilink bot type (default '3')
|
||||
* @param {function} opts.emit - emit(payload) send event upstream
|
||||
* @returns {{ abort: function, onReply: function }} task handle
|
||||
*/
|
||||
function login({ callId, timeout = 180, botType = DEFAULT_BOT_TYPE, emit }) {
|
||||
let aborted = false;
|
||||
let finished = false;
|
||||
|
||||
function abort() {
|
||||
aborted = true;
|
||||
log.info('weixin', `callId=${callId} aborted`);
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(timeout * 1000, 30_000);
|
||||
|
||||
// Run async without blocking caller
|
||||
_runLogin({ callId, timeoutMs, botType, emit, isAborted: () => aborted })
|
||||
.then(() => { finished = true; })
|
||||
.catch((err) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
log.error('weixin', `callId=${callId} login error: ${err.message}`);
|
||||
emit({ action: 'finish', event: 'failed', code: 500, message: err.message });
|
||||
});
|
||||
|
||||
return { abort };
|
||||
}
|
||||
|
||||
async function _runLogin({ callId, timeoutMs, botType, emit, isAborted }) {
|
||||
let qrRefreshCount = 1;
|
||||
let scannedEmitted = false;
|
||||
let activeLogin = null;
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
async function startOrRefreshQr() {
|
||||
const qrData = await _fetchQRCode(botType);
|
||||
activeLogin = {
|
||||
qrcode: qrData.qrcode,
|
||||
qrcodeUrl: qrData.qrcode_img_content,
|
||||
startedAt: Date.now(),
|
||||
apiBaseUrl: FIXED_BASE_URL,
|
||||
};
|
||||
emit({
|
||||
action: 'event',
|
||||
event: 'qrcode',
|
||||
// url = qrcode_img_content: the WeChat URL encoded inside the QR image (use this for QR rendering)
|
||||
// code = qrcode: the polling ticket used by _pollQRStatus (NOT for QR rendering)
|
||||
data: { url: activeLogin.qrcodeUrl, code: activeLogin.qrcode, expire: 30, index: qrRefreshCount },
|
||||
});
|
||||
log.info('weixin', `callId=${callId} qrcode emitted index=${qrRefreshCount}`);
|
||||
}
|
||||
|
||||
await startOrRefreshQr();
|
||||
|
||||
while (!isAborted() && Date.now() < deadline) {
|
||||
// Refresh if local TTL exceeded
|
||||
if (Date.now() - activeLogin.startedAt >= ACTIVE_LOGIN_TTL_MS) {
|
||||
log.info('weixin', `callId=${callId} QR TTL expired, refreshing`);
|
||||
qrRefreshCount++;
|
||||
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
|
||||
emit({ action: 'finish', event: 'failed', code: 1003, message: 'QR expired too many times' });
|
||||
return;
|
||||
}
|
||||
await startOrRefreshQr();
|
||||
scannedEmitted = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = await _pollQRStatus(activeLogin.apiBaseUrl, activeLogin.qrcode);
|
||||
|
||||
if (isAborted()) return;
|
||||
|
||||
switch (status.status) {
|
||||
case 'wait':
|
||||
break;
|
||||
|
||||
case 'scaned':
|
||||
case 'scaned_but_redirect': {
|
||||
if (status.redirect_host) {
|
||||
activeLogin.apiBaseUrl = `https://${status.redirect_host}`;
|
||||
}
|
||||
if (!scannedEmitted) {
|
||||
scannedEmitted = true;
|
||||
emit({ action: 'progress', event: 'scanned', data: { status: 'waiting_confirm' } });
|
||||
log.info('weixin', `callId=${callId} scanned`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'need_verifycode': {
|
||||
// Verify code entry not supported; treat as failure
|
||||
log.warn('weixin', `callId=${callId} need_verifycode not supported`);
|
||||
emit({ action: 'finish', event: 'failed', code: 1005, message: 'verify code required (not supported)' });
|
||||
return;
|
||||
}
|
||||
|
||||
case 'expired': {
|
||||
log.info('weixin', `callId=${callId} QR expired, refreshing`);
|
||||
qrRefreshCount++;
|
||||
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
|
||||
emit({ action: 'finish', event: 'failed', code: 1001, message: 'qrcode expired' });
|
||||
return;
|
||||
}
|
||||
await startOrRefreshQr();
|
||||
scannedEmitted = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'verify_code_blocked': {
|
||||
log.warn('weixin', `callId=${callId} verify_code_blocked`);
|
||||
qrRefreshCount++;
|
||||
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
|
||||
emit({ action: 'finish', event: 'failed', code: 1002, message: 'verify_code blocked' });
|
||||
return;
|
||||
}
|
||||
await startOrRefreshQr();
|
||||
scannedEmitted = false;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'binded_redirect': {
|
||||
// WeChat server considers this OpenClaw already bound.
|
||||
// Check if local token exists — if yes, treat as success; if not, local state was
|
||||
// cleared (e.g. logout) but remote binding wasn't revoked → state inconsistency.
|
||||
const indexedIds = _listIndexedAccountIds();
|
||||
const hasLocalToken = indexedIds.some(id => {
|
||||
const acct = _loadAccount(id);
|
||||
return acct && acct.token;
|
||||
});
|
||||
if (hasLocalToken) {
|
||||
log.info('weixin', `callId=${callId} already connected, local token intact`);
|
||||
emit({ action: 'finish', event: 'success', data: { accountId: indexedIds[0] } });
|
||||
} else {
|
||||
log.warn('weixin', `callId=${callId} binded_redirect but local token missing — state inconsistency`);
|
||||
emit({
|
||||
action: 'finish', event: 'failed', code: 1010,
|
||||
message: '此微信账号已绑定过本设备,但本地登录记录已丢失。请换用另一个微信账号扫码重新登录。',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case 'confirmed': {
|
||||
if (!status.ilink_bot_id) throw new Error('confirmed: missing ilink_bot_id');
|
||||
if (!status.bot_token) throw new Error('confirmed: missing bot_token');
|
||||
const accountId = _normalizeAccountId(status.ilink_bot_id);
|
||||
const filePath = _saveAccount(accountId, {
|
||||
token: status.bot_token,
|
||||
baseUrl: status.baseurl,
|
||||
userId: status.ilink_user_id,
|
||||
});
|
||||
_registerAccountId(accountId);
|
||||
if (status.ilink_user_id) _clearStaleAccountsForUserId(accountId, status.ilink_user_id);
|
||||
_bumpOpenClawConfigTimestamp();
|
||||
log.info('weixin', `callId=${callId} login success accountId=${accountId} file=${filePath}`);
|
||||
emit({ action: 'finish', event: 'success', data: { accountId } });
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
log.warn('weixin', `callId=${callId} unknown status: ${JSON.stringify(status)}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Brief pause between polls to avoid tight looping on 'wait'
|
||||
if (status.status === 'wait') {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAborted()) {
|
||||
emit({ action: 'finish', event: 'failed', code: 1004, message: 'login timeout' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { login };
|
||||
1461
lib/client.js
1461
lib/client.js
File diff suppressed because it is too large
Load Diff
@@ -19,19 +19,68 @@ const DEFAULTS = {
|
||||
heartbeat_interval: 30, // 秒
|
||||
/** 云端已激活:用于启动/重连时立即点亮 alarm(pwr),不等首包 connected */
|
||||
activated: false,
|
||||
ssh_secret_key: null,
|
||||
share_key: null,
|
||||
headscale_server: 'https://hs.claw.cutos.ai',
|
||||
};
|
||||
|
||||
function _generateSshSecretKey() {
|
||||
const bytes = require('crypto').randomBytes(16);
|
||||
return 'sk-' + bytes.toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成可读性好的 Samba 共享密码,格式:xxxxxx-xxxxxx-xxxxXX(三段各6字符,连字符分隔)。
|
||||
* 最后一段包含至少1个大写字母和1个数字,其余为小写字母。
|
||||
* 示例:juqhuf-hykgyh-mykGi3
|
||||
*/
|
||||
function _generateShareKey() {
|
||||
const crypto = require('crypto');
|
||||
const lower = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const digits = '0123456789';
|
||||
|
||||
function rndChars(chars, n) {
|
||||
const bytes = crypto.randomBytes(n);
|
||||
return Array.from(bytes).map(b => chars[b % chars.length]).join('');
|
||||
}
|
||||
|
||||
const g1 = rndChars(lower, 6);
|
||||
const g2 = rndChars(lower, 6);
|
||||
// 第三段:4个小写 + 1个大写 + 1个数字,随机打乱顺序
|
||||
const g3raw = (rndChars(lower, 4) + rndChars(upper, 1) + rndChars(digits, 1)).split('');
|
||||
for (let i = g3raw.length - 1; i > 0; i--) {
|
||||
const j = crypto.randomBytes(1)[0] % (i + 1);
|
||||
[g3raw[i], g3raw[j]] = [g3raw[j], g3raw[i]];
|
||||
}
|
||||
return `${g1}-${g2}-${g3raw.join('')}`;
|
||||
}
|
||||
|
||||
function load() {
|
||||
let cfg;
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||
return Object.assign({}, DEFAULTS, JSON.parse(raw));
|
||||
cfg = Object.assign({}, DEFAULTS, JSON.parse(raw));
|
||||
}
|
||||
} catch (e) {
|
||||
const log = require('./logger');
|
||||
log.error('config', '读取配置失败,使用默认值:', e.message);
|
||||
}
|
||||
return Object.assign({}, DEFAULTS);
|
||||
if (!cfg) cfg = Object.assign({}, DEFAULTS);
|
||||
|
||||
let dirty = false;
|
||||
if (!cfg.ssh_secret_key) {
|
||||
cfg.ssh_secret_key = _generateSshSecretKey();
|
||||
dirty = true;
|
||||
}
|
||||
if (!cfg.share_key) {
|
||||
cfg.share_key = _generateShareKey();
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) save(cfg);
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function save(data) {
|
||||
|
||||
20
lib/frpc.js
20
lib/frpc.js
@@ -143,11 +143,16 @@ function downloadFile(url, dest) {
|
||||
});
|
||||
}
|
||||
|
||||
function writeFrpcConfig(clawId, frpConfig) {
|
||||
function writeFrpcConfig(clawId, frpConfig, sshSecretKey) {
|
||||
const { auth_token, dashboard_local_port = 18789 } = frpConfig;
|
||||
const ttyRemotePort = 10000 + Number(clawId);
|
||||
// 固定使用 WebSocket over HTTPS (443),可穿透仅开放 443 的网络环境;
|
||||
// 旧版 clawd 仍使用后端下发的 server:7000(TCP),两者并存互不影响。
|
||||
const stcpBlock = sshSecretKey ? `
|
||||
[[proxies]]
|
||||
name = "ssh-${clawId}-secret"
|
||||
type = "stcp"
|
||||
secretKey = "${sshSecretKey}"
|
||||
localPort = 22
|
||||
` : '';
|
||||
const toml = `# 由 clawd 自动生成,请勿手动修改
|
||||
serverAddr = "frp.claw.cutos.ai"
|
||||
serverPort = 443
|
||||
@@ -157,7 +162,6 @@ method = "token"
|
||||
token = "${auth_token}"
|
||||
|
||||
[transport]
|
||||
protocol = "websocket"
|
||||
tls.enable = true
|
||||
|
||||
[[proxies]]
|
||||
@@ -171,10 +175,10 @@ name = "tty-${clawId}"
|
||||
type = "tcp"
|
||||
localPort = ${TTYD_PORT}
|
||||
remotePort = ${ttyRemotePort}
|
||||
`;
|
||||
${stcpBlock}`;
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(FRPC_CONFIG, toml, 'utf8');
|
||||
log.info('frpc', `frpc.toml 已写入: dashboard subdomain=${clawId}, tty tcp-port=${ttyRemotePort}`);
|
||||
log.info('frpc', `frpc.toml 已写入: dashboard subdomain=${clawId}, tty tcp-port=${ttyRemotePort}${sshSecretKey ? ', ssh stcp=enabled' : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,7 +190,7 @@ class FrpcManager {
|
||||
this._watchdog = null;
|
||||
}
|
||||
|
||||
async start(clawId, frpConfig) {
|
||||
async start(clawId, frpConfig, sshSecretKey) {
|
||||
this.stop();
|
||||
|
||||
if (!fs.existsSync(FRPC_BIN)) {
|
||||
@@ -198,7 +202,7 @@ class FrpcManager {
|
||||
}
|
||||
}
|
||||
|
||||
writeFrpcConfig(clawId, frpConfig);
|
||||
writeFrpcConfig(clawId, frpConfig, sshSecretKey);
|
||||
|
||||
this._watchdog = new Watchdog('frpc', FRPC_BIN, ['-c', FRPC_CONFIG], {
|
||||
maxRestarts: 10,
|
||||
|
||||
93
lib/headscale.js
Normal file
93
lib/headscale.js
Normal file
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const { execSync, exec } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const log = require('./logger');
|
||||
|
||||
const TAILSCALE_BIN_CANDIDATES = [
|
||||
'/usr/bin/tailscale',
|
||||
'/usr/local/bin/tailscale',
|
||||
'/sbin/tailscale',
|
||||
];
|
||||
|
||||
/** 查找 tailscale 可执行文件路径,找不到返回 null */
|
||||
function findTailscaleBin() {
|
||||
for (const p of TAILSCALE_BIN_CANDIDATES) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** tailscale 是否已安装 */
|
||||
function isInstalled() {
|
||||
return findTailscaleBin() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已加入指定 headscale 服务端。
|
||||
* @param {string} loginServer headscale 服务地址,如 https://hs.claw.cutos.ai
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isJoined(loginServer) {
|
||||
const bin = findTailscaleBin();
|
||||
if (!bin) return false;
|
||||
try {
|
||||
const out = execSync(`${bin} status --json`, { timeout: 5000 }).toString();
|
||||
const status = JSON.parse(out);
|
||||
if (status.BackendState !== 'Running') return false;
|
||||
const currentServer = (status.CurrentTailnet?.MagicDNSSuffix || '')
|
||||
|| status.ControlURL || '';
|
||||
// 检查是否连接到同一 headscale 实例(比对 hostname)
|
||||
const serverHost = new URL(loginServer).hostname;
|
||||
return currentServer.includes(serverHost) || (status.Self?.Online === true);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入 headscale mesh 网络。
|
||||
* @param {string} loginServer headscale 服务地址
|
||||
* @param {string} authkey 一次性 preauth key
|
||||
* @param {string} hostname 节点 hostname(如 claw-1014)
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
function join(loginServer, authkey, hostname) {
|
||||
return new Promise((resolve) => {
|
||||
const bin = findTailscaleBin();
|
||||
if (!bin) {
|
||||
log.warn('headscale', 'tailscale 未安装,跳过 mesh 注册');
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const cmd = `${bin} up --login-server "${loginServer}" --authkey "${authkey}" --hostname "${hostname}" --accept-routes`;
|
||||
log.info('headscale', `加入 mesh: ${loginServer}, hostname=${hostname}`);
|
||||
exec(cmd, { timeout: 30_000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
log.error('headscale', `tailscale up 失败: ${stderr || err.message}`);
|
||||
resolve(false);
|
||||
} else {
|
||||
log.info('headscale', `成功加入 mesh: ${stdout.trim() || 'ok'}`);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出 headscale mesh 网络(解绑时调用)。
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function logout() {
|
||||
return new Promise((resolve) => {
|
||||
const bin = findTailscaleBin();
|
||||
if (!bin) { resolve(); return; }
|
||||
exec(`${bin} logout`, { timeout: 10_000 }, (err) => {
|
||||
if (err) log.warn('headscale', `tailscale logout 失败: ${err.message}`);
|
||||
else log.info('headscale', 'tailscale logout 成功');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { isInstalled, isJoined, join, logout };
|
||||
401
lib/led.js
401
lib/led.js
@@ -1,356 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const log = require('./logger');
|
||||
const { hasLanCableCarrier } = require('./network');
|
||||
|
||||
/**
|
||||
* OpenVFD 图标:/sys/class/leds/openvfd/led_on|led_off(写入图标名)。
|
||||
*
|
||||
* 映射(与面板丝印一致:LAN=play,WiFi=wifi+eth):
|
||||
* wifi + eth 同亮/同灭 → 产品 WiFi 灯(配网 on/off/blink)
|
||||
* play → LAN(有线插拔,见 hasLanCableCarrier / CLAWD_ETH_IFACE)
|
||||
* alarm → pwr(SETUP=灭 / APPS=亮)
|
||||
* BT → 无 sysfs,仅日志
|
||||
*
|
||||
* 数码管:vfdservice 管道(660 字节,mode=4 TITLE / mode=1 CLOCK,与板端 Demo 一致)。
|
||||
* CLAWD_VFD_PIPE 默认 /tmp/openvfd_service;管道不存在时仅 debug,不抛错。
|
||||
*
|
||||
* CLAWD_OPENVFD_PATH 默认 /sys/class/leds/openvfd(图标灯)
|
||||
*/
|
||||
|
||||
const BLINK_INTERVAL_MS = 500;
|
||||
const LAN_POLL_MS = 500;
|
||||
|
||||
const VFD_BASE = process.env.CLAWD_OPENVFD_PATH || '/sys/class/leds/openvfd';
|
||||
const VFD_PIPE = process.env.CLAWD_VFD_PIPE || '/tmp/openvfd_service';
|
||||
const VFD_BUF_SIZE = 660;
|
||||
const VFD_MODE_CLOCK = 1;
|
||||
const VFD_MODE_TITLE = 4;
|
||||
const VFD_TITLE_OFF = 20;
|
||||
|
||||
function vfdPipePresent() {
|
||||
try {
|
||||
return fs.existsSync(VFD_PIPE);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** TITLE:固定 4 字符,ASCII,供 vfdservice 解析为段码 */
|
||||
function vfdTitleNormalize(raw) {
|
||||
return String(raw || '')
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9 \-]/g, ' ')
|
||||
.slice(0, 4)
|
||||
.padEnd(4, ' ');
|
||||
}
|
||||
|
||||
// O_WRONLY | O_NONBLOCK:FIFO 无读端时立即抛 ENXIO 而非永久阻塞事件循环
|
||||
const VFD_OPEN_FLAGS = fs.constants.O_WRONLY | fs.constants.O_NONBLOCK;
|
||||
|
||||
function writeVfdPipeTitle(text4) {
|
||||
if (!vfdPipePresent()) return;
|
||||
const seg = vfdTitleNormalize(text4);
|
||||
const data = Buffer.alloc(VFD_BUF_SIZE);
|
||||
data.writeUInt16LE(VFD_MODE_TITLE, 0);
|
||||
try {
|
||||
data.write(`${seg}\0`, VFD_TITLE_OFF, 'ascii');
|
||||
} catch (e) {
|
||||
log.debug('display', `openvfd title encode: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fd = fs.openSync(VFD_PIPE, VFD_OPEN_FLAGS);
|
||||
fs.writeSync(fd, data);
|
||||
fs.closeSync(fd);
|
||||
} catch (e) {
|
||||
log.debug('display', `openvfd pipe title: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeVfdPipeClock() {
|
||||
if (!vfdPipePresent()) return;
|
||||
const data = Buffer.alloc(VFD_BUF_SIZE);
|
||||
data.writeUInt16LE(VFD_MODE_CLOCK, 0);
|
||||
try {
|
||||
const fd = fs.openSync(VFD_PIPE, VFD_OPEN_FLAGS);
|
||||
fs.writeSync(fd, data);
|
||||
fs.closeSync(fd);
|
||||
} catch (e) {
|
||||
log.debug('display', `openvfd pipe clock: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function vfdOn(icon) {
|
||||
try {
|
||||
fs.writeFileSync(`${VFD_BASE}/led_on`, icon);
|
||||
} catch (e) {
|
||||
log.debug('led', `openvfd led_on ${icon}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function vfdOff(icon) {
|
||||
try {
|
||||
fs.writeFileSync(`${VFD_BASE}/led_off`, icon);
|
||||
} catch (e) {
|
||||
log.debug('led', `openvfd led_off ${icon}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function vfdWifiPair(on) {
|
||||
if (on) {
|
||||
vfdOn('wifi');
|
||||
vfdOn('eth');
|
||||
} else {
|
||||
vfdOff('wifi');
|
||||
vfdOff('eth');
|
||||
}
|
||||
}
|
||||
|
||||
class WifiLed {
|
||||
constructor() {
|
||||
this._blinkTimer = null;
|
||||
this._blinkState = false;
|
||||
this._current = null;
|
||||
}
|
||||
|
||||
on() {
|
||||
if (this._current === 'on') return;
|
||||
this._stopBlink();
|
||||
this._write(1);
|
||||
this._current = 'on';
|
||||
log.info('led', 'WiFi 指示灯 → 常亮');
|
||||
}
|
||||
|
||||
off() {
|
||||
if (this._current === 'off') return;
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
log.info('led', 'WiFi 指示灯 → 熄灭');
|
||||
}
|
||||
|
||||
blink(intervalMs = BLINK_INTERVAL_MS) {
|
||||
if (this._current === 'blink') return;
|
||||
this._stopBlink();
|
||||
this._blinkState = true;
|
||||
this._write(1);
|
||||
this._blinkTimer = setInterval(() => {
|
||||
this._blinkState = !this._blinkState;
|
||||
this._write(this._blinkState ? 1 : 0);
|
||||
}, intervalMs);
|
||||
this._current = 'blink';
|
||||
log.info('led', 'WiFi 指示灯 → 闪烁');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
}
|
||||
|
||||
_stopBlink() {
|
||||
if (this._blinkTimer) {
|
||||
clearInterval(this._blinkTimer);
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_write(val) {
|
||||
const on = !!val;
|
||||
log.debug('led', `[vfd] WiFi(wifi+eth)<= ${on ? 1 : 0}`);
|
||||
vfdWifiPair(on);
|
||||
}
|
||||
}
|
||||
|
||||
class BtLed {
|
||||
constructor() {
|
||||
this._blinkTimer = null;
|
||||
this._blinkState = false;
|
||||
this._current = null;
|
||||
}
|
||||
|
||||
on() {
|
||||
if (this._current === 'on') return;
|
||||
this._stopBlink();
|
||||
this._write(1);
|
||||
this._current = 'on';
|
||||
log.info('led', 'BT 指示灯 → 常亮');
|
||||
}
|
||||
|
||||
off() {
|
||||
if (this._current === 'off') return;
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
log.info('led', 'BT 指示灯 → 熄灭');
|
||||
}
|
||||
|
||||
blink(intervalMs = BLINK_INTERVAL_MS) {
|
||||
if (this._current === 'blink') return;
|
||||
this._stopBlink();
|
||||
this._blinkState = true;
|
||||
this._write(1);
|
||||
this._blinkTimer = setInterval(() => {
|
||||
this._blinkState = !this._blinkState;
|
||||
this._write(this._blinkState ? 1 : 0);
|
||||
}, intervalMs);
|
||||
this._current = 'blink';
|
||||
log.info('led', 'BT 指示灯 → 闪烁');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
}
|
||||
|
||||
_stopBlink() {
|
||||
if (this._blinkTimer) {
|
||||
clearInterval(this._blinkTimer);
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_write(_val) {
|
||||
log.debug('led', '[vfd] BT 无 OpenVFD 映射,忽略');
|
||||
}
|
||||
}
|
||||
|
||||
class Display {
|
||||
constructor() {
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
|
||||
showAP() {
|
||||
this._stopBlink();
|
||||
this._pipeTitle('AP ', '#m3AP ');
|
||||
log.info('display', '显示屏 → AP(闪烁)');
|
||||
let visible = true;
|
||||
const blink = () => {
|
||||
visible = !visible;
|
||||
this._pipeTitle(visible ? 'AP ' : ' ', visible ? '#m3AP ' : '#c1');
|
||||
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
||||
};
|
||||
this._blinkTimer = setTimeout(blink, 1000);
|
||||
}
|
||||
|
||||
showConn() {
|
||||
this._stopBlink();
|
||||
this._pipeTitle('Conn', '#m3Conn');
|
||||
log.info('display', '显示屏 → Conn(闪烁)');
|
||||
let visible = true;
|
||||
const blink = () => {
|
||||
visible = !visible;
|
||||
this._pipeTitle(visible ? 'Conn' : ' ', visible ? '#m3Conn' : '#c1');
|
||||
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
||||
};
|
||||
this._blinkTimer = setTimeout(blink, 1000);
|
||||
}
|
||||
|
||||
showErr0() {
|
||||
this._stopBlink();
|
||||
this._pipeTitle('Err0', '#m3Err0');
|
||||
log.info('display', '显示屏 → Err0');
|
||||
}
|
||||
|
||||
showTime() {
|
||||
this._stopBlink();
|
||||
writeVfdPipeClock();
|
||||
log.info('display', '显示屏 → 时间');
|
||||
}
|
||||
|
||||
showPin(pin) {
|
||||
this._stopBlink();
|
||||
const s = String(pin || '').padStart(4, '0').slice(-4);
|
||||
this._pipeTitle(s, '#m2' + s);
|
||||
log.info('display', `显示屏 → PIN: ${s}(闪烁)`);
|
||||
let visible = true;
|
||||
const blink = () => {
|
||||
visible = !visible;
|
||||
this._pipeTitle(visible ? s : ' ', visible ? '#m2' + s : '#c1');
|
||||
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
||||
};
|
||||
this._blinkTimer = setTimeout(blink, 1000);
|
||||
}
|
||||
|
||||
_stopBlink() {
|
||||
if (this._blinkTimer) {
|
||||
clearTimeout(this._blinkTimer);
|
||||
clearInterval(this._blinkTimer);
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留原 #m3/#m2/#c1 的 debug 语义,并写 vfdservice TITLE */
|
||||
_pipeTitle(fourCharText, debugLegacy) {
|
||||
log.debug('display', `[vfd] ${debugLegacy}`);
|
||||
writeVfdPipeTitle(fourCharText);
|
||||
}
|
||||
}
|
||||
|
||||
class StatusLed {
|
||||
setSetup() {
|
||||
vfdOff('alarm');
|
||||
log.debug('led', '[vfd] alarm(pwr)<= 0');
|
||||
log.info('led', '状态灯 → SETUP(未激活)');
|
||||
}
|
||||
|
||||
setApps() {
|
||||
vfdOn('alarm');
|
||||
// 部分 OpenVFD 驱动单次写入生效慢,短延迟再写一次
|
||||
setTimeout(() => vfdOn('alarm'), 50);
|
||||
log.debug('led', '[vfd] alarm(pwr)<= 1');
|
||||
log.info('led', '状态灯 → APPS(已激活)');
|
||||
}
|
||||
|
||||
off() {
|
||||
vfdOff('alarm');
|
||||
log.debug('led', '[vfd] alarm(pwr)<= 0 (off)');
|
||||
}
|
||||
}
|
||||
|
||||
class LanLed {
|
||||
constructor() {
|
||||
this._timer = null;
|
||||
this._current = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this._sync();
|
||||
this._timer = setInterval(() => this._sync(), LAN_POLL_MS);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
vfdOff('play');
|
||||
this._current = null;
|
||||
}
|
||||
|
||||
_sync() {
|
||||
const up = hasLanCableCarrier();
|
||||
if (up) {
|
||||
if (this._current !== 'on') {
|
||||
vfdOn('play');
|
||||
this._current = 'on';
|
||||
log.info('led', 'LAN(play / 有线 carrier)→ 亮');
|
||||
}
|
||||
} else if (this._current !== 'off') {
|
||||
vfdOff('play');
|
||||
this._current = 'off';
|
||||
log.info('led', 'LAN(play / 有线 carrier)→ 灭');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lan = new LanLed();
|
||||
|
||||
module.exports = new WifiLed();
|
||||
module.exports.bt = new BtLed();
|
||||
module.exports.status = new StatusLed();
|
||||
module.exports.display = new Display();
|
||||
module.exports.lan = lan;
|
||||
'use strict';
|
||||
|
||||
const log = require('./logger');
|
||||
const { isRK3566, isRK3588, readDeviceModel } = require('./led/detect');
|
||||
|
||||
function loadImpl() {
|
||||
const forced = String(process.env.CLAWD_LED_IMPL || '').trim().toLowerCase();
|
||||
const model = readDeviceModel();
|
||||
|
||||
let name;
|
||||
if (forced) {
|
||||
name = forced;
|
||||
} else if (isRK3588()) {
|
||||
name = 'rk3588-lvgl';
|
||||
} else if (isRK3566()) {
|
||||
name = 'rk3566';
|
||||
} else {
|
||||
name = 'openvfd';
|
||||
}
|
||||
|
||||
try {
|
||||
if (name === 'rk3566' || name === '3566') {
|
||||
log.info('led', `LED/VFD backend → rk3566-openvfd (${model || 'unknown model'})`);
|
||||
return require('./led/rk3566-openvfd');
|
||||
}
|
||||
if (name === 'rk3588-lvgl' || name === '3588' || name === 'rk3588') {
|
||||
log.info('led', `LED/VFD backend → rk3588-lvgl (${model || 'unknown model'})`);
|
||||
return require('./led/rk3588-lvgl');
|
||||
}
|
||||
if (name === 'noop' || name === 'none' || name === 'off') {
|
||||
log.info('led', `LED/VFD backend → noop (${model || 'unknown model'})`);
|
||||
return require('./led/noop');
|
||||
}
|
||||
if (name !== 'openvfd' && name !== 'default') {
|
||||
log.warn('led', `未知 CLAWD_LED_IMPL=${name},回退 openvfd`);
|
||||
}
|
||||
log.info('led', `LED/VFD backend → openvfd-class (${model || 'unknown model'})`);
|
||||
return require('./led/openvfd-class');
|
||||
} catch (e) {
|
||||
log.warn('led', `LED/VFD backend ${name} 加载失败:${e.message},回退 noop`);
|
||||
return require('./led/noop');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = loadImpl();
|
||||
|
||||
27
lib/led/detect.js
Normal file
27
lib/led/detect.js
Normal file
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
function readDeviceModel() {
|
||||
try {
|
||||
return fs.readFileSync('/proc/device-tree/model', 'utf8')
|
||||
.replace(/\0/g, '')
|
||||
.trim();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isRK3566() {
|
||||
return /RK3566/i.test(readDeviceModel());
|
||||
}
|
||||
|
||||
function isRK3588() {
|
||||
return /RK3588/i.test(readDeviceModel());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readDeviceModel,
|
||||
isRK3566,
|
||||
isRK3588,
|
||||
};
|
||||
41
lib/led/noop.js
Normal file
41
lib/led/noop.js
Normal file
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
const log = require('../logger');
|
||||
|
||||
class BasicLed {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this._current = null;
|
||||
}
|
||||
on() { this._current = 'on'; log.debug('led', `[noop] ${this.name} on`); }
|
||||
off() { this._current = 'off'; log.debug('led', `[noop] ${this.name} off`); }
|
||||
blink() { this._current = 'blink'; log.debug('led', `[noop] ${this.name} blink`); }
|
||||
destroy() { this._current = 'off'; log.debug('led', `[noop] ${this.name} destroy`); }
|
||||
}
|
||||
|
||||
class StatusLed {
|
||||
setSetup() { log.debug('led', '[noop] status setup'); }
|
||||
setApps() { log.debug('led', '[noop] status apps'); }
|
||||
off() { log.debug('led', '[noop] status off'); }
|
||||
}
|
||||
|
||||
class Display {
|
||||
showAP() { log.debug('display', '[noop] AP'); }
|
||||
showConn() { log.debug('display', '[noop] Conn'); }
|
||||
showErr0() { log.debug('display', '[noop] Err0'); }
|
||||
showTime() { log.debug('display', '[noop] time'); }
|
||||
showPin(pin) { log.debug('display', `[noop] PIN ${pin}`); }
|
||||
}
|
||||
|
||||
class LanLed {
|
||||
start() { log.debug('led', '[noop] LAN start ignored'); }
|
||||
stop() { log.debug('led', '[noop] LAN stop ignored'); }
|
||||
}
|
||||
|
||||
const led = new BasicLed('wifi');
|
||||
led.bt = new BasicLed('bt');
|
||||
led.status = new StatusLed();
|
||||
led.display = new Display();
|
||||
led.lan = new LanLed();
|
||||
|
||||
module.exports = led;
|
||||
356
lib/led/openvfd-class.js
Normal file
356
lib/led/openvfd-class.js
Normal file
@@ -0,0 +1,356 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const log = require('../logger');
|
||||
const { hasLanCableCarrier } = require('../network');
|
||||
|
||||
/**
|
||||
* OpenVFD 图标:/sys/class/leds/openvfd/led_on|led_off(写入图标名)。
|
||||
*
|
||||
* 映射(与面板丝印一致:LAN=play,WiFi=wifi+eth):
|
||||
* wifi + eth 同亮/同灭 → 产品 WiFi 灯(配网 on/off/blink)
|
||||
* play → LAN(有线插拔,见 hasLanCableCarrier / CLAWD_ETH_IFACE)
|
||||
* alarm → pwr(SETUP=灭 / APPS=亮)
|
||||
* BT → 无 sysfs,仅日志
|
||||
*
|
||||
* 数码管:vfdservice 管道(660 字节,mode=4 TITLE / mode=1 CLOCK,与板端 Demo 一致)。
|
||||
* CLAWD_VFD_PIPE 默认 /tmp/openvfd_service;管道不存在时仅 debug,不抛错。
|
||||
*
|
||||
* CLAWD_OPENVFD_PATH 默认 /sys/class/leds/openvfd(图标灯)
|
||||
*/
|
||||
|
||||
const BLINK_INTERVAL_MS = 500;
|
||||
const LAN_POLL_MS = 500;
|
||||
|
||||
const VFD_BASE = process.env.CLAWD_OPENVFD_PATH || '/sys/class/leds/openvfd';
|
||||
const VFD_PIPE = process.env.CLAWD_VFD_PIPE || '/tmp/openvfd_service';
|
||||
const VFD_BUF_SIZE = 660;
|
||||
const VFD_MODE_CLOCK = 1;
|
||||
const VFD_MODE_TITLE = 4;
|
||||
const VFD_TITLE_OFF = 20;
|
||||
|
||||
function vfdPipePresent() {
|
||||
try {
|
||||
return fs.existsSync(VFD_PIPE);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** TITLE:固定 4 字符,ASCII,供 vfdservice 解析为段码 */
|
||||
function vfdTitleNormalize(raw) {
|
||||
return String(raw || '')
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9 \-]/g, ' ')
|
||||
.slice(0, 4)
|
||||
.padEnd(4, ' ');
|
||||
}
|
||||
|
||||
// O_WRONLY | O_NONBLOCK:FIFO 无读端时立即抛 ENXIO 而非永久阻塞事件循环
|
||||
const VFD_OPEN_FLAGS = fs.constants.O_WRONLY | fs.constants.O_NONBLOCK;
|
||||
|
||||
function writeVfdPipeTitle(text4) {
|
||||
if (!vfdPipePresent()) return;
|
||||
const seg = vfdTitleNormalize(text4);
|
||||
const data = Buffer.alloc(VFD_BUF_SIZE);
|
||||
data.writeUInt16LE(VFD_MODE_TITLE, 0);
|
||||
try {
|
||||
data.write(`${seg}\0`, VFD_TITLE_OFF, 'ascii');
|
||||
} catch (e) {
|
||||
log.debug('display', `openvfd title encode: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fd = fs.openSync(VFD_PIPE, VFD_OPEN_FLAGS);
|
||||
fs.writeSync(fd, data);
|
||||
fs.closeSync(fd);
|
||||
} catch (e) {
|
||||
log.debug('display', `openvfd pipe title: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeVfdPipeClock() {
|
||||
if (!vfdPipePresent()) return;
|
||||
const data = Buffer.alloc(VFD_BUF_SIZE);
|
||||
data.writeUInt16LE(VFD_MODE_CLOCK, 0);
|
||||
try {
|
||||
const fd = fs.openSync(VFD_PIPE, VFD_OPEN_FLAGS);
|
||||
fs.writeSync(fd, data);
|
||||
fs.closeSync(fd);
|
||||
} catch (e) {
|
||||
log.debug('display', `openvfd pipe clock: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function vfdOn(icon) {
|
||||
try {
|
||||
fs.writeFileSync(`${VFD_BASE}/led_on`, icon);
|
||||
} catch (e) {
|
||||
log.debug('led', `openvfd led_on ${icon}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function vfdOff(icon) {
|
||||
try {
|
||||
fs.writeFileSync(`${VFD_BASE}/led_off`, icon);
|
||||
} catch (e) {
|
||||
log.debug('led', `openvfd led_off ${icon}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function vfdWifiPair(on) {
|
||||
if (on) {
|
||||
vfdOn('wifi');
|
||||
vfdOn('eth');
|
||||
} else {
|
||||
vfdOff('wifi');
|
||||
vfdOff('eth');
|
||||
}
|
||||
}
|
||||
|
||||
class WifiLed {
|
||||
constructor() {
|
||||
this._blinkTimer = null;
|
||||
this._blinkState = false;
|
||||
this._current = null;
|
||||
}
|
||||
|
||||
on() {
|
||||
if (this._current === 'on') return;
|
||||
this._stopBlink();
|
||||
this._write(1);
|
||||
this._current = 'on';
|
||||
log.info('led', 'WiFi 指示灯 → 常亮');
|
||||
}
|
||||
|
||||
off() {
|
||||
if (this._current === 'off') return;
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
log.info('led', 'WiFi 指示灯 → 熄灭');
|
||||
}
|
||||
|
||||
blink(intervalMs = BLINK_INTERVAL_MS) {
|
||||
if (this._current === 'blink') return;
|
||||
this._stopBlink();
|
||||
this._blinkState = true;
|
||||
this._write(1);
|
||||
this._blinkTimer = setInterval(() => {
|
||||
this._blinkState = !this._blinkState;
|
||||
this._write(this._blinkState ? 1 : 0);
|
||||
}, intervalMs);
|
||||
this._current = 'blink';
|
||||
log.info('led', 'WiFi 指示灯 → 闪烁');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
}
|
||||
|
||||
_stopBlink() {
|
||||
if (this._blinkTimer) {
|
||||
clearInterval(this._blinkTimer);
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_write(val) {
|
||||
const on = !!val;
|
||||
log.debug('led', `[vfd] WiFi(wifi+eth)<= ${on ? 1 : 0}`);
|
||||
vfdWifiPair(on);
|
||||
}
|
||||
}
|
||||
|
||||
class BtLed {
|
||||
constructor() {
|
||||
this._blinkTimer = null;
|
||||
this._blinkState = false;
|
||||
this._current = null;
|
||||
}
|
||||
|
||||
on() {
|
||||
if (this._current === 'on') return;
|
||||
this._stopBlink();
|
||||
this._write(1);
|
||||
this._current = 'on';
|
||||
log.info('led', 'BT 指示灯 → 常亮');
|
||||
}
|
||||
|
||||
off() {
|
||||
if (this._current === 'off') return;
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
log.info('led', 'BT 指示灯 → 熄灭');
|
||||
}
|
||||
|
||||
blink(intervalMs = BLINK_INTERVAL_MS) {
|
||||
if (this._current === 'blink') return;
|
||||
this._stopBlink();
|
||||
this._blinkState = true;
|
||||
this._write(1);
|
||||
this._blinkTimer = setInterval(() => {
|
||||
this._blinkState = !this._blinkState;
|
||||
this._write(this._blinkState ? 1 : 0);
|
||||
}, intervalMs);
|
||||
this._current = 'blink';
|
||||
log.info('led', 'BT 指示灯 → 闪烁');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
}
|
||||
|
||||
_stopBlink() {
|
||||
if (this._blinkTimer) {
|
||||
clearInterval(this._blinkTimer);
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_write(_val) {
|
||||
log.debug('led', '[vfd] BT 无 OpenVFD 映射,忽略');
|
||||
}
|
||||
}
|
||||
|
||||
class Display {
|
||||
constructor() {
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
|
||||
showAP() {
|
||||
this._stopBlink();
|
||||
this._pipeTitle('AP ', '#m3AP ');
|
||||
log.info('display', '显示屏 → AP(闪烁)');
|
||||
let visible = true;
|
||||
const blink = () => {
|
||||
visible = !visible;
|
||||
this._pipeTitle(visible ? 'AP ' : ' ', visible ? '#m3AP ' : '#c1');
|
||||
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
||||
};
|
||||
this._blinkTimer = setTimeout(blink, 1000);
|
||||
}
|
||||
|
||||
showConn() {
|
||||
this._stopBlink();
|
||||
this._pipeTitle('Conn', '#m3Conn');
|
||||
log.info('display', '显示屏 → Conn(闪烁)');
|
||||
let visible = true;
|
||||
const blink = () => {
|
||||
visible = !visible;
|
||||
this._pipeTitle(visible ? 'Conn' : ' ', visible ? '#m3Conn' : '#c1');
|
||||
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
||||
};
|
||||
this._blinkTimer = setTimeout(blink, 1000);
|
||||
}
|
||||
|
||||
showErr0() {
|
||||
this._stopBlink();
|
||||
this._pipeTitle('Err0', '#m3Err0');
|
||||
log.info('display', '显示屏 → Err0');
|
||||
}
|
||||
|
||||
showTime() {
|
||||
this._stopBlink();
|
||||
writeVfdPipeClock();
|
||||
log.info('display', '显示屏 → 时间');
|
||||
}
|
||||
|
||||
showPin(pin) {
|
||||
this._stopBlink();
|
||||
const s = String(pin || '').padStart(4, '0').slice(-4);
|
||||
this._pipeTitle(s, '#m2' + s);
|
||||
log.info('display', `显示屏 → PIN: ${s}(闪烁)`);
|
||||
let visible = true;
|
||||
const blink = () => {
|
||||
visible = !visible;
|
||||
this._pipeTitle(visible ? s : ' ', visible ? '#m2' + s : '#c1');
|
||||
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
||||
};
|
||||
this._blinkTimer = setTimeout(blink, 1000);
|
||||
}
|
||||
|
||||
_stopBlink() {
|
||||
if (this._blinkTimer) {
|
||||
clearTimeout(this._blinkTimer);
|
||||
clearInterval(this._blinkTimer);
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留原 #m3/#m2/#c1 的 debug 语义,并写 vfdservice TITLE */
|
||||
_pipeTitle(fourCharText, debugLegacy) {
|
||||
log.debug('display', `[vfd] ${debugLegacy}`);
|
||||
writeVfdPipeTitle(fourCharText);
|
||||
}
|
||||
}
|
||||
|
||||
class StatusLed {
|
||||
setSetup() {
|
||||
vfdOff('alarm');
|
||||
log.debug('led', '[vfd] alarm(pwr)<= 0');
|
||||
log.info('led', '状态灯 → SETUP(未激活)');
|
||||
}
|
||||
|
||||
setApps() {
|
||||
vfdOn('alarm');
|
||||
// 部分 OpenVFD 驱动单次写入生效慢,短延迟再写一次
|
||||
setTimeout(() => vfdOn('alarm'), 50);
|
||||
log.debug('led', '[vfd] alarm(pwr)<= 1');
|
||||
log.info('led', '状态灯 → APPS(已激活)');
|
||||
}
|
||||
|
||||
off() {
|
||||
vfdOff('alarm');
|
||||
log.debug('led', '[vfd] alarm(pwr)<= 0 (off)');
|
||||
}
|
||||
}
|
||||
|
||||
class LanLed {
|
||||
constructor() {
|
||||
this._timer = null;
|
||||
this._current = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this._sync();
|
||||
this._timer = setInterval(() => this._sync(), LAN_POLL_MS);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
vfdOff('play');
|
||||
this._current = null;
|
||||
}
|
||||
|
||||
_sync() {
|
||||
const up = hasLanCableCarrier();
|
||||
if (up) {
|
||||
if (this._current !== 'on') {
|
||||
vfdOn('play');
|
||||
this._current = 'on';
|
||||
log.info('led', 'LAN(play / 有线 carrier)→ 亮');
|
||||
}
|
||||
} else if (this._current !== 'off') {
|
||||
vfdOff('play');
|
||||
this._current = 'off';
|
||||
log.info('led', 'LAN(play / 有线 carrier)→ 灭');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lan = new LanLed();
|
||||
|
||||
module.exports = new WifiLed();
|
||||
module.exports.bt = new BtLed();
|
||||
module.exports.status = new StatusLed();
|
||||
module.exports.display = new Display();
|
||||
module.exports.lan = lan;
|
||||
301
lib/led/rk3566-openvfd.js
Normal file
301
lib/led/rk3566-openvfd.js
Normal file
@@ -0,0 +1,301 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
const log = require('../logger');
|
||||
|
||||
/**
|
||||
* 前面板指示灯控制
|
||||
*
|
||||
* WiFi 灯 (b5): 1 = 亮, 0 = 灭(正逻辑)
|
||||
* - WiFi 已连接且互联网畅通 → 常亮
|
||||
* - WiFi 连接中(正在尝试) → 闪烁
|
||||
* - WiFi 未连接 / 无互联网 → 熄灭
|
||||
*
|
||||
* BT 灯 (b6): 1 = 亮, 0 = 灭(正逻辑)
|
||||
* - BLE 配网进行中 → 闪烁
|
||||
* - BLE 配网成功 → 常亮
|
||||
* - 蓝牙不工作 → 熄灭
|
||||
*
|
||||
* SETUP 灯 (b2): 0 = 亮, 1 = 灭(反逻辑,与 APPS 互斥)
|
||||
* APPS 灯 (b1): 0 = 亮, 1 = 灭(反逻辑,与 SETUP 互斥)
|
||||
* - claw 未激活 → SETUP 亮,APPS 灭
|
||||
* - claw 已激活 → APPS 亮,SETUP 灭
|
||||
*/
|
||||
|
||||
const LED_PATH = process.env.CLAWD_LED_PATH || '/sys/devices/platform/openvfd/attr/b5';
|
||||
const BT_LED_PATH = process.env.CLAWD_BT_LED_PATH || '/sys/devices/platform/openvfd/attr/b6';
|
||||
const SETUP_LED_PATH = '/sys/devices/platform/openvfd/attr/b1'; // 物理 SETUP 灯
|
||||
const APPS_LED_PATH = '/sys/devices/platform/openvfd/attr/b2'; // 物理 APPS 灯
|
||||
const BLINK_INTERVAL_MS = 500; // 闪烁间隔(ms)
|
||||
|
||||
class WifiLed {
|
||||
constructor() {
|
||||
this._blinkTimer = null;
|
||||
this._blinkState = false;
|
||||
this._current = null; // 'on' | 'off' | 'blink'
|
||||
}
|
||||
|
||||
/** 常亮 */
|
||||
on() {
|
||||
if (this._current === 'on') return;
|
||||
this._stopBlink();
|
||||
this._write(1);
|
||||
this._current = 'on';
|
||||
log.info('led', 'WiFi 指示灯 → 常亮');
|
||||
}
|
||||
|
||||
/** 熄灭 */
|
||||
off() {
|
||||
if (this._current === 'off') return;
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
log.info('led', 'WiFi 指示灯 → 熄灭');
|
||||
}
|
||||
|
||||
/** 闪烁(连接中) */
|
||||
blink(intervalMs = BLINK_INTERVAL_MS) {
|
||||
if (this._current === 'blink') return;
|
||||
this._stopBlink();
|
||||
this._blinkState = true;
|
||||
this._write(1);
|
||||
this._blinkTimer = setInterval(() => {
|
||||
this._blinkState = !this._blinkState;
|
||||
this._write(this._blinkState ? 1 : 0);
|
||||
}, intervalMs);
|
||||
this._current = 'blink';
|
||||
log.info('led', 'WiFi 指示灯 → 闪烁');
|
||||
}
|
||||
|
||||
/** 释放资源,关灯 */
|
||||
destroy() {
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_stopBlink() {
|
||||
if (this._blinkTimer) {
|
||||
clearInterval(this._blinkTimer);
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_write(val) {
|
||||
try {
|
||||
fs.writeFileSync(LED_PATH, String(val));
|
||||
} catch (e) {
|
||||
log.warn('led', `写入失败 (${LED_PATH}): ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 蓝牙指示灯 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* BT 指示灯(b6)正逻辑:1 = 亮,0 = 灭。
|
||||
* blink() — BLE 配网进行中
|
||||
* on() — BLE 配网成功 / 蓝牙功能正常
|
||||
* off() — 蓝牙不工作
|
||||
*/
|
||||
class BtLed {
|
||||
constructor() {
|
||||
this._blinkTimer = null;
|
||||
this._blinkState = false;
|
||||
this._current = null; // 'on' | 'off' | 'blink'
|
||||
}
|
||||
|
||||
/** 常亮(配网成功) */
|
||||
on() {
|
||||
if (this._current === 'on') return;
|
||||
this._stopBlink();
|
||||
this._write(1);
|
||||
this._current = 'on';
|
||||
log.info('led', 'BT 指示灯 → 常亮');
|
||||
}
|
||||
|
||||
/** 熄灭(蓝牙不工作) */
|
||||
off() {
|
||||
if (this._current === 'off') return;
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
log.info('led', 'BT 指示灯 → 熄灭');
|
||||
}
|
||||
|
||||
/** 闪烁(BLE 配网进行中) */
|
||||
blink(intervalMs = BLINK_INTERVAL_MS) {
|
||||
if (this._current === 'blink') return;
|
||||
this._stopBlink();
|
||||
this._blinkState = true;
|
||||
this._write(1);
|
||||
this._blinkTimer = setInterval(() => {
|
||||
this._blinkState = !this._blinkState;
|
||||
this._write(this._blinkState ? 1 : 0);
|
||||
}, intervalMs);
|
||||
this._current = 'blink';
|
||||
log.info('led', 'BT 指示灯 → 闪烁');
|
||||
}
|
||||
|
||||
/** 释放资源,关灯 */
|
||||
destroy() {
|
||||
this._stopBlink();
|
||||
this._write(0);
|
||||
this._current = 'off';
|
||||
}
|
||||
|
||||
_stopBlink() {
|
||||
if (this._blinkTimer) {
|
||||
clearInterval(this._blinkTimer);
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_write(val) {
|
||||
try {
|
||||
fs.writeFileSync(BT_LED_PATH, String(val));
|
||||
} catch (e) {
|
||||
log.warn('led', `写入失败 (${BT_LED_PATH}): ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── VFD 显示屏 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DISPLAY_PATH = '/sys/devices/platform/openvfd/attr/led';
|
||||
|
||||
/**
|
||||
* VFD 显示屏控制。
|
||||
* #m3 <text> 手动模式,显示指定文字
|
||||
* #s1 系统时钟模式,显示当前时间
|
||||
*/
|
||||
class Display {
|
||||
constructor() {
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
|
||||
/** 网络断开 / AP 模式 → 显示 "AP " */
|
||||
showAP() {
|
||||
this._stopBlink();
|
||||
this._write('#m3AP ');
|
||||
log.info('display', '显示屏 → AP');
|
||||
}
|
||||
|
||||
/** WS 连接中(失败次数 < 3)→ 显示 "Conn" 闪烁 */
|
||||
showConn() {
|
||||
this._stopBlink();
|
||||
this._write('#m3Conn');
|
||||
log.info('display', '显示屏 → Conn(闪烁)');
|
||||
let visible = true;
|
||||
const blink = () => {
|
||||
visible = !visible;
|
||||
this._write(visible ? '#m3Conn' : '#c1');
|
||||
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
||||
};
|
||||
this._blinkTimer = setTimeout(blink, 1000);
|
||||
}
|
||||
|
||||
/** 网络正常但 VPS 不可达 → 显示 "Err0" */
|
||||
showErr0() {
|
||||
this._stopBlink();
|
||||
this._write('#m3Err0');
|
||||
log.info('display', '显示屏 → Err0');
|
||||
}
|
||||
|
||||
/** 网络已连接 → 显示时间 */
|
||||
showTime() {
|
||||
this._stopBlink();
|
||||
this._write('#s1');
|
||||
log.info('display', '显示屏 → 时间');
|
||||
}
|
||||
|
||||
/** 未激活 + 连网 → 显示 PIN 码(4 位数字)并闪烁 */
|
||||
showPin(pin) {
|
||||
this._stopBlink();
|
||||
const s = String(pin || '').padStart(4, '0').slice(-4);
|
||||
this._write('#m2' + s);
|
||||
log.info('display', `显示屏 → PIN: ${s}(闪烁)`);
|
||||
// 亮 1s → 灭 0.5s → 循环
|
||||
let visible = true;
|
||||
const blink = () => {
|
||||
visible = !visible;
|
||||
this._write(visible ? '#m2' + s : '#c1');
|
||||
this._blinkTimer = setTimeout(blink, visible ? 1000 : 500);
|
||||
};
|
||||
this._blinkTimer = setTimeout(blink, 1000);
|
||||
}
|
||||
|
||||
_stopBlink() {
|
||||
if (this._blinkTimer) {
|
||||
clearTimeout(this._blinkTimer);
|
||||
clearInterval(this._blinkTimer);
|
||||
this._blinkTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_write(val) {
|
||||
try {
|
||||
execSync(`echo "${val}" | tee ${DISPLAY_PATH} > /dev/null`, { timeout: 3000 });
|
||||
} catch (e) {
|
||||
log.warn('display', `写入失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SETUP / APPS 状态灯 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* SETUP 灯(b2)与 APPS 灯(b1)互斥控制。
|
||||
* 两灯均为反逻辑:写 0 = 亮,写 1 = 灭。
|
||||
*/
|
||||
class StatusLed {
|
||||
/** claw 未激活 → SETUP 亮,APPS 灭 */
|
||||
setSetup() {
|
||||
this._write(SETUP_LED_PATH, 0); // SETUP 亮
|
||||
this._write(APPS_LED_PATH, 1); // APPS 灭
|
||||
log.info('led', '状态灯 → SETUP(未激活)');
|
||||
}
|
||||
|
||||
/** claw 已激活 → APPS 亮,SETUP 灭 */
|
||||
setApps() {
|
||||
this._write(SETUP_LED_PATH, 1); // SETUP 灭
|
||||
this._write(APPS_LED_PATH, 0); // APPS 亮
|
||||
log.info('led', '状态灯 → APPS(已激活)');
|
||||
}
|
||||
|
||||
/** 两灯全灭(进程退出时调用) */
|
||||
off() {
|
||||
this._write(SETUP_LED_PATH, 1);
|
||||
this._write(APPS_LED_PATH, 1);
|
||||
}
|
||||
|
||||
_write(path, val) {
|
||||
try {
|
||||
fs.writeFileSync(path, String(val));
|
||||
} catch (e) {
|
||||
log.warn('led', `写入失败 (${path}): ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── LAN 指示灯 ───────────────────────────────────────────────────────────────
|
||||
// RK3566 这版硬件没有 LAN LED 映射;保留统一 API,避免上层分支判断。
|
||||
class LanLed {
|
||||
start() {
|
||||
log.debug('led', '[rk3566] LAN LED unsupported; start ignored');
|
||||
}
|
||||
|
||||
stop() {
|
||||
log.debug('led', '[rk3566] LAN LED unsupported; stop ignored');
|
||||
}
|
||||
}
|
||||
|
||||
// 全局单例,整个进程共用
|
||||
module.exports = new WifiLed();
|
||||
module.exports.bt = new BtLed();
|
||||
module.exports.status = new StatusLed();
|
||||
module.exports.display = new Display();
|
||||
module.exports.lan = new LanLed();
|
||||
81
lib/led/rk3588-lvgl.js
Normal file
81
lib/led/rk3588-lvgl.js
Normal file
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const log = require('../logger');
|
||||
|
||||
const LVGL_CMD_FIFO = process.env.CLAWD_LVGL_CMD_FIFO || '/tmp/lvgl_cmd';
|
||||
|
||||
function writeLvglCommand(command) {
|
||||
try {
|
||||
const fd = fs.openSync(LVGL_CMD_FIFO, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK);
|
||||
fs.writeSync(fd, `${command}\n`);
|
||||
fs.closeSync(fd);
|
||||
return true;
|
||||
} catch (e) {
|
||||
log.warn('display', `lvgl cmd failed (${command}): ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class BasicLed {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this._current = null;
|
||||
}
|
||||
on() { this._current = 'on'; log.debug('led', `[rk3588-lvgl] ${this.name} on`); }
|
||||
off() { this._current = 'off'; log.debug('led', `[rk3588-lvgl] ${this.name} off`); }
|
||||
blink() { this._current = 'blink'; log.debug('led', `[rk3588-lvgl] ${this.name} blink`); }
|
||||
destroy() { this._current = 'off'; log.debug('led', `[rk3588-lvgl] ${this.name} destroy`); }
|
||||
}
|
||||
|
||||
class StatusLed {
|
||||
setSetup() { log.debug('led', '[rk3588-lvgl] status setup'); }
|
||||
setApps() { log.debug('led', '[rk3588-lvgl] status apps'); }
|
||||
off() { log.debug('led', '[rk3588-lvgl] status off'); }
|
||||
}
|
||||
|
||||
class Display {
|
||||
showAP() {
|
||||
if (writeLvglCommand('show_ap')) {
|
||||
log.info('display', '显示屏 → AP(闪烁)');
|
||||
}
|
||||
}
|
||||
|
||||
showConn() {
|
||||
if (writeLvglCommand('show_conn')) {
|
||||
log.info('display', '显示屏 → Conn(闪烁)');
|
||||
}
|
||||
}
|
||||
|
||||
showErr0() {
|
||||
if (writeLvglCommand('show_err0')) {
|
||||
log.info('display', '显示屏 → Err0');
|
||||
}
|
||||
}
|
||||
|
||||
showTime() {
|
||||
if (writeLvglCommand('show_time')) {
|
||||
log.info('display', '显示屏 → 时间');
|
||||
}
|
||||
}
|
||||
|
||||
showPin(pin) {
|
||||
const s = String(pin || '').padStart(4, '0').slice(-4);
|
||||
if (writeLvglCommand(`show_pin:${s}`)) {
|
||||
log.info('display', `显示屏 → PIN: ${s}(闪烁)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LanLed {
|
||||
start() { log.debug('led', '[rk3588-lvgl] LAN start ignored'); }
|
||||
stop() { log.debug('led', '[rk3588-lvgl] LAN stop ignored'); }
|
||||
}
|
||||
|
||||
const led = new BasicLed('wifi');
|
||||
led.bt = new BasicLed('bt');
|
||||
led.status = new StatusLed();
|
||||
led.display = new Display();
|
||||
led.lan = new LanLed();
|
||||
|
||||
module.exports = led;
|
||||
481
lib/network.js
481
lib/network.js
@@ -10,6 +10,7 @@ const AP_IP = '10.42.0.1';
|
||||
const AP_PASSWORD = '12345678';
|
||||
const AP_IFACE = process.env.CLAWD_WIFI_IFACE || '';
|
||||
const CON_NAME = 'clawd-hotspot';
|
||||
const AP_RETRY_TOKEN_FILE = '/run/clawd-ap-retry.token';
|
||||
|
||||
/** 产品 RJ45 在 sysfs 中的默认名;等价于检测 `cat /sys/class/net/end0/carrier` */
|
||||
const DEFAULT_ETH_IFACE = 'end0';
|
||||
@@ -92,28 +93,15 @@ function hasLanCableCarrier() {
|
||||
return hasWiredCarrier();
|
||||
}
|
||||
|
||||
function _tryPingInternet() {
|
||||
function _tryPingDefaultInternet() {
|
||||
try {
|
||||
run('ping -c 1 -W 3 8.8.8.8');
|
||||
return true;
|
||||
} catch (_) {}
|
||||
|
||||
// 开热点时默认路由可能走 wlan,无 -I 的 ping 会误判;指定有线口再试
|
||||
const wired = getWiredIfaceWithCarrier();
|
||||
if (wired) {
|
||||
try {
|
||||
run(`ping -c 1 -W 3 -I ${wired} 8.8.8.8`);
|
||||
return true;
|
||||
} catch (_) {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅经有线口 ping 公网(不依赖默认路由)。
|
||||
* AP 开启时 hasInternet() 易误判;维持 WS / 网络监视时用此兜底。
|
||||
*/
|
||||
function hasWiredInternetProbe() {
|
||||
function _tryPingWiredInternet() {
|
||||
const wired = getWiredIfaceWithCarrier();
|
||||
if (!wired) return false;
|
||||
try {
|
||||
@@ -124,18 +112,31 @@ function hasWiredInternetProbe() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否有互联网连接(nmcli 连通性 + ping 兜底)
|
||||
* 仅经有线口 ping 公网(不依赖默认路由)。
|
||||
*/
|
||||
function hasWiredInternetProbe() {
|
||||
return _tryPingWiredInternet();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否有真实互联网连接。
|
||||
* 注意:NetworkManager 的 limited/local 可能只是 AP 本地网络或 captive 状态,不能当公网可用。
|
||||
*/
|
||||
function hasInternet() {
|
||||
const wifiSta = isWifiStaConnected();
|
||||
const wired = getWiredIfaceWithCarrier();
|
||||
|
||||
// 物理层快检:无 WiFi STA 且无任何有线 carrier → 立即 false(nmcli 有缓存,不可信)
|
||||
if (!isWifiStaConnected() && !hasWiredCarrier()) return false;
|
||||
if (!wifiSta && !wired) return false;
|
||||
|
||||
try {
|
||||
const out = run('nmcli networking connectivity check').trim();
|
||||
if (out === 'full' || out === 'limited') return true;
|
||||
if (out === 'full') return true;
|
||||
} catch (_) {}
|
||||
|
||||
return _tryPingInternet();
|
||||
if (wifiSta) return _tryPingDefaultInternet();
|
||||
if (wired) return _tryPingWiredInternet();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,19 +181,24 @@ function scanWifi() {
|
||||
// 等扫描完成
|
||||
sleep(2000);
|
||||
|
||||
const out = run('nmcli -t -f SSID,SIGNAL,SECURITY device wifi list');
|
||||
// 指定 ifname,避免 AP/多网卡场景下读取到非目标接口或旧缓存;带回频率便于诊断 2.4G/5G。
|
||||
const out = run(`nmcli -t -f SSID,SIGNAL,SECURITY,FREQ device wifi list ifname ${iface}`);
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
for (const line of out.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const parts = line.split(':');
|
||||
const ssid = parts[0].trim().replace(/\\:/g, ':');
|
||||
const parts = _parseNmcliTerseLine(line);
|
||||
const ssid = (parts[0] || '').trim();
|
||||
if (!ssid || seen.has(ssid)) continue;
|
||||
seen.add(ssid);
|
||||
const freq = (parts[3] || '').trim();
|
||||
const freqMhz = parseInt(freq, 10) || null;
|
||||
results.push({
|
||||
ssid,
|
||||
signal: parseInt(parts[1], 10) || 0,
|
||||
security: parts.slice(2).join(':').trim() || 'Open',
|
||||
security: (parts[2] || '').trim() || 'Open',
|
||||
freq,
|
||||
band: freqMhz ? (freqMhz >= 4900 ? '5G' : '2.4G') : null,
|
||||
});
|
||||
}
|
||||
results.sort((a, b) => b.signal - a.signal);
|
||||
@@ -260,6 +266,7 @@ function nmcliAsync(args, timeoutMs = 60000) {
|
||||
* @returns {Promise<{ success: boolean, error?: string }>}
|
||||
*/
|
||||
async function connectWifi(ssid, password) {
|
||||
cancelHotspotRadioRetry(`准备连接 WiFi: ${ssid}`);
|
||||
const iface = getWifiIface();
|
||||
log.info('network', `尝试连接 WiFi: ${ssid}(ifname=${iface})`);
|
||||
try {
|
||||
@@ -267,14 +274,37 @@ async function connectWifi(ssid, password) {
|
||||
await nmcliAsync(['connection', 'delete', ssid], 15000);
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await nmcliAsync(['device', 'set', iface, 'managed', 'yes'], 8000);
|
||||
} catch (_) {}
|
||||
await _resetWifiRadioForSTA(iface);
|
||||
|
||||
const args = ['device', 'wifi', 'connect', ssid];
|
||||
if (password) args.push('password', password);
|
||||
args.push('ifname', iface);
|
||||
await nmcliAsync(args, 120000);
|
||||
if (password) {
|
||||
// 显式创建 STA profile,并固定为 WPA2-PSK only。
|
||||
// RK3588/Broadcom DHD 对 NetworkManager 默认生成的 SAE/FT/WPA-PSK-SHA256 混合参数不稳定,
|
||||
// 可能表现为一直 associating -> disconnected,最后误报“需要密钥”。
|
||||
await nmcliAsync([
|
||||
'connection', 'add',
|
||||
'type', 'wifi',
|
||||
'ifname', iface,
|
||||
'con-name', ssid,
|
||||
'ssid', ssid,
|
||||
], 15000);
|
||||
|
||||
await nmcliAsync([
|
||||
'connection', 'modify', ssid,
|
||||
// 连接成功前先禁止自动连接,避免失败恢复 AP 时 NM 又自动抢占 wlan0。
|
||||
'connection.autoconnect', 'no',
|
||||
'802-11-wireless-security.key-mgmt', 'wpa-psk',
|
||||
'802-11-wireless-security.proto', 'rsn',
|
||||
'802-11-wireless-security.pairwise', 'ccmp',
|
||||
'802-11-wireless-security.group', 'ccmp',
|
||||
'802-11-wireless-security.pmf', 'disable',
|
||||
'802-11-wireless-security.psk', password,
|
||||
], 15000);
|
||||
|
||||
await nmcliAsync(['connection', 'up', 'id', ssid, 'ifname', iface], 120000);
|
||||
} else {
|
||||
await nmcliAsync(['device', 'wifi', 'connect', ssid, 'ifname', iface], 120000);
|
||||
}
|
||||
await _ensureActiveWifiAutoconnect();
|
||||
|
||||
const deadline = Date.now() + CONNECT_WIFI_STA_WAIT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -293,11 +323,201 @@ async function connectWifi(ssid, password) {
|
||||
}
|
||||
return { success: false, error: '超时:网卡未进入已连接状态' };
|
||||
} catch (e) {
|
||||
try { await nmcliAsync(['connection', 'modify', ssid, 'connection.autoconnect', 'no'], 8000); } catch (_) {}
|
||||
log.error('network', `WiFi 连接失败: ${e.message}`);
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function _newHotspotRetryToken() {
|
||||
const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
||||
try {
|
||||
fs.writeFileSync(AP_RETRY_TOKEN_FILE, token, { mode: 0o600 });
|
||||
} catch (e) {
|
||||
log.warn('network', `写入 AP retry token 失败: ${e.message}`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function cancelHotspotRadioRetry(reason = 'cancel') {
|
||||
try {
|
||||
fs.unlinkSync(AP_RETRY_TOKEN_FILE);
|
||||
log.info('network', `已取消后台 AP retry: ${reason}`);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function _resetWifiRadioForSTA(iface, reason = '准备连接 STA 前重置 WiFi radio') {
|
||||
log.warn('network', `${reason}: ${iface}`);
|
||||
|
||||
try { await nmcliAsync(['connection', 'down', CON_NAME], 8000); } catch (_) {}
|
||||
try { await nmcliAsync(['connection', 'delete', CON_NAME], 8000); } catch (_) {}
|
||||
try { await nmcliAsync(['device', 'disconnect', iface], 8000); } catch (_) {}
|
||||
|
||||
try {
|
||||
await nmcliAsync(['radio', 'wifi', 'off'], 10000);
|
||||
} catch (e) {
|
||||
log.warn('network', `关闭 WiFi radio 失败: ${e.message}`);
|
||||
}
|
||||
|
||||
await _delay(2500);
|
||||
|
||||
try {
|
||||
await nmcliAsync(['radio', 'wifi', 'on'], 10000);
|
||||
} catch (e) {
|
||||
log.warn('network', `开启 WiFi radio 失败: ${e.message}`);
|
||||
}
|
||||
|
||||
await _delay(5000);
|
||||
try { await nmcliAsync(['device', 'set', iface, 'managed', 'yes'], 8000); } catch (_) {}
|
||||
try { await nmcliAsync(['device', 'wifi', 'rescan', 'ifname', iface], 15000); } catch (_) {}
|
||||
await _delay(1500);
|
||||
}
|
||||
|
||||
function _resetWifiRadioForAP(iface, reason = '准备 AP 前重置 WiFi radio') {
|
||||
log.warn('network', `${reason}: ${iface}`);
|
||||
|
||||
try { nmcliSync(['connection', 'down', CON_NAME], 8000); } catch (_) {}
|
||||
try { nmcliSync(['connection', 'delete', CON_NAME], 8000); } catch (_) {}
|
||||
try { nmcliSync(['device', 'disconnect', iface], 8000); } catch (_) {}
|
||||
|
||||
try {
|
||||
nmcliSync(['radio', 'wifi', 'off'], 10000);
|
||||
} catch (e) {
|
||||
log.warn('network', `关闭 WiFi radio 失败: ${e.message}`);
|
||||
}
|
||||
|
||||
sleep(2500);
|
||||
|
||||
try {
|
||||
nmcliSync(['radio', 'wifi', 'on'], 10000);
|
||||
} catch (e) {
|
||||
log.warn('network', `开启 WiFi radio 失败: ${e.message}`);
|
||||
}
|
||||
|
||||
sleep(5000);
|
||||
try { nmcliSync(['device', 'set', iface, 'managed', 'yes'], 8000); } catch (_) {}
|
||||
}
|
||||
|
||||
function _spawnHotspotRadioRetry(ssid, iface) {
|
||||
const token = _newHotspotRetryToken();
|
||||
const script = `
|
||||
set -u
|
||||
log() { logger -t clawd-ap-retry "$*"; }
|
||||
check_token() {
|
||||
if [ ! -f "$TOKEN_FILE" ] || [ "$(cat "$TOKEN_FILE" 2>/dev/null || true)" != "$TOKEN" ]; then
|
||||
log "AP retry canceled"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
log "AP retry started: ssid=$SSID iface=$IFACE"
|
||||
check_token
|
||||
nmcli connection down "$CON_NAME" >/dev/null 2>&1 || true
|
||||
nmcli connection delete "$CON_NAME" >/dev/null 2>&1 || true
|
||||
nmcli device disconnect "$IFACE" >/dev/null 2>&1 || true
|
||||
check_token
|
||||
nmcli radio wifi off >/dev/null 2>&1 || true
|
||||
sleep 2.5
|
||||
# If canceled while radio is off, always turn it back on before exiting.
|
||||
nmcli radio wifi on >/dev/null 2>&1 || true
|
||||
sleep 5
|
||||
check_token
|
||||
nmcli device set "$IFACE" managed yes >/dev/null 2>&1 || true
|
||||
check_token
|
||||
if ! nmcli connection add type wifi ifname "$IFACE" con-name "$CON_NAME" ssid "$SSID" >/dev/null 2>&1; then
|
||||
log "AP retry failed: connection add failed"
|
||||
exit 1
|
||||
fi
|
||||
args=(
|
||||
connection modify "$CON_NAME"
|
||||
connection.autoconnect no
|
||||
802-11-wireless.mode ap
|
||||
802-11-wireless.band bg
|
||||
802-11-wireless.channel 1
|
||||
802-11-wireless-security.key-mgmt wpa-psk
|
||||
802-11-wireless-security.proto rsn
|
||||
802-11-wireless-security.pairwise ccmp
|
||||
802-11-wireless-security.group ccmp
|
||||
802-11-wireless-security.pmf disable
|
||||
ipv4.method shared
|
||||
ipv4.addresses "$AP_IP/24"
|
||||
ipv6.method ignore
|
||||
)
|
||||
if [ -n "\${AP_PASSWORD:-}" ]; then
|
||||
args+=(802-11-wireless-security.psk "$AP_PASSWORD")
|
||||
fi
|
||||
check_token
|
||||
if ! nmcli "\${args[@]}" >/dev/null 2>&1; then
|
||||
log "AP retry failed: connection modify failed"
|
||||
exit 1
|
||||
fi
|
||||
check_token
|
||||
if nmcli connection up "$CON_NAME" >/dev/null 2>&1; then
|
||||
if [ ! -f "$TOKEN_FILE" ] || [ "$(cat "$TOKEN_FILE" 2>/dev/null || true)" != "$TOKEN" ]; then
|
||||
log "AP retry canceled after connection up; tearing hotspot down"
|
||||
nmcli connection down "$CON_NAME" >/dev/null 2>&1 || true
|
||||
nmcli connection delete "$CON_NAME" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
log "AP retry success: $SSID"
|
||||
rm -f "$TOKEN_FILE"
|
||||
else
|
||||
log "AP retry failed: connection up failed"
|
||||
exit 1
|
||||
fi
|
||||
`;
|
||||
|
||||
const child = spawn('/bin/bash', ['-lc', script], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
SSID: ssid,
|
||||
IFACE: iface,
|
||||
CON_NAME,
|
||||
AP_IP,
|
||||
AP_PASSWORD: AP_PASSWORD || '',
|
||||
TOKEN_FILE: AP_RETRY_TOKEN_FILE,
|
||||
TOKEN: token,
|
||||
},
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
function _createHotspotProfile(ssid, iface) {
|
||||
nmcliSync([
|
||||
'connection', 'add',
|
||||
'type', 'wifi',
|
||||
'ifname', iface,
|
||||
'con-name', CON_NAME,
|
||||
'ssid', ssid,
|
||||
], 15000);
|
||||
|
||||
const modifyArgs = [
|
||||
'connection', 'modify', CON_NAME,
|
||||
'connection.autoconnect', 'no',
|
||||
'802-11-wireless.mode', 'ap',
|
||||
'802-11-wireless.band', 'bg',
|
||||
'802-11-wireless.channel', '1',
|
||||
'802-11-wireless-security.key-mgmt', 'wpa-psk',
|
||||
'802-11-wireless-security.proto', 'rsn',
|
||||
'802-11-wireless-security.pairwise', 'ccmp',
|
||||
'802-11-wireless-security.group', 'ccmp',
|
||||
'802-11-wireless-security.pmf', 'disable',
|
||||
'ipv4.method', 'shared',
|
||||
'ipv4.addresses', `${AP_IP}/24`,
|
||||
'ipv6.method', 'ignore',
|
||||
];
|
||||
if (AP_PASSWORD) {
|
||||
modifyArgs.push('802-11-wireless-security.psk', AP_PASSWORD);
|
||||
}
|
||||
nmcliSync(modifyArgs, 15000);
|
||||
}
|
||||
|
||||
function _activateHotspot(ssid, iface, timeoutMs = 8000) {
|
||||
_createHotspotProfile(ssid, iface);
|
||||
nmcliSync(['connection', 'up', CON_NAME], timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 WiFi AP 热点
|
||||
*/
|
||||
@@ -307,26 +527,24 @@ function startAP(clawId) {
|
||||
|
||||
log.info('network', `启动 AP 热点: ${ssid} (${iface})`);
|
||||
|
||||
// 关闭已有热点
|
||||
// 关闭已有热点,并在重新拉起 AP 前真正 power-cycle WiFi 芯片。
|
||||
// RK3588/Broadcom DHD 在 LAN 断开后切 AP 时,单纯 ip link down/up 不一定清掉固件残留状态。
|
||||
stopAP();
|
||||
_resetWifiRadioForAP(iface, '准备 AP 前重置 WiFi radio');
|
||||
|
||||
try {
|
||||
// nmcli 创建热点(开放网络)
|
||||
const cmd = [
|
||||
'nmcli device wifi hotspot',
|
||||
`ifname ${iface}`,
|
||||
`con-name ${CON_NAME}`,
|
||||
`ssid "${ssid}"`,
|
||||
'band bg',
|
||||
];
|
||||
// 如果需要密码
|
||||
if (AP_PASSWORD) {
|
||||
cmd.push(`password "${AP_PASSWORD}"`);
|
||||
// 显式创建并激活热点,固定为 WPA2-PSK only。
|
||||
// 避免 NetworkManager 自动生成 WPA-PSK-SHA256/SAE 混合配置,部分 3588 Broadcom DHD 驱动重启 AP 时会拒绝该 RSN 参数。
|
||||
try {
|
||||
_activateHotspot(ssid, iface, 8000);
|
||||
} catch (firstError) {
|
||||
log.warn('network', `AP 启动未在短超时内完成,后台再次重置 WiFi radio 后重试;避免阻塞 watchdog: ${firstError.message}`);
|
||||
_spawnHotspotRadioRetry(ssid, iface);
|
||||
return { ssid, ip: AP_IP, iface, pending: true };
|
||||
}
|
||||
run(cmd.join(' '));
|
||||
|
||||
// 等待 AP 启动
|
||||
sleep(2000);
|
||||
sleep(1000);
|
||||
log.info('network', `AP 已启动: ${ssid}, 网关 ${AP_IP}`);
|
||||
return { ssid, ip: AP_IP, iface };
|
||||
} catch (e) {
|
||||
@@ -339,6 +557,7 @@ function startAP(clawId) {
|
||||
* 关闭热点,恢复普通 WiFi 模式
|
||||
*/
|
||||
function stopAP() {
|
||||
cancelHotspotRadioRetry('停止 AP');
|
||||
try {
|
||||
run(`nmcli connection down ${CON_NAME}`);
|
||||
} catch (_) {}
|
||||
@@ -361,20 +580,121 @@ function sleep(ms) {
|
||||
execSync(`sleep ${ms / 1000}`, { timeout: ms + 2000 });
|
||||
}
|
||||
|
||||
function _parseNmcliTerseLine(line) {
|
||||
const fields = [];
|
||||
let cur = '';
|
||||
let escaped = false;
|
||||
for (const ch of line) {
|
||||
if (escaped) {
|
||||
cur += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === ':') {
|
||||
fields.push(cur);
|
||||
cur = '';
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
fields.push(cur);
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出已保存的 WiFi STA 连接(排除自身热点),按 autoconnect-priority 从高到低排序。
|
||||
*/
|
||||
function listSavedWifiConnections() {
|
||||
const profiles = [];
|
||||
try {
|
||||
const out = run('nmcli -t -f NAME,UUID,TYPE,AUTOCONNECT,AUTOCONNECT-PRIORITY connection show');
|
||||
for (const line of out.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const [name, uuid, type, autoconnect, priority] = _parseNmcliTerseLine(line);
|
||||
if (type !== '802-11-wireless' || name === CON_NAME) continue;
|
||||
profiles.push({
|
||||
name,
|
||||
uuid,
|
||||
autoconnect: autoconnect === 'yes',
|
||||
priority: parseInt(priority, 10) || 0,
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
profiles.sort((a, b) => {
|
||||
if (b.priority !== a.priority) return b.priority - a.priority;
|
||||
if (a.autoconnect !== b.autoconnect) return a.autoconnect ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否有已保存的 WiFi STA 连接(排除自身热点)
|
||||
*/
|
||||
function hasSavedWifiConnection() {
|
||||
return listSavedWifiConnections().length > 0;
|
||||
}
|
||||
|
||||
function getWifiActiveConnectionName() {
|
||||
const iface = getWifiIface();
|
||||
try {
|
||||
const out = run('nmcli -t -f NAME,TYPE connection show');
|
||||
for (const line of out.split('\n')) {
|
||||
const [name, type] = line.split(':');
|
||||
if (type === '802-11-wireless' && name !== CON_NAME) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const conn = nmcliSync(['-g', 'GENERAL.CONNECTION', 'device', 'show', iface], 8000).trim();
|
||||
return conn && conn !== '--' ? conn : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function _ensureActiveWifiAutoconnect() {
|
||||
const conn = getWifiActiveConnectionName();
|
||||
if (!conn || conn === CON_NAME) return;
|
||||
try {
|
||||
await nmcliAsync(['connection', 'modify', conn, 'connection.autoconnect', 'yes'], 15000);
|
||||
} catch (e) {
|
||||
log.warn('network', `设置 WiFi 自动连接失败: ${conn}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动让 NetworkManager 尝试已保存 WiFi。
|
||||
* clawd 只做调度;真正的认证、DHCP、重连细节仍交给 NM。
|
||||
*/
|
||||
async function connectSavedWifiConnections() {
|
||||
cancelHotspotRadioRetry('准备连接已保存 WiFi');
|
||||
const iface = getWifiIface();
|
||||
const profiles = listSavedWifiConnections();
|
||||
if (profiles.length === 0) {
|
||||
return { success: false, error: '没有已保存的 WiFi 配置' };
|
||||
}
|
||||
|
||||
try {
|
||||
await nmcliAsync(['device', 'set', iface, 'managed', 'yes'], 8000);
|
||||
} catch (_) {}
|
||||
return false;
|
||||
|
||||
let lastError = '';
|
||||
for (const profile of profiles) {
|
||||
const label = profile.name || profile.uuid;
|
||||
try {
|
||||
log.info('network', `尝试连接已保存 WiFi: ${label}(ifname=${iface})`);
|
||||
const idArgs = profile.uuid ? ['uuid', profile.uuid] : ['id', profile.name];
|
||||
await nmcliAsync(['connection', 'up', ...idArgs, 'ifname', iface], 90000);
|
||||
if (isWifiStaConnected()) {
|
||||
await _ensureActiveWifiAutoconnect();
|
||||
log.info('network', `已保存 WiFi 连接成功: ${label}`);
|
||||
return { success: true, profile };
|
||||
}
|
||||
lastError = '连接命令完成但网卡未进入 STA connected 状态';
|
||||
} catch (e) {
|
||||
lastError = e.message;
|
||||
log.warn('network', `已保存 WiFi 连接失败: ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: lastError || '所有已保存 WiFi 均连接失败' };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,22 +718,37 @@ function isWifiStaConnected() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function _ifaceNetworkType(name) {
|
||||
const wifi = getWifiIface();
|
||||
if (name === wifi || name.startsWith('wl')) return 'wifi';
|
||||
if (name === DEFAULT_ETH_IFACE || name.startsWith('en') || name.startsWith('eth')) return 'lan';
|
||||
return null;
|
||||
}
|
||||
|
||||
function _localNetworkEntries() {
|
||||
const ifaces = os.networkInterfaces();
|
||||
const entries = [];
|
||||
for (const [name, addrs] of Object.entries(ifaces)) {
|
||||
if (!addrs) continue;
|
||||
const type = _ifaceNetworkType(name);
|
||||
if (!type) continue;
|
||||
for (const addr of addrs) {
|
||||
if (addr.family !== 'IPv4' || addr.internal) continue;
|
||||
// clawd-hotspot 的 AP 管理网段只用于配网,不上报为 BOX 可访问地址。
|
||||
if (addr.address.startsWith('10.42.')) continue;
|
||||
entries.push({ ip: addr.address, type, iface: name });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本机所有非回环 IPv4 地址,逗号拼接返回
|
||||
* 例:'192.168.1.100' 或 '192.168.1.100,10.0.0.5'
|
||||
* 获取本机所有非回环 IPv4 地址,逗号拼接返回。
|
||||
* 保持旧协议字段 local_ip 兼容:'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 && !addr.address.startsWith('10.42.')) {
|
||||
ips.push(addr.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
const ips = _localNetworkEntries().map((entry) => entry.ip);
|
||||
return ips.length > 0 ? ips.join(',') : null;
|
||||
} catch (e) {
|
||||
log.warn('network', '获取本机 IP 失败:', e.message);
|
||||
@@ -421,19 +756,37 @@ function getLocalIps() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本机 IPv4 地址及网络类型,用于上报服务器。
|
||||
* 例:[{ ip: '192.168.1.100', type: 'wifi', iface: 'wlan0' }]
|
||||
*/
|
||||
function getLocalNetworks() {
|
||||
try {
|
||||
const entries = _localNetworkEntries();
|
||||
return entries.length > 0 ? entries : null;
|
||||
} catch (e) {
|
||||
log.warn('network', '获取本机网络类型失败:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasInternet,
|
||||
hasWiredCarrier,
|
||||
hasLanCableCarrier,
|
||||
hasWiredInternetProbe,
|
||||
getWiredIfaceWithCarrier,
|
||||
listSavedWifiConnections,
|
||||
hasSavedWifiConnection,
|
||||
connectSavedWifiConnections,
|
||||
isWifiStaConnected,
|
||||
getWifiIface,
|
||||
scanWifi,
|
||||
connectWifi,
|
||||
startAP,
|
||||
stopAP,
|
||||
cancelHotspotRadioRetry,
|
||||
AP_IP,
|
||||
getLocalIps,
|
||||
getLocalNetworks,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const { exec } = require('child_process');
|
||||
const log = require('./logger');
|
||||
const { resolveOpenclawConfigFile } = require('./frpc');
|
||||
|
||||
@@ -52,7 +54,7 @@ function fetchModels(baseUrl, apiKey, callback) {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.data && Array.isArray(json.data)) {
|
||||
callback(null, json.data.map((m) => ({ id: m.id, name: m.id })));
|
||||
callback(null, json.data.map((m) => ({ id: m.id, name: m.id, input: ['text', 'image'] })));
|
||||
} else if (json.error) {
|
||||
callback(new Error(json.error.message || JSON.stringify(json.error)));
|
||||
} else {
|
||||
@@ -82,6 +84,23 @@ function writeJsonFile(filePath, obj) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 终止 openclaw-gateway 进程,由 systemd --user 自动重新拉起以读取新配置。
|
||||
* 每次写盘 openclaw.json 成功后应调用一次。
|
||||
* 使用异步 exec,不阻塞 Node.js 事件循环,避免干扰 LED / VFD 等后续操作。
|
||||
*/
|
||||
function restartGateway() {
|
||||
exec('pkill -9 -x openclaw-gateway', (err) => {
|
||||
if (err && err.code !== 1) {
|
||||
log.warn('openclaw-provider', `restartGateway: ${err.message}`);
|
||||
} else if (!err) {
|
||||
log.info('openclaw-provider', 'openclaw-gateway 已终止,等待自动重启');
|
||||
} else {
|
||||
log.info('openclaw-provider', 'openclaw-gateway 进程不存在,无需终止');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步:从 openclaw.json 删除指定 provider(解绑)。
|
||||
* 若 primary 指向该 provider,先置为空串。
|
||||
@@ -125,6 +144,7 @@ function removeProviderByName(providerId) {
|
||||
}
|
||||
|
||||
writeJsonFile(configFile, config);
|
||||
restartGateway();
|
||||
log.info('openclaw-provider', `provider 已移除: ${providerId}`);
|
||||
}
|
||||
|
||||
@@ -143,6 +163,68 @@ function removeProviderFromConfig(config, providerId) {
|
||||
}
|
||||
}
|
||||
|
||||
const WEB_SEARCH_BASE_URL = 'https://web-search.cutos.ai/';
|
||||
|
||||
/**
|
||||
* 写入 .env 中的 KEY=value,已存在相同行则跳过,存在不同值则替换,不存在则追加。
|
||||
*/
|
||||
function ensureEnvVar(envFile, key, value) {
|
||||
const line = `${key}="${value}"`;
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(envFile, 'utf8'); } catch (_) {}
|
||||
|
||||
const re = new RegExp(`^${key}=.*`, 'm');
|
||||
if (re.test(content)) {
|
||||
const updated = content.replace(re, line);
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(envFile, updated, 'utf8');
|
||||
}
|
||||
} else {
|
||||
const sep = content.length && !content.endsWith('\n') ? '\n' : '';
|
||||
fs.writeFileSync(envFile, content + sep + line + '\n', 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并补全 searxng web search 配置(openclaw.json),返回 true 表示有修改。
|
||||
*/
|
||||
function ensureWebSearchConfig(configFile, config) {
|
||||
let dirty = false;
|
||||
|
||||
// .env: SEARXNG_BASE_URL
|
||||
try {
|
||||
const envFile = path.join(path.dirname(configFile), '.env');
|
||||
ensureEnvVar(envFile, 'SEARXNG_BASE_URL', WEB_SEARCH_BASE_URL);
|
||||
} catch (e) {
|
||||
log.warn('openclaw-provider', `ensureEnvVar failed: ${e.message}`);
|
||||
}
|
||||
|
||||
// tools.web.search / tools.web.fetch
|
||||
const curSearch = config.tools?.web?.search;
|
||||
const curFetch = config.tools?.web?.fetch;
|
||||
if (!curSearch?.openaiCodex || curFetch?.enabled !== true) {
|
||||
if (!config.tools) config.tools = {};
|
||||
if (!config.tools.web) config.tools.web = {};
|
||||
config.tools.web.search = { openaiCodex: {} };
|
||||
config.tools.web.fetch = { enabled: true };
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
// plugins.entries.searxng
|
||||
const cur = config.plugins?.entries?.searxng;
|
||||
if (!cur || cur.enabled !== true || cur.config?.webSearch?.baseUrl !== WEB_SEARCH_BASE_URL) {
|
||||
if (!config.plugins) config.plugins = {};
|
||||
if (!config.plugins.entries) config.plugins.entries = {};
|
||||
config.plugins.entries.searxng = {
|
||||
config: { webSearch: { baseUrl: WEB_SEARCH_BASE_URL } },
|
||||
enabled: true,
|
||||
};
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
return dirty;
|
||||
}
|
||||
|
||||
function addProviderSync(configFile, providerId, baseUrl, apiKey, models, defaultModelRaw) {
|
||||
const config = readJsonFile(configFile);
|
||||
|
||||
@@ -186,7 +268,10 @@ function addProviderSync(configFile, providerId, baseUrl, apiKey, models, defaul
|
||||
mode: 'api_key',
|
||||
};
|
||||
|
||||
ensureWebSearchConfig(configFile, config);
|
||||
|
||||
writeJsonFile(configFile, config);
|
||||
restartGateway();
|
||||
log.info('openclaw-provider', `provider 已写入: ${providerId}(${models.length} 个模型)`);
|
||||
}
|
||||
|
||||
@@ -223,6 +308,28 @@ function applyFullProviderFromVps(provider, onDone) {
|
||||
if (err) {
|
||||
log.warn('openclaw-provider', `拉模型失败,使用空列表: ${err.message}`);
|
||||
}
|
||||
|
||||
// 校验 apiKey + 模型列表是否有实际变化,无变化则跳过写盘,避免触发不必要的 gateway 重启
|
||||
try {
|
||||
const existing = readJsonFile(configFile);
|
||||
const cur = existing.models?.providers?.[name] || {};
|
||||
const curApiKey = cur.apiKey || '';
|
||||
const curMd5 = computeModelsMd5(cur.models || []);
|
||||
const newMd5 = computeModelsMd5(list);
|
||||
if (curApiKey === apiKey && curMd5 === newMd5) {
|
||||
// provider 无变化,但仍检查 web search 配置
|
||||
if (ensureWebSearchConfig(configFile, existing)) {
|
||||
writeJsonFile(configFile, existing);
|
||||
restartGateway();
|
||||
log.info('openclaw-provider', 'web search config applied (provider unchanged)');
|
||||
} else {
|
||||
log.info('openclaw-provider', `provider 无变化(apiKey + 模型列表相同),跳过写盘`);
|
||||
}
|
||||
if (typeof onDone === 'function') { try { onDone(); } catch (e) { log.warn('openclaw-provider', `onDone: ${e.message}`); } }
|
||||
return;
|
||||
}
|
||||
} catch (_) { /* 读取失败则继续写盘 */ }
|
||||
|
||||
addProviderSync(configFile, name, baseUrl, apiKey, list, defaultModel);
|
||||
if (typeof onDone === 'function') {
|
||||
try {
|
||||
@@ -246,9 +353,104 @@ function isFullProvider(p) {
|
||||
|| Object.prototype.hasOwnProperty.call(p, 'baseUrl');
|
||||
}
|
||||
|
||||
/**
|
||||
* 对模型列表计算 MD5(按 id 排序后 JSON 序列化),用于变更检测。
|
||||
*/
|
||||
function computeModelsMd5(models) {
|
||||
const ids = (models || []).map((m) => m.id).sort();
|
||||
return crypto.createHash('md5').update(JSON.stringify(ids)).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 重连时刷新模型列表:读取现有 openclaw.json 中第一个 provider 的 baseUrl/apiKey,
|
||||
* 拉取最新模型,MD5 与现有模型对比,不一致才写盘(触发 gateway 自动重启)。
|
||||
* 若模型未变则跳过,不写盘,不触发 gateway 重启。
|
||||
* 完成后调用 onDone()(无论是否更新)。
|
||||
*/
|
||||
function refreshModelsIfChanged(onDone) {
|
||||
if (_busy) {
|
||||
log.info('openclaw-provider', 'refreshModels: 有操作进行中,跳过');
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
const configFile = resolveOpenclawConfigFile();
|
||||
if (!configFile) {
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = readJsonFile(configFile);
|
||||
} catch (e) {
|
||||
log.warn('openclaw-provider', `refreshModels: 读取配置失败: ${e.message}`);
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
// provider 不存在时也要确保 web search 配置
|
||||
const providers = config.models?.providers || {};
|
||||
const providerId = Object.keys(providers)[0];
|
||||
if (!providerId) {
|
||||
try {
|
||||
if (ensureWebSearchConfig(configFile, config)) {
|
||||
writeJsonFile(configFile, config);
|
||||
restartGateway();
|
||||
log.info('openclaw-provider', 'web search config applied (no provider)');
|
||||
}
|
||||
} catch (_) {}
|
||||
log.info('openclaw-provider', 'refreshModels: 未找到已配置的 provider,跳过模型刷新');
|
||||
if (typeof onDone === 'function') onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
const providerCfg = providers[providerId];
|
||||
const baseUrl = providerCfg.baseUrl || '';
|
||||
const apiKey = providerCfg.apiKey || '';
|
||||
const currentModels = providerCfg.models || [];
|
||||
|
||||
_busy = true;
|
||||
fetchModels(baseUrl, apiKey, (err, newModels) => {
|
||||
try {
|
||||
if (err) {
|
||||
log.warn('openclaw-provider', `refreshModels: 拉模型失败: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMd5 = computeModelsMd5(currentModels);
|
||||
const newMd5 = computeModelsMd5(newModels);
|
||||
|
||||
if (currentMd5 === newMd5) {
|
||||
// 模型未变,但仍检查 web search 配置是否缺失或过期
|
||||
try {
|
||||
const cfg = readJsonFile(configFile);
|
||||
if (ensureWebSearchConfig(configFile, cfg)) {
|
||||
writeJsonFile(configFile, cfg);
|
||||
restartGateway();
|
||||
log.info('openclaw-provider', 'web search config repaired');
|
||||
} else {
|
||||
log.info('openclaw-provider', `模型列表未变化(${newModels.length} 个),跳过更新`);
|
||||
}
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('openclaw-provider', `模型列表已变化(${currentModels.length} → ${newModels.length} 个),更新 openclaw.json`);
|
||||
addProviderSync(configFile, providerId, baseUrl, apiKey, newModels, null);
|
||||
} catch (e) {
|
||||
log.error('openclaw-provider', `refreshModels: ${e.message}`);
|
||||
} finally {
|
||||
_busy = false;
|
||||
if (typeof onDone === 'function') onDone();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyFullProviderFromVps,
|
||||
removeProviderByName,
|
||||
refreshModelsIfChanged,
|
||||
isFullProvider,
|
||||
DEFAULT_BASE_URL,
|
||||
};
|
||||
|
||||
@@ -1,261 +1,362 @@
|
||||
'use strict';
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const log = require('./logger');
|
||||
const { hasInternet, hasSavedWifiConnection, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, getWifiIface, AP_IP } = require('./network');
|
||||
const { DnsHijack } = require('./dns-hijack');
|
||||
const { CaptiveServer } = require('./captive-server');
|
||||
const led = require('./led');
|
||||
|
||||
const MONITOR_INTERVAL_MS = 15_000;
|
||||
const BOOT_WAIT_MAX_MS = 20_000; // 等待 NM 自动连接的最大时间
|
||||
const BOOT_POLL_MS = 2_000; // 轮询间隔
|
||||
|
||||
/**
|
||||
* AP 常驻配网管理器。
|
||||
*
|
||||
* 规则:
|
||||
* - 启动时:有已保存 WiFi 配置 → 等 NM 自动连接(最多 20 秒)
|
||||
* - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页
|
||||
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP
|
||||
* - 运行中 WiFi 断开 → 自动重新开 AP
|
||||
* - WiFi 已连接 → AP 关闭
|
||||
*/
|
||||
class ProvisionManager extends EventEmitter {
|
||||
constructor(clawId) {
|
||||
super();
|
||||
this._clawId = clawId || 'Setup';
|
||||
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta'
|
||||
this._dns = null;
|
||||
this._server = null;
|
||||
this._monitorTimer = null;
|
||||
}
|
||||
|
||||
/** 是否正处于 AP 模式(WiFi 热点广播中) */
|
||||
isApMode() { return this._state === 'ap'; }
|
||||
|
||||
async start() {
|
||||
led.off(); // WiFi 灯初始状态:熄灭
|
||||
|
||||
// WiFi STA 已连接 → 直接进入 STA 模式
|
||||
if (isWifiStaConnected()) {
|
||||
this._state = 'sta';
|
||||
log.info('provision', 'WiFi STA 已连接,AP 不启动');
|
||||
this._emitNetworkReady();
|
||||
this._startMonitor();
|
||||
return;
|
||||
}
|
||||
|
||||
// 有线有网 → 立即通知 WS 连接,后台异步设置 AP 供 WiFi 配网
|
||||
if (hasInternet()) {
|
||||
log.info('provision', '有线网络就绪,立即启动 WS,AP 后台准备中...');
|
||||
this._emitNetworkReady();
|
||||
setTimeout(() => {
|
||||
this._enterAP();
|
||||
this._startMonitor();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 无网:有已保存的 WiFi 配置 → 等 NM 自动连接(重启场景)
|
||||
if (hasSavedWifiConnection()) {
|
||||
log.info('provision', '发现已保存的 WiFi 配置,等待 NetworkManager 自动连接...');
|
||||
led.blink(); // WiFi 灯:等待自动重连期间闪烁
|
||||
const connected = await this._waitForWifiConnect();
|
||||
if (connected) {
|
||||
this._state = 'sta';
|
||||
log.info('provision', 'WiFi 自动连接成功,AP 不启动');
|
||||
this._emitNetworkReady();
|
||||
this._startMonitor();
|
||||
return;
|
||||
}
|
||||
log.warn('provision', 'WiFi 自动连接超时,启动 AP');
|
||||
}
|
||||
|
||||
// 没有已保存 WiFi 或等待超时 → 开 AP
|
||||
this._enterAP();
|
||||
this._startMonitor();
|
||||
|
||||
if (hasInternet()) {
|
||||
this._emitNetworkReady();
|
||||
}
|
||||
}
|
||||
|
||||
_emitNetworkReady() {
|
||||
if (hasInternet()) {
|
||||
// WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮)
|
||||
if (this._state === 'sta') led.on();
|
||||
this.emit('network-ready');
|
||||
} else {
|
||||
log.warn('provision', 'hasInternet() 返回 false,LED 保持熄灭');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询等待 NM 自动连接 WiFi,最多等 BOOT_WAIT_MAX_MS
|
||||
*/
|
||||
_waitForWifiConnect() {
|
||||
return new Promise(resolve => {
|
||||
let elapsed = 0;
|
||||
const timer = setInterval(() => {
|
||||
elapsed += BOOT_POLL_MS;
|
||||
if (isWifiStaConnected()) {
|
||||
clearInterval(timer);
|
||||
resolve(true);
|
||||
} else if (elapsed >= BOOT_WAIT_MAX_MS) {
|
||||
clearInterval(timer);
|
||||
resolve(false);
|
||||
}
|
||||
}, BOOT_POLL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._stopMonitor();
|
||||
this._stopAll();
|
||||
this._state = 'idle';
|
||||
led.destroy(); // WiFi 灯:停止时关灯、释放闪烁定时器
|
||||
}
|
||||
|
||||
// ── 进入 AP 模式 ─────────────────────────────────────────────────────────
|
||||
|
||||
_enterAP() {
|
||||
if (this._state === 'ap') return;
|
||||
|
||||
led.off(); // AP 模式:WiFi 未连接,WiFi 灯熄灭
|
||||
if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP,有线时等 WS 连接后再定
|
||||
|
||||
try {
|
||||
// AP 模式下无法扫描 WiFi,必须在开 AP 之前扫描并缓存
|
||||
log.info('provision', '扫描周边 WiFi...');
|
||||
this._cachedWifiList = scanWifi();
|
||||
log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络`);
|
||||
|
||||
// 写 DNS 劫持配置(NM 启动热点时加载);接口名与热点一致,勿写死 wlan0
|
||||
this._dns = new DnsHijack();
|
||||
this._dns.start(getWifiIface(), AP_IP);
|
||||
|
||||
const ap = startAP(this._clawId);
|
||||
|
||||
this._server = new CaptiveServer({
|
||||
clawId: this._clawId,
|
||||
cachedWifiList: this._cachedWifiList,
|
||||
onConnect: (ssid, password) => this._handleWifiConnect(ssid, password),
|
||||
});
|
||||
this._server.startListening();
|
||||
|
||||
this._state = 'ap';
|
||||
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
|
||||
log.info('provision', `配网地址: http://10.42.0.1`);
|
||||
} catch (e) {
|
||||
log.error('provision', `AP 启动失败: ${e.message}`);
|
||||
if (this._state !== 'sta') this._state = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
|
||||
|
||||
async _handleWifiConnect(ssid, password) {
|
||||
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
|
||||
|
||||
this._state = 'connecting';
|
||||
log.info('provision', `用户请求连接 WiFi: ${ssid}`);
|
||||
led.blink(); // 正在连接 → 闪烁
|
||||
|
||||
try {
|
||||
this._stopAPServices();
|
||||
|
||||
// 关热点后射频/模式切换需要时间,立刻 connect 在部分板子上会失败
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
|
||||
const result = await connectWifi(ssid, password);
|
||||
|
||||
if (result.success) {
|
||||
this._state = 'sta';
|
||||
log.info('provision', `WiFi 已连接: ${ssid}`);
|
||||
led.on(); // WiFi 灯:连接成功 → 常亮
|
||||
this.emit('network-ready');
|
||||
return result;
|
||||
}
|
||||
|
||||
log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`);
|
||||
this._safeReenterAP();
|
||||
return result;
|
||||
} catch (e) {
|
||||
log.error('provision', `配网过程异常: ${e.message}`);
|
||||
this._safeReenterAP();
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 重新开 AP;失败时勿把 _state 永久卡在 connecting */
|
||||
_safeReenterAP() {
|
||||
try {
|
||||
this._enterAP();
|
||||
} catch (e) {
|
||||
log.error('provision', `重新启动 AP 失败: ${e.message}`);
|
||||
this._state = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
// ── WiFi 状态监控 ─────────────────────────────────────────────────────────
|
||||
|
||||
_startMonitor() {
|
||||
this._monitorTimer = setInterval(() => {
|
||||
if (this._state === 'connecting') return;
|
||||
|
||||
const wifiUp = isWifiStaConnected();
|
||||
|
||||
if (this._state === 'sta' && !wifiUp) {
|
||||
log.warn('provision', 'WiFi 连接已断开,重新启动 AP');
|
||||
this._enterAP(); // 内部调用 led.off()
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._state === 'ap' && wifiUp) {
|
||||
log.info('provision', 'WiFi 已外部连接,关闭 AP');
|
||||
this._stopAPServices();
|
||||
this._state = 'sta';
|
||||
this.emit('network-ready');
|
||||
}
|
||||
|
||||
// 产品 WiFi 灯(OpenVFD wifi+eth):AP 全程强制熄灭,避免与其它逻辑竞态导致误亮
|
||||
if (this._state === 'ap') {
|
||||
led.off();
|
||||
} else if (this._state === 'sta') {
|
||||
if (hasInternet()) {
|
||||
led.on();
|
||||
} else {
|
||||
led.off(); // STA 已连热点但无互联网
|
||||
}
|
||||
}
|
||||
}, MONITOR_INTERVAL_MS);
|
||||
}
|
||||
|
||||
_stopMonitor() {
|
||||
if (this._monitorTimer) {
|
||||
clearInterval(this._monitorTimer);
|
||||
this._monitorTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 清理 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_stopAPServices() {
|
||||
if (this._server) {
|
||||
this._server.stop();
|
||||
this._server = null;
|
||||
}
|
||||
if (this._dns) {
|
||||
this._dns.stop();
|
||||
this._dns = null;
|
||||
}
|
||||
stopAP();
|
||||
}
|
||||
|
||||
_stopAll() {
|
||||
this._stopAPServices();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ProvisionManager };
|
||||
'use strict';
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const log = require('./logger');
|
||||
const { hasInternet, hasWiredInternetProbe, hasSavedWifiConnection, connectSavedWifiConnections, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, getWifiIface, AP_IP } = require('./network');
|
||||
const { DnsHijack } = require('./dns-hijack');
|
||||
const { CaptiveServer } = require('./captive-server');
|
||||
const led = require('./led');
|
||||
|
||||
const MONITOR_INTERVAL_MS = 15_000;
|
||||
const WIFI_RECONNECT_MAX_ROUNDS = 3;
|
||||
const WIFI_RECONNECT_ROUND_DELAY_MS = 5_000;
|
||||
const AP_SAVED_WIFI_RETRY_INTERVAL_MS = 180_000;
|
||||
const AP_MIN_UP_BEFORE_RETRY_MS = 60_000;
|
||||
|
||||
/**
|
||||
* AP 常驻配网管理器。
|
||||
*
|
||||
* 规则:
|
||||
* - 启动时:WiFi STA 优先;有已保存 WiFi 时主动让 NM 重连,最多 3 轮
|
||||
* - 有线网络可用时:通知网络就绪,但不自动开启 AP
|
||||
* - 自动开 AP 的唯一兜底:无有线/无 WiFi,且无 saved WiFi 或 saved WiFi 3 轮失败
|
||||
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则按网络状态决定是否重新开 AP
|
||||
* - AP 状态下:若仍无有线网络,低频释放 wlan0 并尝试 saved WiFi
|
||||
*/
|
||||
class ProvisionManager extends EventEmitter {
|
||||
constructor(clawId) {
|
||||
super();
|
||||
this._clawId = clawId || 'Setup';
|
||||
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta' | 'wired'
|
||||
this._dns = null;
|
||||
this._server = null;
|
||||
this._monitorTimer = null;
|
||||
this._monitorBusy = false;
|
||||
this._apStartedAt = 0;
|
||||
this._lastApSavedWifiRetryAt = 0;
|
||||
}
|
||||
|
||||
/** 是否正处于 AP 模式(WiFi 热点广播中) */
|
||||
isApMode() { return this._state === 'ap'; }
|
||||
|
||||
async start() {
|
||||
led.off(); // WiFi 灯初始状态:熄灭
|
||||
|
||||
// WiFi STA 已连接 → 直接进入 STA 模式
|
||||
if (isWifiStaConnected()) {
|
||||
this._state = 'sta';
|
||||
log.info('provision', 'WiFi STA 已连接,AP 不启动');
|
||||
this._emitNetworkReady();
|
||||
this._startMonitor();
|
||||
return;
|
||||
}
|
||||
|
||||
// 网络已就绪时先启动 WS;hasInternet() 可能来自 WiFi,也可能来自有线,不能直接当作 wired。
|
||||
if (hasInternet()) {
|
||||
if (isWifiStaConnected()) {
|
||||
this._state = 'sta';
|
||||
log.info('provision', 'WiFi STA 已连接,AP 不启动');
|
||||
this._emitNetworkReady();
|
||||
} else {
|
||||
this._state = 'wired';
|
||||
log.info('provision', '有线网络就绪,启动 WS;不自动开启 AP');
|
||||
led.off();
|
||||
this._emitNetworkReady();
|
||||
}
|
||||
this._startMonitor();
|
||||
return;
|
||||
}
|
||||
|
||||
// 无有线可用时,有 saved WiFi 才主动让 NetworkManager 重连;不要只被动等待 NM autoconnect。
|
||||
if (hasSavedWifiConnection()) {
|
||||
log.info('provision', `发现已保存的 WiFi 配置,主动重连(最多 ${WIFI_RECONNECT_MAX_ROUNDS} 轮)...`);
|
||||
this._state = 'connecting';
|
||||
led.blink();
|
||||
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
|
||||
if (connected) {
|
||||
this._state = 'sta';
|
||||
log.info('provision', '已保存 WiFi 重连成功,AP 不启动');
|
||||
this._emitNetworkReady();
|
||||
this._startMonitor();
|
||||
return;
|
||||
}
|
||||
log.warn('provision', '已保存 WiFi 重连失败');
|
||||
}
|
||||
|
||||
// 无有线、无 WiFi;且无 saved WiFi 或 saved WiFi 3 轮失败 → 开 AP 兜底配网。
|
||||
this._enterAP();
|
||||
this._startMonitor();
|
||||
}
|
||||
|
||||
_emitNetworkReady() {
|
||||
if (hasInternet()) {
|
||||
// WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮)
|
||||
if (this._state === 'sta') led.on();
|
||||
this.emit('network-ready');
|
||||
} else {
|
||||
log.warn('provision', 'hasInternet() 返回 false,LED 保持熄灭');
|
||||
}
|
||||
}
|
||||
|
||||
async _trySavedWifiReconnectRounds(rounds = WIFI_RECONNECT_MAX_ROUNDS) {
|
||||
for (let i = 1; i <= rounds; i++) {
|
||||
if (isWifiStaConnected()) return true;
|
||||
log.info('provision', `尝试已保存 WiFi 重连:第 ${i}/${rounds} 轮`);
|
||||
const result = await connectSavedWifiConnections();
|
||||
if (result.success || isWifiStaConnected()) return true;
|
||||
if (i < rounds) {
|
||||
await new Promise((r) => setTimeout(r, WIFI_RECONNECT_ROUND_DELAY_MS));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._stopMonitor();
|
||||
this._stopAll();
|
||||
this._state = 'idle';
|
||||
led.destroy(); // WiFi 灯:停止时关灯、释放闪烁定时器
|
||||
}
|
||||
|
||||
// ── 进入 AP 模式 ─────────────────────────────────────────────────────────
|
||||
|
||||
_enterAP() {
|
||||
if (this._state === 'ap') return;
|
||||
|
||||
led.off(); // AP 模式:WiFi 未连接,WiFi 灯熄灭
|
||||
if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP,有线时等 WS 连接后再定
|
||||
|
||||
try {
|
||||
// 若上次进程退出前留下 clawd-hotspot,必须先释放 wlan0;否则会在 AP 模式下扫描,列表可能只剩 2.4G/自身热点。
|
||||
stopAP();
|
||||
|
||||
// AP 模式下无法扫描 WiFi,必须在开 AP 之前扫描并缓存
|
||||
log.info('provision', '扫描周边 WiFi...');
|
||||
this._cachedWifiList = scanWifi();
|
||||
log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络: ${this._cachedWifiList.map(w => `${w.ssid}${w.band ? `(${w.band})` : ''}`).join(', ')}`);
|
||||
|
||||
// 写 DNS 劫持配置(NM 启动热点时加载);接口名与热点一致,勿写死 wlan0
|
||||
this._dns = new DnsHijack();
|
||||
this._dns.start(getWifiIface(), AP_IP);
|
||||
|
||||
const ap = startAP(this._clawId);
|
||||
|
||||
this._server = new CaptiveServer({
|
||||
clawId: this._clawId,
|
||||
cachedWifiList: this._cachedWifiList,
|
||||
onConnect: (ssid, password) => this._handleWifiConnect(ssid, password),
|
||||
});
|
||||
this._server.startListening();
|
||||
|
||||
this._state = 'ap';
|
||||
this._apStartedAt = Date.now();
|
||||
this._lastApSavedWifiRetryAt = 0;
|
||||
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
|
||||
log.info('provision', `配网地址: http://10.42.0.1`);
|
||||
} catch (e) {
|
||||
log.error('provision', `AP 启动失败: ${e.message}`);
|
||||
if (this._state !== 'sta') this._state = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
|
||||
|
||||
async _handleWifiConnect(ssid, password) {
|
||||
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
|
||||
|
||||
this._state = 'connecting';
|
||||
log.info('provision', `用户请求连接 WiFi: ${ssid}`);
|
||||
led.blink(); // 正在连接 → 闪烁
|
||||
|
||||
try {
|
||||
this._stopAPServices();
|
||||
|
||||
// 关热点后射频/模式切换需要时间,立刻 connect 在部分板子上会失败
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
|
||||
const result = await connectWifi(ssid, password);
|
||||
|
||||
if (result.success) {
|
||||
this._state = 'sta';
|
||||
log.info('provision', `WiFi 已连接: ${ssid}`);
|
||||
led.on(); // WiFi 灯:连接成功 → 常亮
|
||||
this.emit('network-ready');
|
||||
return result;
|
||||
}
|
||||
|
||||
log.warn('provision', `WiFi 连接失败: ${result.error},按当前网络状态恢复`);
|
||||
this._recoverAfterWifiFailure();
|
||||
return result;
|
||||
} catch (e) {
|
||||
log.error('provision', `配网过程异常: ${e.message}`);
|
||||
this._recoverAfterWifiFailure();
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** WiFi 连接失败后:有线可用则保持 wired;否则开 AP 兜底。 */
|
||||
_recoverAfterWifiFailure() {
|
||||
if (hasInternet()) {
|
||||
this._state = 'wired';
|
||||
led.off();
|
||||
this._emitNetworkReady();
|
||||
return;
|
||||
}
|
||||
this._safeReenterAP();
|
||||
}
|
||||
|
||||
/** 重新开 AP;失败时勿把 _state 永久卡在 connecting */
|
||||
_safeReenterAP() {
|
||||
try {
|
||||
this._enterAP();
|
||||
} catch (e) {
|
||||
log.error('provision', `重新启动 AP 失败: ${e.message}`);
|
||||
this._state = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
// ── WiFi 状态监控 ─────────────────────────────────────────────────────────
|
||||
|
||||
_startMonitor() {
|
||||
this._monitorTimer = setInterval(() => {
|
||||
if (this._monitorBusy) return;
|
||||
this._monitorBusy = true;
|
||||
this._monitorTick()
|
||||
.catch((e) => log.error('provision', `WiFi 状态监控异常: ${e.message}`))
|
||||
.finally(() => { this._monitorBusy = false; });
|
||||
}, MONITOR_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async _monitorTick() {
|
||||
if (this._state === 'connecting') return;
|
||||
|
||||
const wifiUp = isWifiStaConnected();
|
||||
|
||||
if (wifiUp && this._state !== 'sta') {
|
||||
if (this._state === 'ap') {
|
||||
log.info('provision', 'WiFi 已外部连接,关闭 AP');
|
||||
this._stopAPServices();
|
||||
}
|
||||
this._state = 'sta';
|
||||
this._emitNetworkReady();
|
||||
}
|
||||
|
||||
if (this._state === 'sta' && !wifiUp) {
|
||||
log.warn('provision', 'WiFi 连接已断开,尝试恢复网络');
|
||||
await this._recoverNetworkWithoutWifi();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._state === 'wired') {
|
||||
if (!hasInternet()) {
|
||||
log.warn('provision', '有线网络不可用,尝试恢复 WiFi');
|
||||
await this._recoverNetworkWithoutWifi();
|
||||
return;
|
||||
}
|
||||
led.off();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._state === 'ap') {
|
||||
// AP 模式下 hasInternet() 可能被热点本地网络 / NetworkManager limited 状态误判。
|
||||
// 只有明确探测到有线口可访问公网时,才关闭配网 AP。
|
||||
if (hasWiredInternetProbe()) {
|
||||
log.info('provision', '检测到有线网络可用,关闭 AP');
|
||||
this._stopAPServices();
|
||||
this._state = 'wired';
|
||||
this._emitNetworkReady();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSavedWifiConnection() && this._shouldRetrySavedWifiFromAP()) {
|
||||
await this._retrySavedWifiFromAP();
|
||||
return;
|
||||
}
|
||||
led.off();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async _recoverNetworkWithoutWifi() {
|
||||
this._state = 'connecting';
|
||||
led.blink();
|
||||
|
||||
if (hasSavedWifiConnection()) {
|
||||
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
|
||||
if (connected) {
|
||||
this._state = 'sta';
|
||||
this._emitNetworkReady();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasInternet()) {
|
||||
this._state = 'wired';
|
||||
led.off();
|
||||
this._emitNetworkReady();
|
||||
return;
|
||||
}
|
||||
|
||||
this._safeReenterAP();
|
||||
}
|
||||
|
||||
_shouldRetrySavedWifiFromAP() {
|
||||
const now = Date.now();
|
||||
if (this._apStartedAt && now - this._apStartedAt < AP_MIN_UP_BEFORE_RETRY_MS) return false;
|
||||
if (this._lastApSavedWifiRetryAt && now - this._lastApSavedWifiRetryAt < AP_SAVED_WIFI_RETRY_INTERVAL_MS) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async _retrySavedWifiFromAP() {
|
||||
this._lastApSavedWifiRetryAt = Date.now();
|
||||
log.info('provision', 'AP 模式下定期尝试已保存 WiFi');
|
||||
this._state = 'connecting';
|
||||
led.blink();
|
||||
this._stopAPServices();
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
|
||||
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
|
||||
if (connected) {
|
||||
this._state = 'sta';
|
||||
this._emitNetworkReady();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasInternet()) {
|
||||
this._state = 'wired';
|
||||
led.off();
|
||||
this._emitNetworkReady();
|
||||
return;
|
||||
}
|
||||
|
||||
log.warn('provision', 'AP 模式下重试已保存 WiFi 失败,恢复 AP');
|
||||
this._safeReenterAP();
|
||||
}
|
||||
|
||||
_stopMonitor() {
|
||||
if (this._monitorTimer) {
|
||||
clearInterval(this._monitorTimer);
|
||||
this._monitorTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 清理 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_stopAPServices() {
|
||||
if (this._server) {
|
||||
this._server.stop();
|
||||
this._server = null;
|
||||
}
|
||||
if (this._dns) {
|
||||
this._dns.stop();
|
||||
this._dns = null;
|
||||
}
|
||||
stopAP();
|
||||
}
|
||||
|
||||
_stopAll() {
|
||||
this._stopAPServices();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ProvisionManager };
|
||||
|
||||
39
lib/resource/3588s/README.md
Normal file
39
lib/resource/3588s/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# RK3588S demo resource
|
||||
|
||||
This directory contains the LVGL demo binary deployed on RK3588S devices by `install.sh`.
|
||||
|
||||
## Files
|
||||
|
||||
- `demo` — prebuilt LVGL UI binary installed to `/usr/bin/demo` on RK3588S boards
|
||||
|
||||
## Source
|
||||
|
||||
Current binary source on the build machine:
|
||||
|
||||
- `/home/sts/share/小屏demo开发指南/lvgl源码/lv_port_linux_v2/lv_port_linux/build/bin/demo`
|
||||
|
||||
## Purpose
|
||||
|
||||
This demo provides the small-screen UI used on RK3588S devices, including FIFO-based control through:
|
||||
|
||||
- `/tmp/lvgl_cmd`
|
||||
|
||||
Supported commands expected by the current clawd RK3588 LVGL backend include:
|
||||
|
||||
- `show_text:AP`
|
||||
- `show_text:Conn`
|
||||
- `show_text:Err0`
|
||||
- `show_text:<PIN>`
|
||||
- `show_time`
|
||||
|
||||
## Install behavior
|
||||
|
||||
During `install.sh`, if `/proc/device-tree/model` matches `RK3588S`, clawd will:
|
||||
|
||||
1. Back up existing `/usr/bin/demo` to `/usr/bin/demo.clawd-bak` if not already backed up
|
||||
2. Install this `demo` binary to `/usr/bin/demo`
|
||||
|
||||
## Notes
|
||||
|
||||
- The binary is hardware-specific and intended for RK3588S boards.
|
||||
- Replacing the binary should be done together with verification of `/tmp/lvgl_cmd` behavior and screen rendering.
|
||||
BIN
lib/resource/3588s/demo
Executable file
BIN
lib/resource/3588s/demo
Executable file
Binary file not shown.
21
lib/resource/3588s/src/LICENSE
Executable file
21
lib/resource/3588s/src/LICENSE
Executable file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Littlev Graphics Library
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
105
lib/resource/3588s/src/Makefile
Executable file
105
lib/resource/3588s/src/Makefile
Executable file
@@ -0,0 +1,105 @@
|
||||
ROOT_DIR := $(shell pwd)
|
||||
|
||||
LVGL_DIR_NAME ?= lvgl
|
||||
|
||||
CC ?= gcc
|
||||
|
||||
TARGET_NAME := demo
|
||||
|
||||
BUILD_DIR := build
|
||||
OBJ_DIR := $(BUILD_DIR)/obj
|
||||
BIN_DIR := $(BUILD_DIR)/bin
|
||||
TARGET := $(BIN_DIR)/$(TARGET_NAME)
|
||||
|
||||
IMG_SRC_DIR := user/images
|
||||
|
||||
# 图片文件名固定为 logo,后缀可以是 png / jpg / jpeg / bmp
|
||||
LOGO_NAME := logo
|
||||
LOGO_IMG_EXTS := png jpg jpeg bmp
|
||||
LOGO_IMG_FILES := $(foreach ext,$(LOGO_IMG_EXTS),$(IMG_SRC_DIR)/$(LOGO_NAME).$(ext))
|
||||
LOGO_IMG := $(firstword $(wildcard $(LOGO_IMG_FILES)))
|
||||
|
||||
LOGO_C := $(LOGO_NAME).c
|
||||
|
||||
LVGL_IMG_CONV := lv_img_conv
|
||||
LVGL_IMG_CF := CF_TRUE_COLOR_ALPHA
|
||||
|
||||
APP_CSRCS := $(filter-out logo.c,$(wildcard *.c))
|
||||
APP_CSRCS += $(shell if [ -d src ]; then find src -type f -name "*.c"; fi)
|
||||
|
||||
LVGL_CSRCS := $(shell if [ -d $(LVGL_DIR_NAME)/src ]; then find $(LVGL_DIR_NAME)/src -type f -name "*.c"; fi)
|
||||
|
||||
LV_DRIVERS_CSRCS := $(shell if [ -d lv_drivers ]; then find lv_drivers -type f -name "*.c"; fi)
|
||||
|
||||
CSRCS := $(APP_CSRCS)
|
||||
CSRCS += $(LVGL_CSRCS)
|
||||
CSRCS += $(LV_DRIVERS_CSRCS)
|
||||
CSRCS += $(LOGO_C)
|
||||
|
||||
OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(CSRCS))
|
||||
|
||||
CFLAGS ?= -O3 -g0
|
||||
CFLAGS += -Wall
|
||||
CFLAGS += -I.
|
||||
CFLAGS += -I$(LVGL_DIR_NAME)
|
||||
CFLAGS += -I$(LVGL_DIR_NAME)/src
|
||||
CFLAGS += -Ilv_drivers
|
||||
CFLAGS += -Iinclude
|
||||
|
||||
LDFLAGS += -lm
|
||||
|
||||
.PHONY: all
|
||||
all: $(TARGET)
|
||||
|
||||
$(BIN_DIR):
|
||||
mkdir -p $(BIN_DIR)
|
||||
|
||||
$(OBJ_DIR):
|
||||
mkdir -p $(OBJ_DIR)
|
||||
|
||||
$(LOGO_C): $(LOGO_IMG)
|
||||
@if [ -z "$(LOGO_IMG)" ]; then \
|
||||
echo "Error: 未找到图片文件"; \
|
||||
echo "请将图片命名为以下任意一种格式,并放入 $(IMG_SRC_DIR) 目录:"; \
|
||||
echo " logo.png"; \
|
||||
echo " logo.jpg"; \
|
||||
echo " logo.jpeg"; \
|
||||
echo " logo.bmp"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Use image: $(LOGO_IMG)"
|
||||
rm -f $(LOGO_C)
|
||||
cp $(LOGO_IMG) ./$(notdir $(LOGO_IMG))
|
||||
$(LVGL_IMG_CONV) $(notdir $(LOGO_IMG)) -f -c $(LVGL_IMG_CF)
|
||||
rm -f ./$(notdir $(LOGO_IMG))
|
||||
|
||||
$(OBJ_DIR)/%.o: %.c
|
||||
mkdir -p $(dir $@)
|
||||
$(CC) $(CFLAGS) -c $< -o $@
|
||||
|
||||
$(TARGET): $(LOGO_C) $(OBJS) | $(BIN_DIR)
|
||||
$(CC) $(OBJS) -o $(TARGET) $(LDFLAGS)
|
||||
@echo "Build success: $(TARGET)"
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf $(OBJ_DIR)
|
||||
rm -f $(TARGET)
|
||||
|
||||
.PHONY: imgclean
|
||||
imgclean:
|
||||
rm -f $(LOGO_C)
|
||||
|
||||
.PHONY: distclean
|
||||
distclean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
rm -f $(LOGO_C)
|
||||
|
||||
.PHONY: info
|
||||
info:
|
||||
@echo "TARGET = $(TARGET)"
|
||||
@echo "IMG_SRC_DIR = $(IMG_SRC_DIR)"
|
||||
@echo "LOGO_IMG = $(LOGO_IMG)"
|
||||
@echo "LOGO_C = $(LOGO_C)"
|
||||
@echo "Support image formats: $(LOGO_IMG_EXTS)"
|
||||
@echo "CSRCS = $(CSRCS)"
|
||||
8
lib/resource/3588s/src/README.md
Executable file
8
lib/resource/3588s/src/README.md
Executable file
@@ -0,0 +1,8 @@
|
||||
# LVGL for frame buffer device
|
||||
|
||||
LVGL configured to work with /dev/fb0 on Linux.
|
||||
|
||||
When cloning this repository, also make sure to download submodules (`git submodule update --init --recursive`) otherwise you will be missing key components.
|
||||
|
||||
Check out this blog post for a step by step tutorial:
|
||||
https://blog.lvgl.io/2018-01-03/linux_fb
|
||||
16
lib/resource/3588s/src/S50-lv_demo
Executable file
16
lib/resource/3588s/src/S50-lv_demo
Executable file
@@ -0,0 +1,16 @@
|
||||
#! /bin/sh
|
||||
|
||||
start() {
|
||||
demo &
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
start
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start}"
|
||||
exit 1
|
||||
esac
|
||||
|
||||
exit $?
|
||||
BIN
lib/resource/3588s/src/build/bin/demo
Executable file
BIN
lib/resource/3588s/src/build/bin/demo
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/logo.o
Executable file
BIN
lib/resource/3588s/src/build/obj/logo.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/GC9A01.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/GC9A01.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/ILI9341.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/ILI9341.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/R61581.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/R61581.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/SHARP_MIP.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/SHARP_MIP.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/SSD1963.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/SSD1963.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/ST7565.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/ST7565.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/UC1610.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/UC1610.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/drm.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/drm.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/fbdev.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/display/fbdev.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/gtkdrv/gtkdrv.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/gtkdrv/gtkdrv.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/AD_touch.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/AD_touch.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/FT5406EE8.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/FT5406EE8.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/XPT2046.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/XPT2046.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/evdev.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/evdev.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/libinput.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/libinput.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/xkb.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/indev/xkb.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/sdl/sdl.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/sdl/sdl.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/sdl/sdl_gpu.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/sdl/sdl_gpu.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/wayland/wayland.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/wayland/wayland.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/win32drv/win32drv.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/win32drv/win32drv.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lv_drivers/win_drv.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lv_drivers/win_drv.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_disp.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_disp.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_event.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_event.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_group.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_group.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_indev.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_indev.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_indev_scroll.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_indev_scroll.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_class.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_class.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_draw.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_draw.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_pos.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_pos.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_scroll.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_scroll.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_style.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_style.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_style_gen.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_style_gen.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_tree.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_obj_tree.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_refr.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_refr.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_theme.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/core/lv_theme.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_arc.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_arc.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_img.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_img.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_label.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_label.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_line.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_line.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_mask.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_mask.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_rect.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_rect.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_triangle.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_draw_triangle.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_img_buf.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_img_buf.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_img_cache.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_img_cache.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_img_decoder.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/lv_img_decoder.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/nxp_pxp/lv_gpu_nxp_pxp.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/nxp_pxp/lv_gpu_nxp_pxp.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/nxp_pxp/lv_gpu_nxp_pxp_osa.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/nxp_pxp/lv_gpu_nxp_pxp_osa.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/nxp_vglite/lv_gpu_nxp_vglite.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/nxp_vglite/lv_gpu_nxp_vglite.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_arc.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_arc.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_bg.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_bg.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_composite.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_composite.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_img.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_img.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_label.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_label.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_line.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_line.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_mask.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_mask.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_polygon.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_polygon.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_rect.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_rect.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_stack_blur.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_stack_blur.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_texture_cache.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_texture_cache.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_utils.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sdl/lv_draw_sdl_utils.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/stm32_dma2d/lv_gpu_stm32_dma2d.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/stm32_dma2d/lv_gpu_stm32_dma2d.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_arc.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_arc.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_blend.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_blend.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_dither.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_dither.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_gradient.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_gradient.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_img.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_img.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_letter.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_letter.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_line.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_line.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_polygon.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_polygon.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_rect.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/draw/sw/lv_draw_sw_rect.o
Executable file
Binary file not shown.
BIN
lib/resource/3588s/src/build/obj/lvgl/src/extra/layouts/flex/lv_flex.o
Executable file
BIN
lib/resource/3588s/src/build/obj/lvgl/src/extra/layouts/flex/lv_flex.o
Executable file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user