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
|
// 先于其它模块:摘掉 NOTIFY_SOCKET,避免任意子进程误发 systemd notify
|
||||||
require('../lib/systemd-env');
|
require('../lib/systemd-env');
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { exec } = require('child_process');
|
||||||
const { ClawClient } = require('../lib/client');
|
const { ClawClient } = require('../lib/client');
|
||||||
|
const config = require('../lib/config');
|
||||||
const log = require('../lib/logger');
|
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();
|
const client = new ClawClient();
|
||||||
client.start();
|
client.start();
|
||||||
|
|
||||||
|
pollSmsSafe();
|
||||||
|
smsTimer = setInterval(pollSmsSafe, 15_000);
|
||||||
|
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
|
|
||||||
function shutdown(signal) {
|
function shutdown(signal) {
|
||||||
if (stopping) return;
|
if (stopping) return;
|
||||||
stopping = true;
|
stopping = true;
|
||||||
log.info('clawd', `收到 ${signal},正在停止...`);
|
log.info('clawd', `收到 ${signal},正在停止...`);
|
||||||
|
|
||||||
|
if (smsTimer) {
|
||||||
|
clearInterval(smsTimer);
|
||||||
|
smsTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
client.stop();
|
client.stop();
|
||||||
setTimeout(() => process.exit(0), 500);
|
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
|
#!/usr/bin/env bash
|
||||||
# clawd 一键安装脚本
|
# clawd installer
|
||||||
# 用法:curl -fsSL https://raw.githubusercontent.com/stswangzhiping/clawd/main/install.sh | bash
|
# Run: curl -fsSL https://git.cutos.ai/claw-daemon/clawd/raw/branch/main/install.sh | sudo bash
|
||||||
# 需要 root 权限,需要已安装 Node.js >= 18
|
# Requires root and Node.js >= 18
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -11,26 +11,26 @@ info() { echo -e "${GREEN}[clawd]${NC} $*"; }
|
|||||||
warn() { echo -e "${YELLOW}[clawd]${NC} $*"; }
|
warn() { echo -e "${YELLOW}[clawd]${NC} $*"; }
|
||||||
error() { echo -e "${RED}[clawd]${NC} $*"; exit 1; }
|
error() { echo -e "${RED}[clawd]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
# ── 检查 root ────────────────────────────────────────────────────────────────
|
# Check root
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$EUID" -ne 0 ]; then
|
||||||
error "请以 root 身份运行(sudo bash install.sh)"
|
error "Please run as root: sudo bash install.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 检查 Node.js ─────────────────────────────────────────────────────────────
|
# Check Node.js
|
||||||
if ! command -v node &>/dev/null; then
|
if ! command -v node &>/dev/null; then
|
||||||
error "未找到 Node.js,请先安装 Node.js >= 18"
|
error "Node.js not found. Please install Node.js >= 18"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
NODE_VER=$(node -e "process.stdout.write(process.versions.node)")
|
NODE_VER=$(node -e "process.stdout.write(process.versions.node)")
|
||||||
MAJOR=$(echo "$NODE_VER" | cut -d. -f1)
|
MAJOR=$(echo "$NODE_VER" | cut -d. -f1)
|
||||||
if [ "$MAJOR" -lt 18 ]; then
|
if [ "$MAJOR" -lt 18 ]; then
|
||||||
error "Node.js 版本过低(当前 $NODE_VER),需要 >= 18"
|
error "Node.js version $NODE_VER is too old. Requires >= 18"
|
||||||
fi
|
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
|
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
|
if command -v apt-get &>/dev/null; then
|
||||||
apt-get install -y -qq dnsmasq >/dev/null 2>&1
|
apt-get install -y -qq dnsmasq >/dev/null 2>&1
|
||||||
elif command -v yum &>/dev/null; then
|
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
|
elif command -v apk &>/dev/null; then
|
||||||
apk add --quiet dnsmasq >/dev/null 2>&1
|
apk add --quiet dnsmasq >/dev/null 2>&1
|
||||||
else
|
else
|
||||||
warn "无法自动安装 dnsmasq,WiFi 配网功能可能不可用"
|
warn "Cannot install dnsmasq. WiFi captive portal may not work."
|
||||||
fi
|
fi
|
||||||
# 禁止 dnsmasq 系统服务自启(clawd 自己管理)
|
# Disable system dnsmasq; clawd manages it directly
|
||||||
systemctl disable dnsmasq 2>/dev/null || true
|
systemctl disable dnsmasq 2>/dev/null || true
|
||||||
systemctl stop dnsmasq 2>/dev/null || true
|
systemctl stop dnsmasq 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
if command -v dnsmasq &>/dev/null; then
|
if command -v dnsmasq &>/dev/null; then
|
||||||
info "dnsmasq ✓"
|
info "dnsmasq OK"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 启用 NetworkManager(WiFi 配网需要)──────────────────────────────────────
|
# Configure NetworkManager for WiFi
|
||||||
if command -v nmcli &>/dev/null; then
|
if command -v nmcli &>/dev/null; then
|
||||||
if ! systemctl is-active --quiet NetworkManager 2>/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
|
systemctl enable --now NetworkManager 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
info "NetworkManager ✓"
|
info "NetworkManager OK"
|
||||||
|
|
||||||
# 预写 DNS 劫持配置(运行时 /etc 可能为只读)
|
# Write captive-portal DNS config
|
||||||
NM_DNSMASQ_DIR="/etc/NetworkManager/dnsmasq-shared.d"
|
NM_DNSMASQ_DIR="/etc/NetworkManager/dnsmasq-shared.d"
|
||||||
mkdir -p "$NM_DNSMASQ_DIR"
|
mkdir -p "$NM_DNSMASQ_DIR"
|
||||||
cat > "$NM_DNSMASQ_DIR/clawd-captive.conf" << 'DNSCONF'
|
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
|
# All DNS queries resolve to gateway to trigger captive portal
|
||||||
address=/#/10.42.0.1
|
address=/#/10.42.0.1
|
||||||
DNSCONF
|
DNSCONF
|
||||||
info "DNS 劫持配置已写入 $NM_DNSMASQ_DIR ✓"
|
info "DNS captive config written to $NM_DNSMASQ_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── WiFi rfkill 解锁(部分设备默认禁用 WiFi)────────────────────────────────
|
# Unblock WiFi via rfkill
|
||||||
for rf in /sys/class/rfkill/rfkill*; do
|
for rf in /sys/class/rfkill/rfkill*; do
|
||||||
if [ -f "$rf/type" ] && [ "$(cat "$rf/type")" = "wlan" ]; then
|
if [ -f "$rf/type" ] && [ "$(cat "$rf/type")" = "wlan" ]; then
|
||||||
if [ "$(cat "$rf/soft")" = "1" ]; then
|
if [ "$(cat "$rf/soft")" = "1" ]; then
|
||||||
info "解锁 WiFi ($(basename "$rf"))..."
|
info "Unblocking WiFi ($(basename "$rf"))..."
|
||||||
echo 0 > "$rf/soft"
|
echo 0 > "$rf/soft"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# 持久化:独立脚本 + systemd 服务,确保开机自动解锁 WiFi
|
# Install rfkill unblock script + systemd unit for persistence
|
||||||
RFKILL_SCRIPT="/usr/local/bin/clawd-unblock-wifi.sh"
|
RFKILL_SCRIPT="/usr/local/bin/clawd-unblock-wifi.sh"
|
||||||
cat > "$RFKILL_SCRIPT" << 'SCRIPT'
|
cat > "$RFKILL_SCRIPT" << 'SCRIPT'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
@@ -108,51 +108,74 @@ WantedBy=multi-user.target
|
|||||||
UNIT
|
UNIT
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable clawd-rfkill
|
systemctl enable clawd-rfkill
|
||||||
info "WiFi rfkill 解锁服务已创建 ✓"
|
info "WiFi rfkill service installed"
|
||||||
|
|
||||||
# ── 安装 ttyd(Web 终端)────────────────────────────────────────────────────
|
# Install ttyd (Web terminal)
|
||||||
info "安装 ttyd..."
|
info "Installing ttyd..."
|
||||||
if apt-get install -y ttyd >/dev/null 2>&1; then
|
if apt-get install -y ttyd >/dev/null 2>&1; then
|
||||||
info "ttyd 已安装 ✓"
|
info "ttyd installed OK"
|
||||||
else
|
else
|
||||||
warn "ttyd 安装失败,Web 终端功能将不可用"
|
warn "ttyd install failed. Web terminal will not be available."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 安装 clawd ───────────────────────────────────────────────────────────────
|
# Clone / update clawd
|
||||||
INSTALL_DIR="/opt/clawd"
|
INSTALL_DIR="/opt/clawd"
|
||||||
CONFIG_DIR="/etc/clawd"
|
CONFIG_DIR="/etc/clawd"
|
||||||
ENV_FILE="$CONFIG_DIR/env"
|
ENV_FILE="$CONFIG_DIR/env"
|
||||||
info "安装到 $INSTALL_DIR ..."
|
info "Setting up $INSTALL_DIR ..."
|
||||||
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
|
|
||||||
# 下载源码(若目录已有 package.json,视为离线/已解压部署,跳过 git/tarball;避免设备无法访问 github.com)
|
# Use git if available, fall back to tarball
|
||||||
if [ -f "package.json" ]; then
|
CUTOS_REPO="https://git.cutos.ai/claw-daemon/clawd.git"
|
||||||
info "检测到已有源码,跳过 git/tarball 下载"
|
if command -v git &>/dev/null && [ -d ".git" ]; then
|
||||||
elif command -v git &>/dev/null; then
|
CURRENT_REMOTE=$(git remote get-url origin 2>/dev/null || echo "")
|
||||||
if [ -d ".git" ]; then
|
if echo "$CURRENT_REMOTE" | grep -q "github.com"; then
|
||||||
git pull --quiet
|
info "Migrating git remote to git.cutos.ai ..."
|
||||||
else
|
git remote set-url origin "$CUTOS_REPO"
|
||||||
git clone --depth=1 https://github.com/stswangzhiping/clawd.git .
|
|
||||||
fi
|
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
|
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
|
curl -fsSL "$TARBALL_URL" | tar -xz --strip-components=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 安装依赖
|
# Install npm dependencies
|
||||||
info "安装 npm 依赖..."
|
info "Running npm install..."
|
||||||
npm install --omit=dev --silent
|
npm install --omit=dev --silent
|
||||||
|
|
||||||
# 创建可执行链接
|
# Create symlink
|
||||||
ln -sf "$INSTALL_DIR/bin/clawd.js" /usr/local/bin/clawd
|
ln -sf "$INSTALL_DIR/bin/clawd.js" /usr/local/bin/clawd
|
||||||
chmod +x "$INSTALL_DIR/bin/clawd.js"
|
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"
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
|
||||||
if [ ! -f "$CONFIG_DIR/config.json" ]; then
|
if [ ! -f "$CONFIG_DIR/config.json" ]; then
|
||||||
@@ -164,120 +187,116 @@ if [ ! -f "$CONFIG_DIR/config.json" ]; then
|
|||||||
"heartbeat_interval": 30
|
"heartbeat_interval": 30
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
info "配置文件已创建:$CONFIG_DIR/config.json ✓"
|
info "Default config written to $CONFIG_DIR/config.json"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
cat > "$ENV_FILE" <<EOF
|
cat > "$ENV_FILE" <<EOF
|
||||||
# clawd 环境变量(systemd EnvironmentFile)
|
# clawd environment (loaded by systemd EnvironmentFile)
|
||||||
# 日志级别: debug / info / warn / error
|
# Log level: debug / info / warn / error
|
||||||
CLAWD_LOG_LEVEL=info
|
CLAWD_LOG_LEVEL=info
|
||||||
# 是否写日志文件(0=仅 journald)
|
# Log to file (0 = journald only)
|
||||||
CLAWD_LOG_FILE=1
|
CLAWD_LOG_FILE=1
|
||||||
# 自定义服务器地址(留空则读 config.json)
|
# Override server URL (default from config.json)
|
||||||
# CLAWD_SERVER=wss://claw.cutos.ai/ws
|
# CLAWD_SERVER=wss://claw.cutos.ai/ws
|
||||||
# BtMonitor(bluetoothctl)默认在程序内关闭,无需在此写 CLAWD_DISABLE_BT。
|
# Enable Bluetooth monitor (bluetoothctl); disabled by default
|
||||||
# 若产品需要蓝牙指示灯,取消下一行注释:
|
|
||||||
# CLAWD_ENABLE_BT=1
|
# 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
|
# CLAWD_OPENVFD_PATH=/sys/class/leds/openvfd
|
||||||
# 数码管 vfdservice 管道(默认 /tmp/openvfd_service)
|
# vfdservice pipe path (default: /tmp/openvfd_service)
|
||||||
# CLAWD_VFD_PIPE=/tmp/openvfd_service
|
# CLAWD_VFD_PIPE=/tmp/openvfd_service
|
||||||
# 多网口/特殊板型可固定 LAN 灯监控的以太网口(默认由 clawd 自动锁定首次 carrier 口)
|
# Wired LAN interface for carrier detection
|
||||||
# CLAWD_ETH_IFACE=end0
|
# CLAWD_ETH_IFACE=end0
|
||||||
EOF
|
EOF
|
||||||
info "环境变量文件已创建:$ENV_FILE ✓"
|
info "Default env file written to $ENV_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 创建日志目录 ─────────────────────────────────────────────────────────────
|
# Create log directory
|
||||||
mkdir -p "$CONFIG_DIR/logs"
|
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)
|
NODE_BIN=$(command -v node)
|
||||||
SERVICE_FILE="/etc/systemd/system/clawd.service"
|
SERVICE_FILE="/etc/systemd/system/clawd.service"
|
||||||
|
|
||||||
cat > "$SERVICE_FILE" <<EOF
|
cat > "$SERVICE_FILE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Claw Box Daemon
|
Description=Claw Box Daemon
|
||||||
Documentation=https://github.com/stswangzhiping/clawd
|
Documentation=https://git.cutos.ai/claw-daemon/clawd
|
||||||
After=NetworkManager.service
|
After=NetworkManager.service
|
||||||
Wants=NetworkManager.service
|
Wants=NetworkManager.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
# systemd-notify 由子进程执行,默认 NotifyAccess=main 会拒收;需 all 才能喂 WatchdogSec
|
# NotifyAccess=all required for systemd-notify with WatchdogSec
|
||||||
NotifyAccess=all
|
NotifyAccess=all
|
||||||
EnvironmentFile=$ENV_FILE
|
EnvironmentFile=$ENV_FILE
|
||||||
ExecStart=$NODE_BIN $INSTALL_DIR/bin/clawd.js
|
ExecStart=$NODE_BIN $INSTALL_DIR/bin/clawd.js
|
||||||
WorkingDirectory=$INSTALL_DIR
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
|
||||||
# 重启策略
|
# Restart policy
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
# 旧版 systemd 不认 StartLimitIntervalSec,用 StartLimitInterval=(秒)
|
|
||||||
StartLimitInterval=300
|
StartLimitInterval=300
|
||||||
StartLimitBurst=10
|
StartLimitBurst=10
|
||||||
|
|
||||||
# 优雅停止(10s 内 SIGTERM,超时 SIGKILL)
|
# Allow 10s for graceful shutdown before SIGKILL
|
||||||
TimeoutStopSec=10
|
TimeoutStopSec=10
|
||||||
KillMode=mixed
|
KillMode=mixed
|
||||||
KillSignal=SIGTERM
|
KillSignal=SIGTERM
|
||||||
|
|
||||||
# 资源限制(防止失控)
|
# Resource limits
|
||||||
MemoryMax=256M
|
MemoryMax=256M
|
||||||
CPUQuota=50%
|
CPUQuota=50%
|
||||||
TasksMax=64
|
TasksMax=64
|
||||||
|
|
||||||
# 安全加固(ttyd 子进程需要 setuid sudo,不能用 NoNewPrivileges/strict)
|
# Sandbox disabled: clawd needs to write system/config files on some devices
|
||||||
ProtectSystem=full
|
|
||||||
ReadWritePaths=$CONFIG_DIR /tmp
|
|
||||||
|
|
||||||
# 日志
|
# Logging
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
SyslogIdentifier=clawd
|
SyslogIdentifier=clawd
|
||||||
|
|
||||||
# systemd Watchdog(60s 无响应视为挂死)
|
# systemd Watchdog: restart if no heartbeat within 60s
|
||||||
WatchdogSec=60
|
WatchdogSec=60
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
info "systemd 服务文件已创建 ✓"
|
info "systemd service file written"
|
||||||
|
|
||||||
# ── journald 日志限制(可选) ────────────────────────────────────────────────
|
# Configure journald retention
|
||||||
JOURNAL_CONF="/etc/systemd/journald.conf.d/clawd.conf"
|
JOURNAL_CONF="/etc/systemd/journald.conf.d/clawd.conf"
|
||||||
if [ ! -f "$JOURNAL_CONF" ]; then
|
if [ ! -f "$JOURNAL_CONF" ]; then
|
||||||
mkdir -p /etc/systemd/journald.conf.d
|
mkdir -p /etc/systemd/journald.conf.d
|
||||||
cat > "$JOURNAL_CONF" <<EOF
|
cat > "$JOURNAL_CONF" <<EOF
|
||||||
# clawd journald 限制
|
# clawd journald limits
|
||||||
[Journal]
|
[Journal]
|
||||||
SystemMaxUse=100M
|
SystemMaxUse=100M
|
||||||
MaxFileSec=7day
|
MaxFileSec=7day
|
||||||
EOF
|
EOF
|
||||||
systemctl restart systemd-journald 2>/dev/null || true
|
systemctl restart systemd-journald 2>/dev/null || true
|
||||||
info "journald 日志限制已配置 ✓"
|
info "journald config written"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 启用并启动 ──────────────────────────────────────────────────────────────
|
# Enable and start clawd
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable clawd
|
systemctl enable clawd
|
||||||
systemctl restart clawd
|
systemctl restart clawd
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
if systemctl is-active --quiet clawd; then
|
if systemctl is-active --quiet clawd; then
|
||||||
info "clawd 服务运行中 ✓"
|
info "clawd is running"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 查看日志: journalctl -u clawd -f"
|
echo " Logs: journalctl -u clawd -f"
|
||||||
echo " 查看状态: systemctl status clawd"
|
echo " Status: systemctl status clawd"
|
||||||
echo " 停止服务: systemctl stop clawd"
|
echo " Stop: systemctl stop clawd"
|
||||||
echo " 配置文件: $CONFIG_DIR/config.json"
|
echo " Config: $CONFIG_DIR/config.json"
|
||||||
echo " 环境变量: $ENV_FILE"
|
echo " Env: $ENV_FILE"
|
||||||
echo " 文件日志: $CONFIG_DIR/logs/clawd.log"
|
echo " Log dir: $CONFIG_DIR/logs/clawd.log"
|
||||||
echo ""
|
echo ""
|
||||||
else
|
else
|
||||||
warn "服务启动失败,请检查日志:"
|
warn "clawd failed to start. Check logs:"
|
||||||
echo " journalctl -u clawd -n 50 --no-pager"
|
echo " journalctl -u clawd -n 50 --no-pager"
|
||||||
fi
|
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, // 秒
|
heartbeat_interval: 30, // 秒
|
||||||
/** 云端已激活:用于启动/重连时立即点亮 alarm(pwr),不等首包 connected */
|
/** 云端已激活:用于启动/重连时立即点亮 alarm(pwr),不等首包 connected */
|
||||||
activated: false,
|
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() {
|
function load() {
|
||||||
|
let cfg;
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
return Object.assign({}, DEFAULTS, JSON.parse(raw));
|
cfg = Object.assign({}, DEFAULTS, JSON.parse(raw));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const log = require('./logger');
|
const log = require('./logger');
|
||||||
log.error('config', '读取配置失败,使用默认值:', e.message);
|
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) {
|
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 { auth_token, dashboard_local_port = 18789 } = frpConfig;
|
||||||
const ttyRemotePort = 10000 + Number(clawId);
|
const ttyRemotePort = 10000 + Number(clawId);
|
||||||
// 固定使用 WebSocket over HTTPS (443),可穿透仅开放 443 的网络环境;
|
const stcpBlock = sshSecretKey ? `
|
||||||
// 旧版 clawd 仍使用后端下发的 server:7000(TCP),两者并存互不影响。
|
[[proxies]]
|
||||||
|
name = "ssh-${clawId}-secret"
|
||||||
|
type = "stcp"
|
||||||
|
secretKey = "${sshSecretKey}"
|
||||||
|
localPort = 22
|
||||||
|
` : '';
|
||||||
const toml = `# 由 clawd 自动生成,请勿手动修改
|
const toml = `# 由 clawd 自动生成,请勿手动修改
|
||||||
serverAddr = "frp.claw.cutos.ai"
|
serverAddr = "frp.claw.cutos.ai"
|
||||||
serverPort = 443
|
serverPort = 443
|
||||||
@@ -157,7 +162,6 @@ method = "token"
|
|||||||
token = "${auth_token}"
|
token = "${auth_token}"
|
||||||
|
|
||||||
[transport]
|
[transport]
|
||||||
protocol = "websocket"
|
|
||||||
tls.enable = true
|
tls.enable = true
|
||||||
|
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
@@ -171,10 +175,10 @@ name = "tty-${clawId}"
|
|||||||
type = "tcp"
|
type = "tcp"
|
||||||
localPort = ${TTYD_PORT}
|
localPort = ${TTYD_PORT}
|
||||||
remotePort = ${ttyRemotePort}
|
remotePort = ${ttyRemotePort}
|
||||||
`;
|
${stcpBlock}`;
|
||||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
fs.writeFileSync(FRPC_CONFIG, toml, 'utf8');
|
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;
|
this._watchdog = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(clawId, frpConfig) {
|
async start(clawId, frpConfig, sshSecretKey) {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|
||||||
if (!fs.existsSync(FRPC_BIN)) {
|
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], {
|
this._watchdog = new Watchdog('frpc', FRPC_BIN, ['-c', FRPC_CONFIG], {
|
||||||
maxRestarts: 10,
|
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';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
const log = require('./logger');
|
||||||
const log = require('./logger');
|
const { isRK3566, isRK3588, readDeviceModel } = require('./led/detect');
|
||||||
const { hasLanCableCarrier } = require('./network');
|
|
||||||
|
function loadImpl() {
|
||||||
/**
|
const forced = String(process.env.CLAWD_LED_IMPL || '').trim().toLowerCase();
|
||||||
* OpenVFD 图标:/sys/class/leds/openvfd/led_on|led_off(写入图标名)。
|
const model = readDeviceModel();
|
||||||
*
|
|
||||||
* 映射(与面板丝印一致:LAN=play,WiFi=wifi+eth):
|
let name;
|
||||||
* wifi + eth 同亮/同灭 → 产品 WiFi 灯(配网 on/off/blink)
|
if (forced) {
|
||||||
* play → LAN(有线插拔,见 hasLanCableCarrier / CLAWD_ETH_IFACE)
|
name = forced;
|
||||||
* alarm → pwr(SETUP=灭 / APPS=亮)
|
} else if (isRK3588()) {
|
||||||
* BT → 无 sysfs,仅日志
|
name = 'rk3588-lvgl';
|
||||||
*
|
} else if (isRK3566()) {
|
||||||
* 数码管:vfdservice 管道(660 字节,mode=4 TITLE / mode=1 CLOCK,与板端 Demo 一致)。
|
name = 'rk3566';
|
||||||
* CLAWD_VFD_PIPE 默认 /tmp/openvfd_service;管道不存在时仅 debug,不抛错。
|
} else {
|
||||||
*
|
name = 'openvfd';
|
||||||
* CLAWD_OPENVFD_PATH 默认 /sys/class/leds/openvfd(图标灯)
|
}
|
||||||
*/
|
|
||||||
|
try {
|
||||||
const BLINK_INTERVAL_MS = 500;
|
if (name === 'rk3566' || name === '3566') {
|
||||||
const LAN_POLL_MS = 500;
|
log.info('led', `LED/VFD backend → rk3566-openvfd (${model || 'unknown model'})`);
|
||||||
|
return require('./led/rk3566-openvfd');
|
||||||
const VFD_BASE = process.env.CLAWD_OPENVFD_PATH || '/sys/class/leds/openvfd';
|
}
|
||||||
const VFD_PIPE = process.env.CLAWD_VFD_PIPE || '/tmp/openvfd_service';
|
if (name === 'rk3588-lvgl' || name === '3588' || name === 'rk3588') {
|
||||||
const VFD_BUF_SIZE = 660;
|
log.info('led', `LED/VFD backend → rk3588-lvgl (${model || 'unknown model'})`);
|
||||||
const VFD_MODE_CLOCK = 1;
|
return require('./led/rk3588-lvgl');
|
||||||
const VFD_MODE_TITLE = 4;
|
}
|
||||||
const VFD_TITLE_OFF = 20;
|
if (name === 'noop' || name === 'none' || name === 'off') {
|
||||||
|
log.info('led', `LED/VFD backend → noop (${model || 'unknown model'})`);
|
||||||
function vfdPipePresent() {
|
return require('./led/noop');
|
||||||
try {
|
}
|
||||||
return fs.existsSync(VFD_PIPE);
|
if (name !== 'openvfd' && name !== 'default') {
|
||||||
} catch (_) {
|
log.warn('led', `未知 CLAWD_LED_IMPL=${name},回退 openvfd`);
|
||||||
return false;
|
}
|
||||||
}
|
log.info('led', `LED/VFD backend → openvfd-class (${model || 'unknown model'})`);
|
||||||
}
|
return require('./led/openvfd-class');
|
||||||
|
} catch (e) {
|
||||||
/** TITLE:固定 4 字符,ASCII,供 vfdservice 解析为段码 */
|
log.warn('led', `LED/VFD backend ${name} 加载失败:${e.message},回退 noop`);
|
||||||
function vfdTitleNormalize(raw) {
|
return require('./led/noop');
|
||||||
return String(raw || '')
|
}
|
||||||
.toUpperCase()
|
}
|
||||||
.replace(/[^A-Z0-9 \-]/g, ' ')
|
|
||||||
.slice(0, 4)
|
module.exports = loadImpl();
|
||||||
.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;
|
|
||||||
|
|||||||
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_PASSWORD = '12345678';
|
||||||
const AP_IFACE = process.env.CLAWD_WIFI_IFACE || '';
|
const AP_IFACE = process.env.CLAWD_WIFI_IFACE || '';
|
||||||
const CON_NAME = 'clawd-hotspot';
|
const CON_NAME = 'clawd-hotspot';
|
||||||
|
const AP_RETRY_TOKEN_FILE = '/run/clawd-ap-retry.token';
|
||||||
|
|
||||||
/** 产品 RJ45 在 sysfs 中的默认名;等价于检测 `cat /sys/class/net/end0/carrier` */
|
/** 产品 RJ45 在 sysfs 中的默认名;等价于检测 `cat /sys/class/net/end0/carrier` */
|
||||||
const DEFAULT_ETH_IFACE = 'end0';
|
const DEFAULT_ETH_IFACE = 'end0';
|
||||||
@@ -92,28 +93,15 @@ function hasLanCableCarrier() {
|
|||||||
return hasWiredCarrier();
|
return hasWiredCarrier();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _tryPingInternet() {
|
function _tryPingDefaultInternet() {
|
||||||
try {
|
try {
|
||||||
run('ping -c 1 -W 3 8.8.8.8');
|
run('ping -c 1 -W 3 8.8.8.8');
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {}
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function _tryPingWiredInternet() {
|
||||||
* 仅经有线口 ping 公网(不依赖默认路由)。
|
|
||||||
* AP 开启时 hasInternet() 易误判;维持 WS / 网络监视时用此兜底。
|
|
||||||
*/
|
|
||||||
function hasWiredInternetProbe() {
|
|
||||||
const wired = getWiredIfaceWithCarrier();
|
const wired = getWiredIfaceWithCarrier();
|
||||||
if (!wired) return false;
|
if (!wired) return false;
|
||||||
try {
|
try {
|
||||||
@@ -124,18 +112,31 @@ function hasWiredInternetProbe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检测是否有互联网连接(nmcli 连通性 + ping 兜底)
|
* 仅经有线口 ping 公网(不依赖默认路由)。
|
||||||
|
*/
|
||||||
|
function hasWiredInternetProbe() {
|
||||||
|
return _tryPingWiredInternet();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测是否有真实互联网连接。
|
||||||
|
* 注意:NetworkManager 的 limited/local 可能只是 AP 本地网络或 captive 状态,不能当公网可用。
|
||||||
*/
|
*/
|
||||||
function hasInternet() {
|
function hasInternet() {
|
||||||
|
const wifiSta = isWifiStaConnected();
|
||||||
|
const wired = getWiredIfaceWithCarrier();
|
||||||
|
|
||||||
// 物理层快检:无 WiFi STA 且无任何有线 carrier → 立即 false(nmcli 有缓存,不可信)
|
// 物理层快检:无 WiFi STA 且无任何有线 carrier → 立即 false(nmcli 有缓存,不可信)
|
||||||
if (!isWifiStaConnected() && !hasWiredCarrier()) return false;
|
if (!wifiSta && !wired) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const out = run('nmcli networking connectivity check').trim();
|
const out = run('nmcli networking connectivity check').trim();
|
||||||
if (out === 'full' || out === 'limited') return true;
|
if (out === 'full') return true;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
return _tryPingInternet();
|
if (wifiSta) return _tryPingDefaultInternet();
|
||||||
|
if (wired) return _tryPingWiredInternet();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,19 +181,24 @@ function scanWifi() {
|
|||||||
// 等扫描完成
|
// 等扫描完成
|
||||||
sleep(2000);
|
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 seen = new Set();
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const line of out.split('\n')) {
|
for (const line of out.split('\n')) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
const parts = line.split(':');
|
const parts = _parseNmcliTerseLine(line);
|
||||||
const ssid = parts[0].trim().replace(/\\:/g, ':');
|
const ssid = (parts[0] || '').trim();
|
||||||
if (!ssid || seen.has(ssid)) continue;
|
if (!ssid || seen.has(ssid)) continue;
|
||||||
seen.add(ssid);
|
seen.add(ssid);
|
||||||
|
const freq = (parts[3] || '').trim();
|
||||||
|
const freqMhz = parseInt(freq, 10) || null;
|
||||||
results.push({
|
results.push({
|
||||||
ssid,
|
ssid,
|
||||||
signal: parseInt(parts[1], 10) || 0,
|
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);
|
results.sort((a, b) => b.signal - a.signal);
|
||||||
@@ -260,6 +266,7 @@ function nmcliAsync(args, timeoutMs = 60000) {
|
|||||||
* @returns {Promise<{ success: boolean, error?: string }>}
|
* @returns {Promise<{ success: boolean, error?: string }>}
|
||||||
*/
|
*/
|
||||||
async function connectWifi(ssid, password) {
|
async function connectWifi(ssid, password) {
|
||||||
|
cancelHotspotRadioRetry(`准备连接 WiFi: ${ssid}`);
|
||||||
const iface = getWifiIface();
|
const iface = getWifiIface();
|
||||||
log.info('network', `尝试连接 WiFi: ${ssid}(ifname=${iface})`);
|
log.info('network', `尝试连接 WiFi: ${ssid}(ifname=${iface})`);
|
||||||
try {
|
try {
|
||||||
@@ -267,14 +274,37 @@ async function connectWifi(ssid, password) {
|
|||||||
await nmcliAsync(['connection', 'delete', ssid], 15000);
|
await nmcliAsync(['connection', 'delete', ssid], 15000);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
try {
|
await _resetWifiRadioForSTA(iface);
|
||||||
await nmcliAsync(['device', 'set', iface, 'managed', 'yes'], 8000);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
const args = ['device', 'wifi', 'connect', ssid];
|
if (password) {
|
||||||
if (password) args.push('password', password);
|
// 显式创建 STA profile,并固定为 WPA2-PSK only。
|
||||||
args.push('ifname', iface);
|
// RK3588/Broadcom DHD 对 NetworkManager 默认生成的 SAE/FT/WPA-PSK-SHA256 混合参数不稳定,
|
||||||
await nmcliAsync(args, 120000);
|
// 可能表现为一直 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;
|
const deadline = Date.now() + CONNECT_WIFI_STA_WAIT_MS;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
@@ -293,11 +323,201 @@ async function connectWifi(ssid, password) {
|
|||||||
}
|
}
|
||||||
return { success: false, error: '超时:网卡未进入已连接状态' };
|
return { success: false, error: '超时:网卡未进入已连接状态' };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
try { await nmcliAsync(['connection', 'modify', ssid, 'connection.autoconnect', 'no'], 8000); } catch (_) {}
|
||||||
log.error('network', `WiFi 连接失败: ${e.message}`);
|
log.error('network', `WiFi 连接失败: ${e.message}`);
|
||||||
return { success: false, error: 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 热点
|
* 启动 WiFi AP 热点
|
||||||
*/
|
*/
|
||||||
@@ -307,26 +527,24 @@ function startAP(clawId) {
|
|||||||
|
|
||||||
log.info('network', `启动 AP 热点: ${ssid} (${iface})`);
|
log.info('network', `启动 AP 热点: ${ssid} (${iface})`);
|
||||||
|
|
||||||
// 关闭已有热点
|
// 关闭已有热点,并在重新拉起 AP 前真正 power-cycle WiFi 芯片。
|
||||||
|
// RK3588/Broadcom DHD 在 LAN 断开后切 AP 时,单纯 ip link down/up 不一定清掉固件残留状态。
|
||||||
stopAP();
|
stopAP();
|
||||||
|
_resetWifiRadioForAP(iface, '准备 AP 前重置 WiFi radio');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// nmcli 创建热点(开放网络)
|
// 显式创建并激活热点,固定为 WPA2-PSK only。
|
||||||
const cmd = [
|
// 避免 NetworkManager 自动生成 WPA-PSK-SHA256/SAE 混合配置,部分 3588 Broadcom DHD 驱动重启 AP 时会拒绝该 RSN 参数。
|
||||||
'nmcli device wifi hotspot',
|
try {
|
||||||
`ifname ${iface}`,
|
_activateHotspot(ssid, iface, 8000);
|
||||||
`con-name ${CON_NAME}`,
|
} catch (firstError) {
|
||||||
`ssid "${ssid}"`,
|
log.warn('network', `AP 启动未在短超时内完成,后台再次重置 WiFi radio 后重试;避免阻塞 watchdog: ${firstError.message}`);
|
||||||
'band bg',
|
_spawnHotspotRadioRetry(ssid, iface);
|
||||||
];
|
return { ssid, ip: AP_IP, iface, pending: true };
|
||||||
// 如果需要密码
|
|
||||||
if (AP_PASSWORD) {
|
|
||||||
cmd.push(`password "${AP_PASSWORD}"`);
|
|
||||||
}
|
}
|
||||||
run(cmd.join(' '));
|
|
||||||
|
|
||||||
// 等待 AP 启动
|
// 等待 AP 启动
|
||||||
sleep(2000);
|
sleep(1000);
|
||||||
log.info('network', `AP 已启动: ${ssid}, 网关 ${AP_IP}`);
|
log.info('network', `AP 已启动: ${ssid}, 网关 ${AP_IP}`);
|
||||||
return { ssid, ip: AP_IP, iface };
|
return { ssid, ip: AP_IP, iface };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -339,6 +557,7 @@ function startAP(clawId) {
|
|||||||
* 关闭热点,恢复普通 WiFi 模式
|
* 关闭热点,恢复普通 WiFi 模式
|
||||||
*/
|
*/
|
||||||
function stopAP() {
|
function stopAP() {
|
||||||
|
cancelHotspotRadioRetry('停止 AP');
|
||||||
try {
|
try {
|
||||||
run(`nmcli connection down ${CON_NAME}`);
|
run(`nmcli connection down ${CON_NAME}`);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -361,20 +580,121 @@ function sleep(ms) {
|
|||||||
execSync(`sleep ${ms / 1000}`, { timeout: ms + 2000 });
|
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 连接(排除自身热点)
|
* 检测是否有已保存的 WiFi STA 连接(排除自身热点)
|
||||||
*/
|
*/
|
||||||
function hasSavedWifiConnection() {
|
function hasSavedWifiConnection() {
|
||||||
|
return listSavedWifiConnections().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWifiActiveConnectionName() {
|
||||||
|
const iface = getWifiIface();
|
||||||
try {
|
try {
|
||||||
const out = run('nmcli -t -f NAME,TYPE connection show');
|
const conn = nmcliSync(['-g', 'GENERAL.CONNECTION', 'device', 'show', iface], 8000).trim();
|
||||||
for (const line of out.split('\n')) {
|
return conn && conn !== '--' ? conn : null;
|
||||||
const [name, type] = line.split(':');
|
} catch (_) {
|
||||||
if (type === '802-11-wireless' && name !== CON_NAME) {
|
return null;
|
||||||
return true;
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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 (_) {}
|
} 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;
|
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 地址,逗号拼接返回
|
* 获取本机所有非回环 IPv4 地址,逗号拼接返回。
|
||||||
* 例:'192.168.1.100' 或 '192.168.1.100,10.0.0.5'
|
* 保持旧协议字段 local_ip 兼容:'192.168.1.100' 或 '192.168.1.100,10.0.0.5'。
|
||||||
*/
|
*/
|
||||||
function getLocalIps() {
|
function getLocalIps() {
|
||||||
try {
|
try {
|
||||||
const ifaces = os.networkInterfaces();
|
const ips = _localNetworkEntries().map((entry) => entry.ip);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ips.length > 0 ? ips.join(',') : null;
|
return ips.length > 0 ? ips.join(',') : null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn('network', '获取本机 IP 失败:', e.message);
|
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 = {
|
module.exports = {
|
||||||
hasInternet,
|
hasInternet,
|
||||||
hasWiredCarrier,
|
hasWiredCarrier,
|
||||||
hasLanCableCarrier,
|
hasLanCableCarrier,
|
||||||
hasWiredInternetProbe,
|
hasWiredInternetProbe,
|
||||||
getWiredIfaceWithCarrier,
|
getWiredIfaceWithCarrier,
|
||||||
|
listSavedWifiConnections,
|
||||||
hasSavedWifiConnection,
|
hasSavedWifiConnection,
|
||||||
|
connectSavedWifiConnections,
|
||||||
isWifiStaConnected,
|
isWifiStaConnected,
|
||||||
getWifiIface,
|
getWifiIface,
|
||||||
scanWifi,
|
scanWifi,
|
||||||
connectWifi,
|
connectWifi,
|
||||||
startAP,
|
startAP,
|
||||||
stopAP,
|
stopAP,
|
||||||
|
cancelHotspotRadioRetry,
|
||||||
AP_IP,
|
AP_IP,
|
||||||
getLocalIps,
|
getLocalIps,
|
||||||
|
getLocalNetworks,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { exec } = require('child_process');
|
||||||
const log = require('./logger');
|
const log = require('./logger');
|
||||||
const { resolveOpenclawConfigFile } = require('./frpc');
|
const { resolveOpenclawConfigFile } = require('./frpc');
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ function fetchModels(baseUrl, apiKey, callback) {
|
|||||||
try {
|
try {
|
||||||
const json = JSON.parse(data);
|
const json = JSON.parse(data);
|
||||||
if (json.data && Array.isArray(json.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) {
|
} else if (json.error) {
|
||||||
callback(new Error(json.error.message || JSON.stringify(json.error)));
|
callback(new Error(json.error.message || JSON.stringify(json.error)));
|
||||||
} else {
|
} else {
|
||||||
@@ -82,6 +84,23 @@ function writeJsonFile(filePath, obj) {
|
|||||||
fs.writeFileSync(filePath, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
|
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(解绑)。
|
* 同步:从 openclaw.json 删除指定 provider(解绑)。
|
||||||
* 若 primary 指向该 provider,先置为空串。
|
* 若 primary 指向该 provider,先置为空串。
|
||||||
@@ -125,6 +144,7 @@ function removeProviderByName(providerId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeJsonFile(configFile, config);
|
writeJsonFile(configFile, config);
|
||||||
|
restartGateway();
|
||||||
log.info('openclaw-provider', `provider 已移除: ${providerId}`);
|
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) {
|
function addProviderSync(configFile, providerId, baseUrl, apiKey, models, defaultModelRaw) {
|
||||||
const config = readJsonFile(configFile);
|
const config = readJsonFile(configFile);
|
||||||
|
|
||||||
@@ -186,7 +268,10 @@ function addProviderSync(configFile, providerId, baseUrl, apiKey, models, defaul
|
|||||||
mode: 'api_key',
|
mode: 'api_key',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ensureWebSearchConfig(configFile, config);
|
||||||
|
|
||||||
writeJsonFile(configFile, config);
|
writeJsonFile(configFile, config);
|
||||||
|
restartGateway();
|
||||||
log.info('openclaw-provider', `provider 已写入: ${providerId}(${models.length} 个模型)`);
|
log.info('openclaw-provider', `provider 已写入: ${providerId}(${models.length} 个模型)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +308,28 @@ function applyFullProviderFromVps(provider, onDone) {
|
|||||||
if (err) {
|
if (err) {
|
||||||
log.warn('openclaw-provider', `拉模型失败,使用空列表: ${err.message}`);
|
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);
|
addProviderSync(configFile, name, baseUrl, apiKey, list, defaultModel);
|
||||||
if (typeof onDone === 'function') {
|
if (typeof onDone === 'function') {
|
||||||
try {
|
try {
|
||||||
@@ -246,9 +353,104 @@ function isFullProvider(p) {
|
|||||||
|| Object.prototype.hasOwnProperty.call(p, 'baseUrl');
|
|| 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 = {
|
module.exports = {
|
||||||
applyFullProviderFromVps,
|
applyFullProviderFromVps,
|
||||||
removeProviderByName,
|
removeProviderByName,
|
||||||
|
refreshModelsIfChanged,
|
||||||
isFullProvider,
|
isFullProvider,
|
||||||
DEFAULT_BASE_URL,
|
DEFAULT_BASE_URL,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,261 +1,362 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const log = require('./logger');
|
const log = require('./logger');
|
||||||
const { hasInternet, hasSavedWifiConnection, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, getWifiIface, AP_IP } = require('./network');
|
const { hasInternet, hasWiredInternetProbe, hasSavedWifiConnection, connectSavedWifiConnections, isWifiStaConnected, scanWifi, startAP, stopAP, connectWifi, getWifiIface, AP_IP } = require('./network');
|
||||||
const { DnsHijack } = require('./dns-hijack');
|
const { DnsHijack } = require('./dns-hijack');
|
||||||
const { CaptiveServer } = require('./captive-server');
|
const { CaptiveServer } = require('./captive-server');
|
||||||
const led = require('./led');
|
const led = require('./led');
|
||||||
|
|
||||||
const MONITOR_INTERVAL_MS = 15_000;
|
const MONITOR_INTERVAL_MS = 15_000;
|
||||||
const BOOT_WAIT_MAX_MS = 20_000; // 等待 NM 自动连接的最大时间
|
const WIFI_RECONNECT_MAX_ROUNDS = 3;
|
||||||
const BOOT_POLL_MS = 2_000; // 轮询间隔
|
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 常驻配网管理器。
|
|
||||||
*
|
/**
|
||||||
* 规则:
|
* AP 常驻配网管理器。
|
||||||
* - 启动时:有已保存 WiFi 配置 → 等 NM 自动连接(最多 20 秒)
|
*
|
||||||
* - wlan0 没有以 STA 模式连接 WiFi → 开 AP + DNS 劫持 + HTTP 配网页
|
* 规则:
|
||||||
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则重新开 AP
|
* - 启动时:WiFi STA 优先;有已保存 WiFi 时主动让 NM 重连,最多 3 轮
|
||||||
* - 运行中 WiFi 断开 → 自动重新开 AP
|
* - 有线网络可用时:通知网络就绪,但不自动开启 AP
|
||||||
* - WiFi 已连接 → AP 关闭
|
* - 自动开 AP 的唯一兜底:无有线/无 WiFi,且无 saved WiFi 或 saved WiFi 3 轮失败
|
||||||
*/
|
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则按网络状态决定是否重新开 AP
|
||||||
class ProvisionManager extends EventEmitter {
|
* - AP 状态下:若仍无有线网络,低频释放 wlan0 并尝试 saved WiFi
|
||||||
constructor(clawId) {
|
*/
|
||||||
super();
|
class ProvisionManager extends EventEmitter {
|
||||||
this._clawId = clawId || 'Setup';
|
constructor(clawId) {
|
||||||
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta'
|
super();
|
||||||
this._dns = null;
|
this._clawId = clawId || 'Setup';
|
||||||
this._server = null;
|
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta' | 'wired'
|
||||||
this._monitorTimer = null;
|
this._dns = null;
|
||||||
}
|
this._server = null;
|
||||||
|
this._monitorTimer = null;
|
||||||
/** 是否正处于 AP 模式(WiFi 热点广播中) */
|
this._monitorBusy = false;
|
||||||
isApMode() { return this._state === 'ap'; }
|
this._apStartedAt = 0;
|
||||||
|
this._lastApSavedWifiRetryAt = 0;
|
||||||
async start() {
|
}
|
||||||
led.off(); // WiFi 灯初始状态:熄灭
|
|
||||||
|
/** 是否正处于 AP 模式(WiFi 热点广播中) */
|
||||||
// WiFi STA 已连接 → 直接进入 STA 模式
|
isApMode() { return this._state === 'ap'; }
|
||||||
if (isWifiStaConnected()) {
|
|
||||||
this._state = 'sta';
|
async start() {
|
||||||
log.info('provision', 'WiFi STA 已连接,AP 不启动');
|
led.off(); // WiFi 灯初始状态:熄灭
|
||||||
this._emitNetworkReady();
|
|
||||||
this._startMonitor();
|
// WiFi STA 已连接 → 直接进入 STA 模式
|
||||||
return;
|
if (isWifiStaConnected()) {
|
||||||
}
|
this._state = 'sta';
|
||||||
|
log.info('provision', 'WiFi STA 已连接,AP 不启动');
|
||||||
// 有线有网 → 立即通知 WS 连接,后台异步设置 AP 供 WiFi 配网
|
this._emitNetworkReady();
|
||||||
if (hasInternet()) {
|
this._startMonitor();
|
||||||
log.info('provision', '有线网络就绪,立即启动 WS,AP 后台准备中...');
|
return;
|
||||||
this._emitNetworkReady();
|
}
|
||||||
setTimeout(() => {
|
|
||||||
this._enterAP();
|
// 网络已就绪时先启动 WS;hasInternet() 可能来自 WiFi,也可能来自有线,不能直接当作 wired。
|
||||||
this._startMonitor();
|
if (hasInternet()) {
|
||||||
}, 0);
|
if (isWifiStaConnected()) {
|
||||||
return;
|
this._state = 'sta';
|
||||||
}
|
log.info('provision', 'WiFi STA 已连接,AP 不启动');
|
||||||
|
this._emitNetworkReady();
|
||||||
// 无网:有已保存的 WiFi 配置 → 等 NM 自动连接(重启场景)
|
} else {
|
||||||
if (hasSavedWifiConnection()) {
|
this._state = 'wired';
|
||||||
log.info('provision', '发现已保存的 WiFi 配置,等待 NetworkManager 自动连接...');
|
log.info('provision', '有线网络就绪,启动 WS;不自动开启 AP');
|
||||||
led.blink(); // WiFi 灯:等待自动重连期间闪烁
|
led.off();
|
||||||
const connected = await this._waitForWifiConnect();
|
this._emitNetworkReady();
|
||||||
if (connected) {
|
}
|
||||||
this._state = 'sta';
|
this._startMonitor();
|
||||||
log.info('provision', 'WiFi 自动连接成功,AP 不启动');
|
return;
|
||||||
this._emitNetworkReady();
|
}
|
||||||
this._startMonitor();
|
|
||||||
return;
|
// 无有线可用时,有 saved WiFi 才主动让 NetworkManager 重连;不要只被动等待 NM autoconnect。
|
||||||
}
|
if (hasSavedWifiConnection()) {
|
||||||
log.warn('provision', 'WiFi 自动连接超时,启动 AP');
|
log.info('provision', `发现已保存的 WiFi 配置,主动重连(最多 ${WIFI_RECONNECT_MAX_ROUNDS} 轮)...`);
|
||||||
}
|
this._state = 'connecting';
|
||||||
|
led.blink();
|
||||||
// 没有已保存 WiFi 或等待超时 → 开 AP
|
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
|
||||||
this._enterAP();
|
if (connected) {
|
||||||
this._startMonitor();
|
this._state = 'sta';
|
||||||
|
log.info('provision', '已保存 WiFi 重连成功,AP 不启动');
|
||||||
if (hasInternet()) {
|
this._emitNetworkReady();
|
||||||
this._emitNetworkReady();
|
this._startMonitor();
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
|
log.warn('provision', '已保存 WiFi 重连失败');
|
||||||
_emitNetworkReady() {
|
}
|
||||||
if (hasInternet()) {
|
|
||||||
// WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮)
|
// 无有线、无 WiFi;且无 saved WiFi 或 saved WiFi 3 轮失败 → 开 AP 兜底配网。
|
||||||
if (this._state === 'sta') led.on();
|
this._enterAP();
|
||||||
this.emit('network-ready');
|
this._startMonitor();
|
||||||
} else {
|
}
|
||||||
log.warn('provision', 'hasInternet() 返回 false,LED 保持熄灭');
|
|
||||||
}
|
_emitNetworkReady() {
|
||||||
}
|
if (hasInternet()) {
|
||||||
|
// WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮)
|
||||||
/**
|
if (this._state === 'sta') led.on();
|
||||||
* 轮询等待 NM 自动连接 WiFi,最多等 BOOT_WAIT_MAX_MS
|
this.emit('network-ready');
|
||||||
*/
|
} else {
|
||||||
_waitForWifiConnect() {
|
log.warn('provision', 'hasInternet() 返回 false,LED 保持熄灭');
|
||||||
return new Promise(resolve => {
|
}
|
||||||
let elapsed = 0;
|
}
|
||||||
const timer = setInterval(() => {
|
|
||||||
elapsed += BOOT_POLL_MS;
|
async _trySavedWifiReconnectRounds(rounds = WIFI_RECONNECT_MAX_ROUNDS) {
|
||||||
if (isWifiStaConnected()) {
|
for (let i = 1; i <= rounds; i++) {
|
||||||
clearInterval(timer);
|
if (isWifiStaConnected()) return true;
|
||||||
resolve(true);
|
log.info('provision', `尝试已保存 WiFi 重连:第 ${i}/${rounds} 轮`);
|
||||||
} else if (elapsed >= BOOT_WAIT_MAX_MS) {
|
const result = await connectSavedWifiConnections();
|
||||||
clearInterval(timer);
|
if (result.success || isWifiStaConnected()) return true;
|
||||||
resolve(false);
|
if (i < rounds) {
|
||||||
}
|
await new Promise((r) => setTimeout(r, WIFI_RECONNECT_ROUND_DELAY_MS));
|
||||||
}, BOOT_POLL_MS);
|
}
|
||||||
});
|
}
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
stop() {
|
|
||||||
this._stopMonitor();
|
stop() {
|
||||||
this._stopAll();
|
this._stopMonitor();
|
||||||
this._state = 'idle';
|
this._stopAll();
|
||||||
led.destroy(); // WiFi 灯:停止时关灯、释放闪烁定时器
|
this._state = 'idle';
|
||||||
}
|
led.destroy(); // WiFi 灯:停止时关灯、释放闪烁定时器
|
||||||
|
}
|
||||||
// ── 进入 AP 模式 ─────────────────────────────────────────────────────────
|
|
||||||
|
// ── 进入 AP 模式 ─────────────────────────────────────────────────────────
|
||||||
_enterAP() {
|
|
||||||
if (this._state === 'ap') return;
|
_enterAP() {
|
||||||
|
if (this._state === 'ap') return;
|
||||||
led.off(); // AP 模式:WiFi 未连接,WiFi 灯熄灭
|
|
||||||
if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP,有线时等 WS 连接后再定
|
led.off(); // AP 模式:WiFi 未连接,WiFi 灯熄灭
|
||||||
|
if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP,有线时等 WS 连接后再定
|
||||||
try {
|
|
||||||
// AP 模式下无法扫描 WiFi,必须在开 AP 之前扫描并缓存
|
try {
|
||||||
log.info('provision', '扫描周边 WiFi...');
|
// 若上次进程退出前留下 clawd-hotspot,必须先释放 wlan0;否则会在 AP 模式下扫描,列表可能只剩 2.4G/自身热点。
|
||||||
this._cachedWifiList = scanWifi();
|
stopAP();
|
||||||
log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络`);
|
|
||||||
|
// AP 模式下无法扫描 WiFi,必须在开 AP 之前扫描并缓存
|
||||||
// 写 DNS 劫持配置(NM 启动热点时加载);接口名与热点一致,勿写死 wlan0
|
log.info('provision', '扫描周边 WiFi...');
|
||||||
this._dns = new DnsHijack();
|
this._cachedWifiList = scanWifi();
|
||||||
this._dns.start(getWifiIface(), AP_IP);
|
log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络: ${this._cachedWifiList.map(w => `${w.ssid}${w.band ? `(${w.band})` : ''}`).join(', ')}`);
|
||||||
|
|
||||||
const ap = startAP(this._clawId);
|
// 写 DNS 劫持配置(NM 启动热点时加载);接口名与热点一致,勿写死 wlan0
|
||||||
|
this._dns = new DnsHijack();
|
||||||
this._server = new CaptiveServer({
|
this._dns.start(getWifiIface(), AP_IP);
|
||||||
clawId: this._clawId,
|
|
||||||
cachedWifiList: this._cachedWifiList,
|
const ap = startAP(this._clawId);
|
||||||
onConnect: (ssid, password) => this._handleWifiConnect(ssid, password),
|
|
||||||
});
|
this._server = new CaptiveServer({
|
||||||
this._server.startListening();
|
clawId: this._clawId,
|
||||||
|
cachedWifiList: this._cachedWifiList,
|
||||||
this._state = 'ap';
|
onConnect: (ssid, password) => this._handleWifiConnect(ssid, password),
|
||||||
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
|
});
|
||||||
log.info('provision', `配网地址: http://10.42.0.1`);
|
this._server.startListening();
|
||||||
} catch (e) {
|
|
||||||
log.error('provision', `AP 启动失败: ${e.message}`);
|
this._state = 'ap';
|
||||||
if (this._state !== 'sta') this._state = 'idle';
|
this._apStartedAt = Date.now();
|
||||||
}
|
this._lastApSavedWifiRetryAt = 0;
|
||||||
}
|
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
|
||||||
|
log.info('provision', `配网地址: http://10.42.0.1`);
|
||||||
// ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
|
} catch (e) {
|
||||||
|
log.error('provision', `AP 启动失败: ${e.message}`);
|
||||||
async _handleWifiConnect(ssid, password) {
|
if (this._state !== 'sta') this._state = 'idle';
|
||||||
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
|
}
|
||||||
|
}
|
||||||
this._state = 'connecting';
|
|
||||||
log.info('provision', `用户请求连接 WiFi: ${ssid}`);
|
// ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
|
||||||
led.blink(); // 正在连接 → 闪烁
|
|
||||||
|
async _handleWifiConnect(ssid, password) {
|
||||||
try {
|
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
|
||||||
this._stopAPServices();
|
|
||||||
|
this._state = 'connecting';
|
||||||
// 关热点后射频/模式切换需要时间,立刻 connect 在部分板子上会失败
|
log.info('provision', `用户请求连接 WiFi: ${ssid}`);
|
||||||
await new Promise((r) => setTimeout(r, 3500));
|
led.blink(); // 正在连接 → 闪烁
|
||||||
|
|
||||||
const result = await connectWifi(ssid, password);
|
try {
|
||||||
|
this._stopAPServices();
|
||||||
if (result.success) {
|
|
||||||
this._state = 'sta';
|
// 关热点后射频/模式切换需要时间,立刻 connect 在部分板子上会失败
|
||||||
log.info('provision', `WiFi 已连接: ${ssid}`);
|
await new Promise((r) => setTimeout(r, 3500));
|
||||||
led.on(); // WiFi 灯:连接成功 → 常亮
|
|
||||||
this.emit('network-ready');
|
const result = await connectWifi(ssid, password);
|
||||||
return result;
|
|
||||||
}
|
if (result.success) {
|
||||||
|
this._state = 'sta';
|
||||||
log.warn('provision', `WiFi 连接失败: ${result.error},重新启动 AP`);
|
log.info('provision', `WiFi 已连接: ${ssid}`);
|
||||||
this._safeReenterAP();
|
led.on(); // WiFi 灯:连接成功 → 常亮
|
||||||
return result;
|
this.emit('network-ready');
|
||||||
} catch (e) {
|
return result;
|
||||||
log.error('provision', `配网过程异常: ${e.message}`);
|
}
|
||||||
this._safeReenterAP();
|
|
||||||
return { success: false, error: e.message };
|
log.warn('provision', `WiFi 连接失败: ${result.error},按当前网络状态恢复`);
|
||||||
}
|
this._recoverAfterWifiFailure();
|
||||||
}
|
return result;
|
||||||
|
} catch (e) {
|
||||||
/** 重新开 AP;失败时勿把 _state 永久卡在 connecting */
|
log.error('provision', `配网过程异常: ${e.message}`);
|
||||||
_safeReenterAP() {
|
this._recoverAfterWifiFailure();
|
||||||
try {
|
return { success: false, error: e.message };
|
||||||
this._enterAP();
|
}
|
||||||
} catch (e) {
|
}
|
||||||
log.error('provision', `重新启动 AP 失败: ${e.message}`);
|
|
||||||
this._state = 'idle';
|
/** WiFi 连接失败后:有线可用则保持 wired;否则开 AP 兜底。 */
|
||||||
}
|
_recoverAfterWifiFailure() {
|
||||||
}
|
if (hasInternet()) {
|
||||||
|
this._state = 'wired';
|
||||||
// ── WiFi 状态监控 ─────────────────────────────────────────────────────────
|
led.off();
|
||||||
|
this._emitNetworkReady();
|
||||||
_startMonitor() {
|
return;
|
||||||
this._monitorTimer = setInterval(() => {
|
}
|
||||||
if (this._state === 'connecting') return;
|
this._safeReenterAP();
|
||||||
|
}
|
||||||
const wifiUp = isWifiStaConnected();
|
|
||||||
|
/** 重新开 AP;失败时勿把 _state 永久卡在 connecting */
|
||||||
if (this._state === 'sta' && !wifiUp) {
|
_safeReenterAP() {
|
||||||
log.warn('provision', 'WiFi 连接已断开,重新启动 AP');
|
try {
|
||||||
this._enterAP(); // 内部调用 led.off()
|
this._enterAP();
|
||||||
return;
|
} catch (e) {
|
||||||
}
|
log.error('provision', `重新启动 AP 失败: ${e.message}`);
|
||||||
|
this._state = 'idle';
|
||||||
if (this._state === 'ap' && wifiUp) {
|
}
|
||||||
log.info('provision', 'WiFi 已外部连接,关闭 AP');
|
}
|
||||||
this._stopAPServices();
|
|
||||||
this._state = 'sta';
|
// ── WiFi 状态监控 ─────────────────────────────────────────────────────────
|
||||||
this.emit('network-ready');
|
|
||||||
}
|
_startMonitor() {
|
||||||
|
this._monitorTimer = setInterval(() => {
|
||||||
// 产品 WiFi 灯(OpenVFD wifi+eth):AP 全程强制熄灭,避免与其它逻辑竞态导致误亮
|
if (this._monitorBusy) return;
|
||||||
if (this._state === 'ap') {
|
this._monitorBusy = true;
|
||||||
led.off();
|
this._monitorTick()
|
||||||
} else if (this._state === 'sta') {
|
.catch((e) => log.error('provision', `WiFi 状态监控异常: ${e.message}`))
|
||||||
if (hasInternet()) {
|
.finally(() => { this._monitorBusy = false; });
|
||||||
led.on();
|
}, MONITOR_INTERVAL_MS);
|
||||||
} else {
|
}
|
||||||
led.off(); // STA 已连热点但无互联网
|
|
||||||
}
|
async _monitorTick() {
|
||||||
}
|
if (this._state === 'connecting') return;
|
||||||
}, MONITOR_INTERVAL_MS);
|
|
||||||
}
|
const wifiUp = isWifiStaConnected();
|
||||||
|
|
||||||
_stopMonitor() {
|
if (wifiUp && this._state !== 'sta') {
|
||||||
if (this._monitorTimer) {
|
if (this._state === 'ap') {
|
||||||
clearInterval(this._monitorTimer);
|
log.info('provision', 'WiFi 已外部连接,关闭 AP');
|
||||||
this._monitorTimer = null;
|
this._stopAPServices();
|
||||||
}
|
}
|
||||||
}
|
this._state = 'sta';
|
||||||
|
this._emitNetworkReady();
|
||||||
// ── 清理 ──────────────────────────────────────────────────────────────────
|
}
|
||||||
|
|
||||||
_stopAPServices() {
|
if (this._state === 'sta' && !wifiUp) {
|
||||||
if (this._server) {
|
log.warn('provision', 'WiFi 连接已断开,尝试恢复网络');
|
||||||
this._server.stop();
|
await this._recoverNetworkWithoutWifi();
|
||||||
this._server = null;
|
return;
|
||||||
}
|
}
|
||||||
if (this._dns) {
|
|
||||||
this._dns.stop();
|
if (this._state === 'wired') {
|
||||||
this._dns = null;
|
if (!hasInternet()) {
|
||||||
}
|
log.warn('provision', '有线网络不可用,尝试恢复 WiFi');
|
||||||
stopAP();
|
await this._recoverNetworkWithoutWifi();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
_stopAll() {
|
led.off();
|
||||||
this._stopAPServices();
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (this._state === 'ap') {
|
||||||
module.exports = { ProvisionManager };
|
// 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