Compare commits

..

69 Commits

Author SHA1 Message Date
c4b9d03e14 Disable clawd sandbox in install script 2026-05-27 18:22:41 +08:00
afbf4cad61 Bump version to 1.5.0 2026-05-27 17:22:54 +08:00
e8218a2ab5 Fix weixin state path for sts 2026-05-27 17:21:36 +08:00
d6bc57dbab Bump version to 1.4.9 2026-05-27 15:33:47 +08:00
464160c1f7 Bump version 2026-05-27 15:29:20 +08:00
efca4a6b7a Update 3588s demo and bump version 2026-05-25 19:50:57 +08:00
000301355f Show VFD time on startup 2026-05-25 11:11:00 +08:00
a85732aa80 fix: stabilize rk3588 wifi provisioning 2026-05-24 20:37:21 +08:00
306243eb6a fix: align provisioning logic with base devices 2026-05-24 10:07:55 +08:00
161e0e654c feat(rk3588s): unify display state semantics and bump version to 1.4.5 2026-05-23 18:22:13 +08:00
5347a728da feat(rk3588s): blink AP display and package source tree 2026-05-23 17:44:47 +08:00
9eddc702b6 feat(led): enable rk3588 lvgl backend and bump version to 1.4.3 2026-05-23 17:04:28 +08:00
2d2bd69780 feat(rk3588s): package lvgl demo and display backend 2026-05-23 17:01:44 +08:00
48f64a6858 feat(led): add rk3588 lvgl display backend 2026-05-23 16:01:31 +08:00
stswangzhiping
7e1f0bef36 chore: bump version to 1.4.2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 18:17:42 +08:00
stswangzhiping
d91a309419 docs: clarify url vs code fields in qrcode event
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 18:02:51 +08:00
stswangzhiping
6da91c7d26 fix: correct binded_redirect error message - suggest alternate WeChat account
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 17:40:39 +08:00
stswangzhiping
f52ad363a2 fix: correct binded_redirect error message for personal WeChat unbind guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 17:30:05 +08:00
stswangzhiping
796c8d3431 fix: handle binded_redirect state inconsistency in WeChat login
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 17:27:57 +08:00
stswangzhiping
06036c6c73 feat: emit raw qrcode code field for client-side QR rendering
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 15:08:56 +08:00
stswangzhiping
eeb984ebfe refactor: remove verify-code/reply support (not needed)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 22:20:10 +08:00
stswangzhiping
80e1c97000 feat: weixin login impl + sys-call reply support (v1.4.0)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 22:13:58 +08:00
stswangzhiping
3dba9fde32 feat: sys-call framework + channel.weixin stub (v1.4.0)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 21:47:27 +08:00
stswangzhiping
8cebf062a2 chore: bump version to 1.3.9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:07:00 +08:00
stswangzhiping
c9597cf1a0 fix: rewrite install.sh in ASCII English to fix garbled characters
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:06:17 +08:00
stswangzhiping
cdf2a5f5ac feat: skip VFD on RK3588 devices (v1.3.8)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 22:38:18 +08:00
stswangzhiping
000dc4a46c revert: remove ineffective showTime at startup, back to v1.3.7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 10:49:15 +08:00
stswangzhiping
be49f32b50 feat: show time on VFD immediately at startup (v1.3.8)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 08:50:53 +08:00
stswangzhiping
6c1c0cf955 feat: restart openclaw-gateway after openclaw.json write (v1.3.7)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 22:11:46 +08:00
stswangzhiping
d89c2340da fix: apply web search config on provider-unchanged early return; bump to 1.3.6
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:47:49 +08:00
stswangzhiping
7e44744c31 fix: apply web search config even when no provider configured; bump to 1.3.5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 21:40:15 +08:00
stswangzhiping
684e9728dd feat: write searxng config via .env + openclaw.json on activation/reconnect; bump to 1.3.4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 20:49:04 +08:00
stswangzhiping
f61a0a4305 feat: ensure searxng config on reconnect if missing/changed; bump to 1.3.3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 18:46:17 +08:00
stswangzhiping
4d13fdec8c feat: write searxng web search config on activation, remove on unbind; bump to 1.3.2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 18:26:39 +08:00
stswangzhiping
811c1be3b9 fix: remove service file rewrite from update-clawd.sh; bump to 1.3.1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 20:17:19 +08:00
stswangzhiping
c3dd87f635 fix: rewrite _setHostname using fs to avoid sed -i temp file issue; bump to 1.3.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 20:00:59 +08:00
stswangzhiping
18a949464e chore: remove startup service-file patch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 19:35:15 +08:00
stswangzhiping
f363836712 fix: git pull in Node.js before calling update-clawd.sh --no-pull; startup service fix; bump to 1.2.9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 19:25:18 +08:00
stswangzhiping
e1e3fa95cd fix: re-exec updated script after git pull so new logic runs; bump to 1.2.8
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 18:54:52 +08:00
stswangzhiping
43117a6a04 fix: auto-patch clawd.service ReadWritePaths on startup; bump to 1.2.7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 18:52:02 +08:00
stswangzhiping
45e1370ca5 fix: rewrite clawd.service on upgrade; bump to 1.2.6
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 18:46:37 +08:00
stswangzhiping
7761b438d3 fix: patch ReadWritePaths in clawd.service on upgrade if /etc/hosts missing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 18:44:35 +08:00
stswangzhiping
8990d48d51 fix: add /etc/hosts /etc/hostname to systemd ReadWritePaths; bump v1.2.5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 18:14:14 +08:00
stswangzhiping
9e67969fd1 fix: hostname - remove sudo (clawd runs as root), use semicolon to run all 3 commands independently; bump v1.2.4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 17:45:35 +08:00
stswangzhiping
6a97f68255 fix: rewrite install.sh in English to avoid Windows encoding corruption; bump v1.2.3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 17:40:26 +08:00
stswangzhiping
f1c24f75b5 fix: auto-migrate git remote from github to git.cutos.ai on install/update; bump v1.2.2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 17:36:24 +08:00
stswangzhiping
f71d448047 fix: persist hostname to /etc/hostname and /etc/hosts; bump v1.2.1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 17:29:09 +08:00
stswangzhiping
e4e99c9aed fix: change clone source from github to git.cutos.ai
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 17:24:41 +08:00
stswangzhiping
844d3c89ae chore: bump version to 1.2.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 14:20:55 +08:00
stswangzhiping
f8789876f5 feat: add share_key for Samba password, sync smbpasswd on startup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 14:17:28 +08:00
stswangzhiping
4be305d0e2 chore: remove /etc/clawd/tailscale path, bump version to 1.1.9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 11:27:18 +08:00
stswangzhiping
7ad45c0cdc chore: bump version to 1.1.8
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 09:46:51 +08:00
stswangzhiping
b4164689a6 fix: add /etc/clawd/tailscale/tailscale to bin candidates
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 09:46:05 +08:00
stswangzhiping
a6bca1349c chore: bump version to 1.1.7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 08:50:59 +08:00
stswangzhiping
29c158f837 feat: headscale mesh integration - auto-join on bind, logout on unbind
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 19:39:16 +08:00
stswangzhiping
5e82505c6a update verison 1.1.6 2026-05-02 18:36:57 +08:00
stswangzhiping
1a7e0a9738 feat: SSH STCP key generation and frp tunnel registration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 18:07:33 +08:00
stswangzhiping
7fe1ee64d8 update version 1.1.5 2026-05-01 09:51:58 +08:00
stswangzhiping
46153b5a5e read sms loop 2026-05-01 09:29:04 +08:00
stswangzhiping
44f69a99b7 chore: bump version to 1.1.4
Made-with: Cursor
2026-04-30 23:19:59 +08:00
stswangzhiping
679c1e2051 feat: add SIM SMS reader (drivers/sim/sms-reader.js)
Made-with: Cursor
2026-04-30 23:18:26 +08:00
stswangzhiping
2b8ebbc86e feat: run bind-quectel-serial.sh on every startup
Made-with: Cursor
2026-04-30 22:34:05 +08:00
stswangzhiping
9bb35220a8 version 1.1.3 2026-04-30 22:30:26 +08:00
stswangzhiping
c5b2d2d480 sim card dirver 2026-04-30 22:28:21 +08:00
stswangzhiping
e8c7d3ebc1 chore: bump version to 1.1.2
Made-with: Cursor
2026-04-30 19:30:43 +08:00
stswangzhiping
b4e0388c71 feat: report version, handle remote upgrade via update-clawd.sh
Made-with: Cursor
2026-04-30 18:18:30 +08:00
yankun
5a5c3ca4b5 Update version in package.json 2026-04-30 17:07:24 +08:00
yankun
87c219c426 update shell 2026-04-30 16:50:07 +08:00
yankun
ad4d195b52 Update version in package.json 2026-04-30 16:27:49 +08:00
1350 changed files with 380887 additions and 1170 deletions

