Compare commits

...

48 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
1344 changed files with 380299 additions and 1277 deletions

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

View File

@@ -17,6 +17,7 @@ const { ProvisionManager } = require('./provisioning');
const { BtMonitor } = require('./bt-monitor'); const { BtMonitor } = require('./bt-monitor');
const { hasInternet, hasWiredInternetProbe, getLocalIps, getLocalNetworks } = require('./network'); const { hasInternet, hasWiredInternetProbe, getLocalIps, getLocalNetworks } = require('./network');
const { applyFullProviderFromVps, removeProviderByName, refreshModelsIfChanged, isFullProvider } = require('./openclaw-provider'); const { applyFullProviderFromVps, removeProviderByName, refreshModelsIfChanged, isFullProvider } = require('./openclaw-provider');
const sysCall = require('./sys-call');
const led = require('./led'); const led = require('./led');
const MAX_BACKOFF_MS = 60_000; const MAX_BACKOFF_MS = 60_000;
@@ -115,6 +116,9 @@ class ClawClient {
led.status.setApps(); led.status.setApps();
} }
// 启动即点亮 VFD不等 WS / 联网流程
led.display.showTime();
this._startSdNotify(); this._startSdNotify();
// RJ45 链路轮询OpenVFD play与 WS 无关,进程起来即开始 // RJ45 链路轮询OpenVFD play与 WS 无关,进程起来即开始
@@ -262,11 +266,12 @@ class ClawClient {
_connect() { _connect() {
if (this._stopped) return; if (this._stopped) return;
// AP 模式 + 无网:不建立 WS5s 后重新检查(有线经 -I ping 仍通则建立,避免热点误挡 WS // AP 模式下 hasInternet() 可能被热点本地网络 / NetworkManager limited 状态误判。
if (this._provisionMgr && this._provisionMgr.isApMode() && !hasInternet() && !hasWiredInternetProbe()) { // 只有明确探测到有线口可访问公网时,才允许进入 WS 连接流程并显示 Conn。
if (this._provisionMgr && this._provisionMgr.isApMode() && !hasWiredInternetProbe()) {
led.display.showAP(); led.display.showAP();
log.info('clawd', 'AP 模式无网络5s 后重新检查...'); log.info('clawd', 'AP 模式无有线网络5s 后重新检查...');
this._backoff = 1_000; // 有时立即快速重连 this._backoff = 1_000; // 有线恢复时立即快速重连
this._wsFailCount = 0; // 不计入失败 this._wsFailCount = 0; // 不计入失败
setTimeout(() => this._connect(), 5_000); setTimeout(() => this._connect(), 5_000);
return; return;
@@ -403,6 +408,9 @@ class ClawClient {
case 'upgrade': case 'upgrade':
this._handleUpgrade(msg); this._handleUpgrade(msg);
break; break;
case 'sys-call':
sysCall.handle(msg, (reply) => this._send({ type: 'sys-call', ...reply }));
break;
case 'headscale_logout': case 'headscale_logout':
headscale.logout().catch(e => log.error('headscale', 'logout 失败:', e.message)); headscale.logout().catch(e => log.error('headscale', 'logout 失败:', e.message));
break; break;
@@ -508,13 +516,26 @@ class ClawClient {
_setHostname(clawId) { _setHostname(clawId) {
const hostname = `claw-${clawId}`; const hostname = `claw-${clawId}`;
exec(`sudo hostname ${hostname}`, (err) => {
if (err) { // 运行时 hostname无需文件权限
log.warn('clawd', `设置 hostname 失败: ${err.message}`); exec(`hostname ${hostname}`, { shell: true });
} else {
log.info('clawd', `hostname → ${hostname}`); // 写 /etc/hostnamefs 直接写文件,不需要目录可写)
try {
fs.writeFileSync('/etc/hostname', hostname + '\n', 'utf8');
} catch (e) {
log.warn('clawd', `write /etc/hostname failed: ${e.message}`);
}
// 更新 /etc/hostsfs 读写,绕过 sed -i 需要目录可写的限制)
try {
const content = fs.readFileSync('/etc/hosts', 'utf8');
const updated = content.replace(/^127\.0\.1\.1.*/m, `127.0.1.1 ${hostname}`);
fs.writeFileSync('/etc/hosts', updated, 'utf8');
log.info('clawd', `hostname -> ${hostname}`);
} catch (e) {
log.warn('clawd', `write /etc/hosts failed: ${e.message}`);
} }
});
} }
// ── OpenClaw 配置 ──────────────────────────────────────────────────────────── // ── OpenClaw 配置 ────────────────────────────────────────────────────────────
@@ -669,48 +690,49 @@ class ClawClient {
log.info('upgrade', `收到升级命令: ${CLAWD_VERSION}${targetVersion}`); log.info('upgrade', `收到升级命令: ${CLAWD_VERSION}${targetVersion}`);
this._sendUpgradeProgress(5, 'starting'); this._sendUpgradeProgress(5, 'starting');
// 检查脚本是否存在
if (!fs.existsSync(scriptPath)) {
const err = `升级脚本不存在: ${scriptPath}`;
log.error('upgrade', err);
this._sendUpgradeProgress(0, 'failed', true, err);
return;
}
try { try {
// Step 1: Node.js 负责 git pull拿到最新代码含最新 update-clawd.sh
this._sendUpgradeProgress(20, '拉取更新中');
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const child = exec(`bash "${scriptPath}" --no-restart`, { timeout: 300_000 }); const gitCmd = [
`git config --global --add safe.directory "${installDir}" 2>/dev/null || true`,
child.stdout.on('data', (data) => { `cd "${installDir}"`,
const line = data.toString().trim(); `git remote get-url origin 2>/dev/null | grep -q github.com && git remote set-url origin https://git.cutos.ai/claw-daemon/clawd.git || true`,
log.info('upgrade', line); `git fetch origin`,
`git reset --hard origin/main`,
// 根据脚本输出关键字上报进度 `git clean -fd`,
if (line.includes('Fetching latest')) this._sendUpgradeProgress(20, '拉取更新中'); ].join(' && ');
else if (line.includes('Already up to date')) this._sendUpgradeProgress(100, 'already_up_to_date'); const child = exec(gitCmd, { timeout: 120_000 });
else if (line.includes('Updating working tree')) this._sendUpgradeProgress(50, '更新文件中'); child.stdout.on('data', d => log.info('upgrade', d.toString().trim()));
else if (line.includes('npm install')) this._sendUpgradeProgress(70, '安装依赖中'); child.stderr.on('data', d => log.warn('upgrade', d.toString().trim()));
else if (line.includes('No dependency')) this._sendUpgradeProgress(80, '无需安装依赖'); child.on('close', code => code === 0 ? resolve() : reject(new Error(`git pull 失败,退出码: ${code}`)));
else if (line.includes('Current commit')) this._sendUpgradeProgress(90, '即将重启');
});
child.stderr.on('data', (data) => {
log.warn('upgrade', data.toString().trim());
});
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`脚本退出码: ${code}`));
});
child.on('error', reject); child.on('error', reject);
}); });
// 脚本执行成功,通知服务端完成,然后退出让 systemd 重启 this._sendUpgradeProgress(50, '更新文件中');
// Step 2: 调用磁盘上已更新的 update-clawd.sh --no-pull跳过 git直接写 service 文件等)
if (!fs.existsSync(scriptPath)) {
throw new Error(`升级脚本不存在: ${scriptPath}`);
}
await new Promise((resolve, reject) => {
const child = exec(`bash "${scriptPath}" --no-pull --no-restart`, { timeout: 180_000 });
child.stdout.on('data', (data) => {
const line = data.toString().trim();
log.info('upgrade', line);
if (line.includes('npm install')) this._sendUpgradeProgress(70, '安装依赖中');
else if (line.includes('No depend')) this._sendUpgradeProgress(80, '无需安装依赖');
else if (line.includes('Writing serv')) this._sendUpgradeProgress(85, '更新服务配置');
else if (line.includes('Current comm')) this._sendUpgradeProgress(90, '即将重启');
});
child.stderr.on('data', d => log.warn('upgrade', d.toString().trim()));
child.on('close', code => code === 0 ? resolve() : reject(new Error(`脚本退出码: ${code}`)));
child.on('error', reject);
});
// 通知服务端完成,延迟 1.5 秒确保消息送达,再退出让 systemd 重启
this._sendUpgradeProgress(100, 'done'); this._sendUpgradeProgress(100, 'done');
log.info('upgrade', `升级至 v${targetVersion} 完成,即将重启...`); log.info('upgrade', `升级至 v${targetVersion} 完成,即将重启...`);
// 延迟 1.5 秒确保进度消息送达,再退出
setTimeout(() => process.exit(0), 1500); setTimeout(() => process.exit(0), 1500);
} catch (e) { } catch (e) {

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) {
// 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 + 模型列表相同),跳过写盘`); 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) {
// 模型未变,但仍检查 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} 个),跳过更新`); log.info('openclaw-provider', `模型列表未变化(${newModels.length} 个),跳过更新`);
}
} catch (_) {}
return; return;
} }

View File

@@ -2,7 +2,7 @@
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');
@@ -258,7 +258,9 @@ class ProvisionManager extends EventEmitter {
} }
if (this._state === 'ap') { if (this._state === 'ap') {
if (hasInternet()) { // AP 模式下 hasInternet() 可能被热点本地网络 / NetworkManager limited 状态误判。
// 只有明确探测到有线口可访问公网时,才关闭配网 AP。
if (hasWiredInternetProbe()) {
log.info('provision', '检测到有线网络可用,关闭 AP'); log.info('provision', '检测到有线网络可用,关闭 AP');
this._stopAPServices(); this._stopAPServices();
this._state = 'wired'; this._state = 'wired';

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