View 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
View 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;
});
}

View File

@@ -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"
# ── 检查/安装 dnsmasqWiFi 配网需要)────────────────────────────────────── # Install dnsmasq (required for WiFi captive portal)
if ! command -v dnsmasq &>/dev/null; then if ! command -v dnsmasq &>/dev/null; then
info "安装 dnsmasqWiFi 配网所需)..." 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 "无法自动安装 dnsmasqWiFi 配网功能可能不可用" 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
# ── 启用 NetworkManagerWiFi 配网需要)────────────────────────────────────── # 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"
# ── 安装 ttydWeb 终端)──────────────────────────────────────────────────── # 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
# BtMonitorbluetoothctl)默认在程序内关闭,无需在此写 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 Watchdog60s 无响应视为挂死) # 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
View 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 };

File diff suppressed because it is too large Load Diff

View File

@@ -19,19 +19,68 @@ const DEFAULTS = {
heartbeat_interval: 30, // 秒 heartbeat_interval: 30, // 秒
/** 云端已激活:用于启动/重连时立即点亮 alarmpwr不等首包 connected */ /** 云端已激活:用于启动/重连时立即点亮 alarmpwr不等首包 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) {

View File

@@ -143,9 +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);
const stcpBlock = sshSecretKey ? `
[[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
@@ -168,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' : ''}`);
} }
/** /**
@@ -183,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)) {
@@ -195,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
View 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 };

View File

@@ -1,7 +1,7 @@
'use strict'; 'use strict';
const log = require('./logger'); const log = require('./logger');
const { isRK3566, readDeviceModel } = require('./led/detect'); const { isRK3566, isRK3588, readDeviceModel } = require('./led/detect');
function loadImpl() { function loadImpl() {
const forced = String(process.env.CLAWD_LED_IMPL || '').trim().toLowerCase(); const forced = String(process.env.CLAWD_LED_IMPL || '').trim().toLowerCase();
@@ -10,6 +10,8 @@ function loadImpl() {
let name; let name;
if (forced) { if (forced) {
name = forced; name = forced;
} else if (isRK3588()) {
name = 'rk3588-lvgl';
} else if (isRK3566()) { } else if (isRK3566()) {
name = 'rk3566'; name = 'rk3566';
} else { } else {
@@ -21,6 +23,10 @@ function loadImpl() {
log.info('led', `LED/VFD backend → rk3566-openvfd (${model || 'unknown model'})`); log.info('led', `LED/VFD backend → rk3566-openvfd (${model || 'unknown model'})`);
return require('./led/rk3566-openvfd'); return require('./led/rk3566-openvfd');
} }
if (name === 'rk3588-lvgl' || name === '3588' || name === 'rk3588') {
log.info('led', `LED/VFD backend → rk3588-lvgl (${model || 'unknown model'})`);
return require('./led/rk3588-lvgl');
}
if (name === 'noop' || name === 'none' || name === 'off') { if (name === 'noop' || name === 'none' || name === 'off') {
log.info('led', `LED/VFD backend → noop (${model || 'unknown model'})`); log.info('led', `LED/VFD backend → noop (${model || 'unknown model'})`);
return require('./led/noop'); return require('./led/noop');

View File

@@ -16,7 +16,12 @@ function isRK3566() {
return /RK3566/i.test(readDeviceModel()); return /RK3566/i.test(readDeviceModel());
} }
function isRK3588() {
return /RK3588/i.test(readDeviceModel());
}
module.exports = { module.exports = {
readDeviceModel, readDeviceModel,
isRK3566, isRK3566,
isRK3588,
}; };

81
lib/led/rk3588-lvgl.js Normal file
View 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;

View File

@@ -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 → 立即 falsenmcli 有缓存,不可信) // 物理层快检:无 WiFi STA 且无任何有线 carrier → 立即 falsenmcli 有缓存,不可信)
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;
} }
/** /**
@@ -265,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 {
@@ -272,14 +274,36 @@ 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(); await _ensureActiveWifiAutoconnect();
const deadline = Date.now() + CONNECT_WIFI_STA_WAIT_MS; const deadline = Date.now() + CONNECT_WIFI_STA_WAIT_MS;
@@ -299,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 热点
*/ */
@@ -313,29 +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',
`ifname ${iface}`,
`con-name ${CON_NAME}`,
`ssid "${ssid}"`,
'band bg',
];
// 如果需要密码
if (AP_PASSWORD) {
cmd.push(`password "${AP_PASSWORD}"`);
}
run(cmd.join(' '));
try { try {
nmcliSync(['connection', 'modify', CON_NAME, 'connection.autoconnect', 'no'], 8000); _activateHotspot(ssid, iface, 8000);
} catch (_) {} } catch (firstError) {
log.warn('network', `AP 启动未在短超时内完成,后台再次重置 WiFi radio 后重试;避免阻塞 watchdog: ${firstError.message}`);
_spawnHotspotRadioRetry(ssid, iface);
return { ssid, ip: AP_IP, iface, pending: true };
}
// 等待 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) {
@@ -348,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 (_) {}
@@ -454,6 +664,7 @@ async function _ensureActiveWifiAutoconnect() {
* clawd 只做调度真正的认证、DHCP、重连细节仍交给 NM。 * clawd 只做调度真正的认证、DHCP、重连细节仍交给 NM。
*/ */
async function connectSavedWifiConnections() { async function connectSavedWifiConnections() {
cancelHotspotRadioRetry('准备连接已保存 WiFi');
const iface = getWifiIface(); const iface = getWifiIface();
const profiles = listSavedWifiConnections(); const profiles = listSavedWifiConnections();
if (profiles.length === 0) { if (profiles.length === 0) {
@@ -574,6 +785,7 @@ module.exports = {
connectWifi, connectWifi,
startAP, startAP,
stopAP, stopAP,
cancelHotspotRadioRetry,
AP_IP, AP_IP,
getLocalIps, getLocalIps,
getLocalNetworks, getLocalNetworks,

View File

@@ -5,6 +5,7 @@ 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 crypto = require('crypto');
const { exec } = require('child_process');
const log = require('./logger'); const log = require('./logger');
const { resolveOpenclawConfigFile } = require('./frpc'); const { resolveOpenclawConfigFile } = require('./frpc');
@@ -83,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先置为空串。
@@ -126,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}`);
} }
@@ -144,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);
@@ -187,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} 个模型)`);
} }
@@ -233,7 +317,14 @@ function applyFullProviderFromVps(provider, onDone) {
const curMd5 = computeModelsMd5(cur.models || []); const curMd5 = computeModelsMd5(cur.models || []);
const newMd5 = computeModelsMd5(list); const newMd5 = computeModelsMd5(list);
if (curApiKey === apiKey && curMd5 === newMd5) { if (curApiKey === apiKey && curMd5 === newMd5) {
log.info('openclaw-provider', `provider 无变化apiKey + 模型列表相同),跳过写盘`); // 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}`); } } if (typeof onDone === 'function') { try { onDone(); } catch (e) { log.warn('openclaw-provider', `onDone: ${e.message}`); } }
return; return;
} }
@@ -298,10 +389,18 @@ function refreshModelsIfChanged(onDone) {
return; return;
} }
// provider 不存在时也要确保 web search 配置
const providers = config.models?.providers || {}; const providers = config.models?.providers || {};
const providerId = Object.keys(providers)[0]; const providerId = Object.keys(providers)[0];
if (!providerId) { if (!providerId) {
log.info('openclaw-provider', 'refreshModels: 未找到已配置的 provider跳过'); 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(); if (typeof onDone === 'function') onDone();
return; return;
} }
@@ -323,7 +422,17 @@ function refreshModelsIfChanged(onDone) {
const newMd5 = computeModelsMd5(newModels); const newMd5 = computeModelsMd5(newModels);
if (currentMd5 === newMd5) { if (currentMd5 === newMd5) {
log.info('openclaw-provider', `模型列表未变化(${newModels.length} 个),跳过更新`); // 模型未变,但仍检查 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; return;
} }

View File

@@ -1,360 +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, connectSavedWifiConnections, 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 WIFI_RECONNECT_MAX_ROUNDS = 3; const WIFI_RECONNECT_MAX_ROUNDS = 3;
const WIFI_RECONNECT_ROUND_DELAY_MS = 5_000; const WIFI_RECONNECT_ROUND_DELAY_MS = 5_000;
const AP_SAVED_WIFI_RETRY_INTERVAL_MS = 180_000; const AP_SAVED_WIFI_RETRY_INTERVAL_MS = 180_000;
const AP_MIN_UP_BEFORE_RETRY_MS = 60_000; const AP_MIN_UP_BEFORE_RETRY_MS = 60_000;
/** /**
* AP 常驻配网管理器。 * AP 常驻配网管理器。
* *
* 规则: * 规则:
* - 启动时WiFi STA 优先;有已保存 WiFi 时主动让 NM 重连,最多 3 轮 * - 启动时WiFi STA 优先;有已保存 WiFi 时主动让 NM 重连,最多 3 轮
* - 有线网络可用时:通知网络就绪,但不自动开启 AP * - 有线网络可用时:通知网络就绪,但不自动开启 AP
* - 自动开 AP 的唯一兜底:无有线/无 WiFi且无 saved WiFi 或 saved WiFi 3 轮失败 * - 自动开 AP 的唯一兜底:无有线/无 WiFi且无 saved WiFi 或 saved WiFi 3 轮失败
* - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则按网络状态决定是否重新开 AP * - 用户提交 WiFi 凭证 → 关 AP → 尝试连接 → 失败则按网络状态决定是否重新开 AP
* - AP 状态下:若仍无有线网络,低频释放 wlan0 并尝试 saved WiFi * - AP 状态下:若仍无有线网络,低频释放 wlan0 并尝试 saved WiFi
*/ */
class ProvisionManager extends EventEmitter { class ProvisionManager extends EventEmitter {
constructor(clawId) { constructor(clawId) {
super(); super();
this._clawId = clawId || 'Setup'; this._clawId = clawId || 'Setup';
this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta' | 'wired' this._state = 'idle'; // 'idle' | 'ap' | 'connecting' | 'sta' | 'wired'
this._dns = null; this._dns = null;
this._server = null; this._server = null;
this._monitorTimer = null; this._monitorTimer = null;
this._monitorBusy = false; this._monitorBusy = false;
this._apStartedAt = 0; this._apStartedAt = 0;
this._lastApSavedWifiRetryAt = 0; this._lastApSavedWifiRetryAt = 0;
} }
/** 是否正处于 AP 模式WiFi 热点广播中) */ /** 是否正处于 AP 模式WiFi 热点广播中) */
isApMode() { return this._state === 'ap'; } isApMode() { return this._state === 'ap'; }
async start() { async start() {
led.off(); // WiFi 灯初始状态:熄灭 led.off(); // WiFi 灯初始状态:熄灭
// WiFi STA 已连接 → 直接进入 STA 模式 // WiFi STA 已连接 → 直接进入 STA 模式
if (isWifiStaConnected()) { if (isWifiStaConnected()) {
this._state = 'sta'; this._state = 'sta';
log.info('provision', 'WiFi STA 已连接AP 不启动'); log.info('provision', 'WiFi STA 已连接AP 不启动');
this._emitNetworkReady(); this._emitNetworkReady();
this._startMonitor(); this._startMonitor();
return; return;
} }
// 网络已就绪时先启动 WShasInternet() 可能来自 WiFi也可能来自有线不能直接当作 wired。 // 网络已就绪时先启动 WShasInternet() 可能来自 WiFi也可能来自有线不能直接当作 wired。
if (hasInternet()) { if (hasInternet()) {
if (isWifiStaConnected()) { if (isWifiStaConnected()) {
this._state = 'sta'; this._state = 'sta';
log.info('provision', 'WiFi STA 已连接AP 不启动'); log.info('provision', 'WiFi STA 已连接AP 不启动');
this._emitNetworkReady(); this._emitNetworkReady();
} else { } else {
this._state = 'wired'; this._state = 'wired';
log.info('provision', '有线网络就绪,启动 WS不自动开启 AP'); log.info('provision', '有线网络就绪,启动 WS不自动开启 AP');
led.off(); led.off();
this._emitNetworkReady(); this._emitNetworkReady();
} }
this._startMonitor(); this._startMonitor();
return; return;
} }
// 无有线可用时,有 saved WiFi 才主动让 NetworkManager 重连;不要只被动等待 NM autoconnect。 // 无有线可用时,有 saved WiFi 才主动让 NetworkManager 重连;不要只被动等待 NM autoconnect。
if (hasSavedWifiConnection()) { if (hasSavedWifiConnection()) {
log.info('provision', `发现已保存的 WiFi 配置,主动重连(最多 ${WIFI_RECONNECT_MAX_ROUNDS} 轮)...`); log.info('provision', `发现已保存的 WiFi 配置,主动重连(最多 ${WIFI_RECONNECT_MAX_ROUNDS} 轮)...`);
this._state = 'connecting'; this._state = 'connecting';
led.blink(); led.blink();
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS); const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
if (connected) { if (connected) {
this._state = 'sta'; this._state = 'sta';
log.info('provision', '已保存 WiFi 重连成功AP 不启动'); log.info('provision', '已保存 WiFi 重连成功AP 不启动');
this._emitNetworkReady(); this._emitNetworkReady();
this._startMonitor(); this._startMonitor();
return; return;
} }
log.warn('provision', '已保存 WiFi 重连失败'); log.warn('provision', '已保存 WiFi 重连失败');
} }
// 无有线、无 WiFi且无 saved WiFi 或 saved WiFi 3 轮失败 → 开 AP 兜底配网。 // 无有线、无 WiFi且无 saved WiFi 或 saved WiFi 3 轮失败 → 开 AP 兜底配网。
this._enterAP(); this._enterAP();
this._startMonitor(); this._startMonitor();
} }
_emitNetworkReady() { _emitNetworkReady() {
if (hasInternet()) { if (hasInternet()) {
// WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮) // WiFi 灯只在 STA 模式下亮(有线有网而 WiFi 在 AP 模式时不亮)
if (this._state === 'sta') led.on(); if (this._state === 'sta') led.on();
this.emit('network-ready'); this.emit('network-ready');
} else { } else {
log.warn('provision', 'hasInternet() 返回 falseLED 保持熄灭'); log.warn('provision', 'hasInternet() 返回 falseLED 保持熄灭');
} }
} }
async _trySavedWifiReconnectRounds(rounds = WIFI_RECONNECT_MAX_ROUNDS) { async _trySavedWifiReconnectRounds(rounds = WIFI_RECONNECT_MAX_ROUNDS) {
for (let i = 1; i <= rounds; i++) { for (let i = 1; i <= rounds; i++) {
if (isWifiStaConnected()) return true; if (isWifiStaConnected()) return true;
log.info('provision', `尝试已保存 WiFi 重连:第 ${i}/${rounds}`); log.info('provision', `尝试已保存 WiFi 重连:第 ${i}/${rounds}`);
const result = await connectSavedWifiConnections(); const result = await connectSavedWifiConnections();
if (result.success || isWifiStaConnected()) return true; if (result.success || isWifiStaConnected()) return true;
if (i < rounds) { if (i < rounds) {
await new Promise((r) => setTimeout(r, WIFI_RECONNECT_ROUND_DELAY_MS)); await new Promise((r) => setTimeout(r, WIFI_RECONNECT_ROUND_DELAY_MS));
} }
} }
return false; return false;
} }
stop() { stop() {
this._stopMonitor(); this._stopMonitor();
this._stopAll(); this._stopAll();
this._state = 'idle'; this._state = 'idle';
led.destroy(); // WiFi 灯:停止时关灯、释放闪烁定时器 led.destroy(); // WiFi 灯:停止时关灯、释放闪烁定时器
} }
// ── 进入 AP 模式 ───────────────────────────────────────────────────────── // ── 进入 AP 模式 ─────────────────────────────────────────────────────────
_enterAP() { _enterAP() {
if (this._state === 'ap') return; if (this._state === 'ap') return;
led.off(); // AP 模式WiFi 未连接WiFi 灯熄灭 led.off(); // AP 模式WiFi 未连接WiFi 灯熄灭
if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP有线时等 WS 连接后再定 if (!hasInternet()) led.display.showAP(); // 无网时立即显示 AP有线时等 WS 连接后再定
try { try {
// 若上次进程退出前留下 clawd-hotspot必须先释放 wlan0否则会在 AP 模式下扫描,列表可能只剩 2.4G/自身热点。 // 若上次进程退出前留下 clawd-hotspot必须先释放 wlan0否则会在 AP 模式下扫描,列表可能只剩 2.4G/自身热点。
stopAP(); stopAP();
// AP 模式下无法扫描 WiFi必须在开 AP 之前扫描并缓存 // AP 模式下无法扫描 WiFi必须在开 AP 之前扫描并缓存
log.info('provision', '扫描周边 WiFi...'); log.info('provision', '扫描周边 WiFi...');
this._cachedWifiList = scanWifi(); this._cachedWifiList = scanWifi();
log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络: ${this._cachedWifiList.map(w => `${w.ssid}${w.band ? `(${w.band})` : ''}`).join(', ')}`); log.info('provision', `扫描到 ${this._cachedWifiList.length} 个网络: ${this._cachedWifiList.map(w => `${w.ssid}${w.band ? `(${w.band})` : ''}`).join(', ')}`);
// 写 DNS 劫持配置NM 启动热点时加载);接口名与热点一致,勿写死 wlan0 // 写 DNS 劫持配置NM 启动热点时加载);接口名与热点一致,勿写死 wlan0
this._dns = new DnsHijack(); this._dns = new DnsHijack();
this._dns.start(getWifiIface(), AP_IP); this._dns.start(getWifiIface(), AP_IP);
const ap = startAP(this._clawId); const ap = startAP(this._clawId);
this._server = new CaptiveServer({ this._server = new CaptiveServer({
clawId: this._clawId, clawId: this._clawId,
cachedWifiList: this._cachedWifiList, cachedWifiList: this._cachedWifiList,
onConnect: (ssid, password) => this._handleWifiConnect(ssid, password), onConnect: (ssid, password) => this._handleWifiConnect(ssid, password),
}); });
this._server.startListening(); this._server.startListening();
this._state = 'ap'; this._state = 'ap';
this._apStartedAt = Date.now(); this._apStartedAt = Date.now();
this._lastApSavedWifiRetryAt = 0; this._lastApSavedWifiRetryAt = 0;
log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`); log.info('provision', `AP 常驻模式已启动: ${ap.ssid}, 密码 12345678`);
log.info('provision', `配网地址: http://10.42.0.1`); log.info('provision', `配网地址: http://10.42.0.1`);
} catch (e) { } catch (e) {
log.error('provision', `AP 启动失败: ${e.message}`); log.error('provision', `AP 启动失败: ${e.message}`);
if (this._state !== 'sta') this._state = 'idle'; if (this._state !== 'sta') this._state = 'idle';
} }
} }
// ── 用户提交 WiFi 凭证 ─────────────────────────────────────────────────── // ── 用户提交 WiFi 凭证 ───────────────────────────────────────────────────
async _handleWifiConnect(ssid, password) { async _handleWifiConnect(ssid, password) {
if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' }; if (this._state === 'connecting') return { success: false, error: '正在连接中,请稍候' };
this._state = 'connecting'; this._state = 'connecting';
log.info('provision', `用户请求连接 WiFi: ${ssid}`); log.info('provision', `用户请求连接 WiFi: ${ssid}`);
led.blink(); // 正在连接 → 闪烁 led.blink(); // 正在连接 → 闪烁
try { try {
this._stopAPServices(); this._stopAPServices();
// 关热点后射频/模式切换需要时间,立刻 connect 在部分板子上会失败 // 关热点后射频/模式切换需要时间,立刻 connect 在部分板子上会失败
await new Promise((r) => setTimeout(r, 3500)); await new Promise((r) => setTimeout(r, 3500));
const result = await connectWifi(ssid, password); const result = await connectWifi(ssid, password);
if (result.success) { if (result.success) {
this._state = 'sta'; this._state = 'sta';
log.info('provision', `WiFi 已连接: ${ssid}`); log.info('provision', `WiFi 已连接: ${ssid}`);
led.on(); // WiFi 灯:连接成功 → 常亮 led.on(); // WiFi 灯:连接成功 → 常亮
this.emit('network-ready'); this.emit('network-ready');
return result; return result;
} }
log.warn('provision', `WiFi 连接失败: ${result.error},按当前网络状态恢复`); log.warn('provision', `WiFi 连接失败: ${result.error},按当前网络状态恢复`);
this._recoverAfterWifiFailure(); this._recoverAfterWifiFailure();
return result; return result;
} catch (e) { } catch (e) {
log.error('provision', `配网过程异常: ${e.message}`); log.error('provision', `配网过程异常: ${e.message}`);
this._recoverAfterWifiFailure(); this._recoverAfterWifiFailure();
return { success: false, error: e.message }; return { success: false, error: e.message };
} }
} }
/** WiFi 连接失败后:有线可用则保持 wired否则开 AP 兜底。 */ /** WiFi 连接失败后:有线可用则保持 wired否则开 AP 兜底。 */
_recoverAfterWifiFailure() { _recoverAfterWifiFailure() {
if (hasInternet()) { if (hasInternet()) {
this._state = 'wired'; this._state = 'wired';
led.off(); led.off();
this._emitNetworkReady(); this._emitNetworkReady();
return; return;
} }
this._safeReenterAP(); this._safeReenterAP();
} }
/** 重新开 AP失败时勿把 _state 永久卡在 connecting */ /** 重新开 AP失败时勿把 _state 永久卡在 connecting */
_safeReenterAP() { _safeReenterAP() {
try { try {
this._enterAP(); this._enterAP();
} catch (e) { } catch (e) {
log.error('provision', `重新启动 AP 失败: ${e.message}`); log.error('provision', `重新启动 AP 失败: ${e.message}`);
this._state = 'idle'; this._state = 'idle';
} }
} }
// ── WiFi 状态监控 ───────────────────────────────────────────────────────── // ── WiFi 状态监控 ─────────────────────────────────────────────────────────
_startMonitor() { _startMonitor() {
this._monitorTimer = setInterval(() => { this._monitorTimer = setInterval(() => {
if (this._monitorBusy) return; if (this._monitorBusy) return;
this._monitorBusy = true; this._monitorBusy = true;
this._monitorTick() this._monitorTick()
.catch((e) => log.error('provision', `WiFi 状态监控异常: ${e.message}`)) .catch((e) => log.error('provision', `WiFi 状态监控异常: ${e.message}`))
.finally(() => { this._monitorBusy = false; }); .finally(() => { this._monitorBusy = false; });
}, MONITOR_INTERVAL_MS); }, MONITOR_INTERVAL_MS);
} }
async _monitorTick() { async _monitorTick() {
if (this._state === 'connecting') return; if (this._state === 'connecting') return;
const wifiUp = isWifiStaConnected(); const wifiUp = isWifiStaConnected();
if (wifiUp && this._state !== 'sta') { if (wifiUp && this._state !== 'sta') {
if (this._state === 'ap') { if (this._state === 'ap') {
log.info('provision', 'WiFi 已外部连接,关闭 AP'); log.info('provision', 'WiFi 已外部连接,关闭 AP');
this._stopAPServices(); this._stopAPServices();
} }
this._state = 'sta'; this._state = 'sta';
this._emitNetworkReady(); this._emitNetworkReady();
} }
if (this._state === 'sta' && !wifiUp) { if (this._state === 'sta' && !wifiUp) {
log.warn('provision', 'WiFi 连接已断开,尝试恢复网络'); log.warn('provision', 'WiFi 连接已断开,尝试恢复网络');
await this._recoverNetworkWithoutWifi(); await this._recoverNetworkWithoutWifi();
return; return;
} }
if (this._state === 'wired') { if (this._state === 'wired') {
if (!hasInternet()) { if (!hasInternet()) {
log.warn('provision', '有线网络不可用,尝试恢复 WiFi'); log.warn('provision', '有线网络不可用,尝试恢复 WiFi');
await this._recoverNetworkWithoutWifi(); await this._recoverNetworkWithoutWifi();
return; return;
} }
led.off(); led.off();
return; return;
} }
if (this._state === 'ap') { if (this._state === 'ap') {
if (hasInternet()) { // AP 模式下 hasInternet() 可能被热点本地网络 / NetworkManager limited 状态误判。
log.info('provision', '检测到有线网络可用,关闭 AP'); // 只有明确探测到有线口可访问公网时,才关闭配网 AP。
this._stopAPServices(); if (hasWiredInternetProbe()) {
this._state = 'wired'; log.info('provision', '检测到有线网络可用,关闭 AP');
this._emitNetworkReady(); this._stopAPServices();
return; this._state = 'wired';
} this._emitNetworkReady();
return;
if (hasSavedWifiConnection() && this._shouldRetrySavedWifiFromAP()) { }
await this._retrySavedWifiFromAP();
return; if (hasSavedWifiConnection() && this._shouldRetrySavedWifiFromAP()) {
} await this._retrySavedWifiFromAP();
led.off(); return;
return; }
} led.off();
} return;
}
async _recoverNetworkWithoutWifi() { }
this._state = 'connecting';
led.blink(); async _recoverNetworkWithoutWifi() {
this._state = 'connecting';
if (hasSavedWifiConnection()) { led.blink();
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
if (connected) { if (hasSavedWifiConnection()) {
this._state = 'sta'; const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
this._emitNetworkReady(); if (connected) {
return; this._state = 'sta';
} this._emitNetworkReady();
} return;
}
if (hasInternet()) { }
this._state = 'wired';
led.off(); if (hasInternet()) {
this._emitNetworkReady(); this._state = 'wired';
return; led.off();
} this._emitNetworkReady();
return;
this._safeReenterAP(); }
}
this._safeReenterAP();
_shouldRetrySavedWifiFromAP() { }
const now = Date.now();
if (this._apStartedAt && now - this._apStartedAt < AP_MIN_UP_BEFORE_RETRY_MS) return false; _shouldRetrySavedWifiFromAP() {
if (this._lastApSavedWifiRetryAt && now - this._lastApSavedWifiRetryAt < AP_SAVED_WIFI_RETRY_INTERVAL_MS) return false; const now = Date.now();
return true; 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'); async _retrySavedWifiFromAP() {
this._state = 'connecting'; this._lastApSavedWifiRetryAt = Date.now();
led.blink(); log.info('provision', 'AP 模式下定期尝试已保存 WiFi');
this._stopAPServices(); this._state = 'connecting';
await new Promise((r) => setTimeout(r, 3500)); led.blink();
this._stopAPServices();
const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS); await new Promise((r) => setTimeout(r, 3500));
if (connected) {
this._state = 'sta'; const connected = await this._trySavedWifiReconnectRounds(WIFI_RECONNECT_MAX_ROUNDS);
this._emitNetworkReady(); if (connected) {
return; this._state = 'sta';
} this._emitNetworkReady();
return;
if (hasInternet()) { }
this._state = 'wired';
led.off(); if (hasInternet()) {
this._emitNetworkReady(); this._state = 'wired';
return; led.off();
} this._emitNetworkReady();
return;
log.warn('provision', 'AP 模式下重试已保存 WiFi 失败,恢复 AP'); }
this._safeReenterAP();
} log.warn('provision', 'AP 模式下重试已保存 WiFi 失败,恢复 AP');
this._safeReenterAP();
_stopMonitor() { }
if (this._monitorTimer) {
clearInterval(this._monitorTimer); _stopMonitor() {
this._monitorTimer = null; if (this._monitorTimer) {
} clearInterval(this._monitorTimer);
} this._monitorTimer = null;
}
// ── 清理 ────────────────────────────────────────────────────────────────── }
_stopAPServices() { // ── 清理 ──────────────────────────────────────────────────────────────────
if (this._server) {
this._server.stop(); _stopAPServices() {
this._server = null; if (this._server) {
} this._server.stop();
if (this._dns) { this._server = null;
this._dns.stop(); }
this._dns = null; if (this._dns) {
} this._dns.stop();
stopAP(); this._dns = null;
} }
stopAP();
_stopAll() { }
this._stopAPServices();
} _stopAll() {
} this._stopAPServices();
}
module.exports = { ProvisionManager }; }
module.exports = { ProvisionManager };

View 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

Binary file not shown.

21
lib/resource/3588s/src/LICENSE Executable file
View 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
View 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)"

View 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

View File

@@ -0,0 +1,16 @@
#! /bin/sh
start() {
demo &
}
case "$1" in
start)
start
;;
*)
echo "Usage: $0 {start}"
exit 1
esac
exit $?

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More