commit f8d3170028b179b7afde7bb6ad539cbfb19a9dd0 Author: Hermes Agent Date: Fri May 15 14:48:09 2026 +0800 feat: publish linux led manager panel diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8c75e0 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# LED Manager Panel + +一个无第三方依赖的 Linux LED Web 管理面板,用 Python 标准库直接管理: + +```text +/sys/class/leds/ +``` + +适合 Debian、OpenWrt、Raspberry Pi OS、4G 路由器等 Linux 设备。 + +你的设备示例: + +```text +blue:wan green:wlan mmc0:: red:power sim:en sim:en2 sim:sel sim:sel2 +``` + +## 功能 + +- 列出 `/sys/class/leds/` 下所有 LED +- 查看 brightness / max_brightness / 当前 trigger +- 打开 / 关闭 LED +- 调整亮度 +- 切换 trigger,例如 `none`、`timer`、`netdev`、`mmc0` 等,具体取决于内核暴露的能力 +- 设置 `timer` trigger 的 `delay_on` / `delay_off` +- “识别 3 秒”闪烁指定 LED +- 无数据库、无 Node、无 pip 依赖 + +## 一键安装 + +```bash +curl -fsSL https://gitea.chickliu.fun/Hermes/led-manager-panel/raw/branch/main/install.sh | sh +``` + +如果设备没有 `curl`,用 `wget`: + +```bash +wget -qO- https://gitea.chickliu.fun/Hermes/led-manager-panel/raw/branch/main/install.sh | sh +``` + +默认端口 `8088`,安装后浏览器打开: + +```text +http://设备IP:8088/ +``` + +自定义端口: + +```bash +PORT=8090 sh -c "$(curl -fsSL https://gitea.chickliu.fun/Hermes/led-manager-panel/raw/branch/main/install.sh)" +``` + +## 手动运行 + +把 `led_panel.py` 上传到设备,例如: + +```bash +scp led_panel.py root@4G-wifi:/root/led_panel.py +``` + +在设备上运行: + +```bash +chmod +x /root/led_panel.py +python3 /root/led_panel.py --host 0.0.0.0 --port 8088 +``` + +如果设备没有 Python3: + +```bash +# Debian/Ubuntu +apt update && apt install -y python3 + +# OpenWrt +opkg update && opkg install python3 +``` + +## Debian systemd 安装 + +```bash +mkdir -p /opt/led-manager +cp led_panel.py /opt/led-manager/led_panel.py +chmod +x /opt/led-manager/led_panel.py +cp led-manager.service /etc/systemd/system/led-manager.service +systemctl daemon-reload +systemctl enable --now led-manager +systemctl status led-manager +``` + +默认端口:`8088`。 + +## OpenWrt procd 安装 + +```bash +mkdir -p /opt/led-manager +cp led_panel.py /opt/led-manager/led_panel.py +chmod +x /opt/led-manager/led_panel.py +cp led-manager.openwrt.init /etc/init.d/led-manager +chmod +x /etc/init.d/led-manager +/etc/init.d/led-manager enable +/etc/init.d/led-manager start +/etc/init.d/led-manager status +``` + +## 安全说明 + +这个面板会写 `/sys/class/leds/*`,建议只在内网使用,不要直接暴露公网。 + +如需公网访问,请放到反代后面加认证,或者只监听 `127.0.0.1`: + +```bash +python3 /root/led_panel.py --host 127.0.0.1 --port 8088 +``` + +## 常见问题 + +### 1. 按钮提示权限不足 + +需要 root 运行,因为 `/sys/class/leds/*/brightness` 通常只有 root 可写。 + +### 2. 某些 LED 不能调亮度 + +有些 LED 只有 `0/1` 两档,`max_brightness` 会是 `1`。 + +### 3. 切换 trigger 后亮度不能手动控制 + +Linux LED 子系统里,手动亮度通常要先切回: + +```bash +echo none > /sys/class/leds//trigger +``` + +面板在手动设置亮度时会自动尝试切到 `none`。 + +### 4. netdev trigger 怎么绑定网卡? + +如果某个 LED 支持 `netdev` trigger,切到 netdev 后可能会出现这些文件: + +```text +device_name +link +tx +rx +``` + +当前版本先做通用亮度/trigger/timer 管理;如果你要把 `blue:wan` 绑定到具体网卡,我可以继续加 netdev 绑定配置。 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..985b408 --- /dev/null +++ b/install.sh @@ -0,0 +1,91 @@ +#!/bin/sh +set -eu + +APP_NAME="led-manager" +APP_DIR="/opt/led-manager" +PORT="${PORT:-8088}" +HOST="${HOST:-0.0.0.0}" +RAW_BASE="${RAW_BASE:-https://gitea.chickliu.fun/Hermes/led-manager-panel/raw/branch/main}" + +need_root() { + if [ "$(id -u)" != "0" ]; then + echo "请用 root 运行:su - 或 sudo sh install.sh" >&2 + exit 1 + fi +} + +has_cmd() { command -v "$1" >/dev/null 2>&1; } + +download() { + url="$1" + out="$2" + if has_cmd curl; then + curl -fsSL "$url" -o "$out" + elif has_cmd wget; then + wget -qO "$out" "$url" + else + echo "缺少 curl/wget,无法下载 $url" >&2 + exit 1 + fi +} + +install_python_if_possible() { + if has_cmd python3; then return 0; fi + echo "未发现 python3,尝试安装..." + if has_cmd opkg; then + opkg update + opkg install python3 + elif has_cmd apt-get; then + apt-get update + apt-get install -y python3 + else + echo "未发现 opkg/apt-get,请先手动安装 python3" >&2 + exit 1 + fi +} + +install_openwrt_service() { + echo "检测到 OpenWrt/procd,安装 /etc/init.d/$APP_NAME" + download "$RAW_BASE/led-manager.openwrt.init" "/etc/init.d/$APP_NAME" + chmod +x "/etc/init.d/$APP_NAME" + # Patch host/port in init script if env overrides were provided. + sed -i "s/^HOST=.*/HOST=$HOST/" "/etc/init.d/$APP_NAME" || true + sed -i "s/^PORT=.*/PORT=$PORT/" "/etc/init.d/$APP_NAME" || true + "/etc/init.d/$APP_NAME" enable + "/etc/init.d/$APP_NAME" restart || "/etc/init.d/$APP_NAME" start + "/etc/init.d/$APP_NAME" status || true +} + +install_systemd_service() { + echo "检测到 systemd,安装 led-manager.service" + download "$RAW_BASE/led-manager.service" "/etc/systemd/system/$APP_NAME.service" + # Patch host/port in service if env overrides were provided. + sed -i "s/--host [^ ]* --port [0-9]*/--host $HOST --port $PORT/" "/etc/systemd/system/$APP_NAME.service" || true + systemctl daemon-reload + systemctl enable --now "$APP_NAME" + systemctl restart "$APP_NAME" + systemctl status "$APP_NAME" --no-pager || true +} + +need_root +install_python_if_possible +mkdir -p "$APP_DIR" +download "$RAW_BASE/led_panel.py" "$APP_DIR/led_panel.py" +chmod +x "$APP_DIR/led_panel.py" + +if [ -x /sbin/procd ] || [ -d /etc/rc.d ] && has_cmd procd; then + install_openwrt_service +elif has_cmd systemctl && [ -d /run/systemd/system ]; then + install_systemd_service +elif [ -d /etc/init.d ] && [ -f /etc/openwrt_release ]; then + install_openwrt_service +else + echo "未检测到 systemd/procd,已安装到 $APP_DIR。手动启动:" + echo "python3 $APP_DIR/led_panel.py --host $HOST --port $PORT" +fi + +ipaddr="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" +echo +echo "安装完成。" +echo "访问地址: http://${ipaddr:-设备IP}:$PORT/" +echo "如需修改端口:PORT=8090 sh install.sh" diff --git a/led-manager.openwrt.init b/led-manager.openwrt.init new file mode 100755 index 0000000..e911558 --- /dev/null +++ b/led-manager.openwrt.init @@ -0,0 +1,23 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 + +PROG=/usr/bin/python3 +APP=/opt/led-manager/led_panel.py +HOST=0.0.0.0 +PORT=8088 + +start_service() { + procd_open_instance + procd_set_param command "$PROG" "$APP" --host "$HOST" --port "$PORT" + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger led-manager +} diff --git a/led-manager.service b/led-manager.service new file mode 100644 index 0000000..c3d88a1 --- /dev/null +++ b/led-manager.service @@ -0,0 +1,14 @@ +[Unit] +Description=Linux LED Manager Web Panel +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/led-manager +ExecStart=/usr/bin/python3 /opt/led-manager/led_panel.py --host 0.0.0.0 --port 8088 +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/led_panel.py b/led_panel.py new file mode 100755 index 0000000..2e415e0 --- /dev/null +++ b/led_panel.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Linux LED Manager Panel +A tiny dependency-free web panel for /sys/class/leds on Debian/OpenWrt/Linux. +""" + +from __future__ import annotations + +import argparse +import html +import json +import os +import re +import sys +import time +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +LED_ROOT = Path(os.environ.get("LED_ROOT", "/sys/class/leds")) +NAME_RE = re.compile(r"^[A-Za-z0-9_.:+@=-]+$") + +CSS = r""" +:root { color-scheme: dark; --bg:#0b1020; --card:#121a2f; --muted:#8ea0c0; --text:#e8eefc; --line:#263551; --accent:#5eead4; --danger:#fb7185; --warn:#fbbf24; } +* { box-sizing: border-box; } +body { margin:0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; background: radial-gradient(circle at top left,#172554 0,#0b1020 34rem); color:var(--text); } +.container { max-width: 1180px; margin: 0 auto; padding: 24px; } +.header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:20px; } +h1 { margin:0 0 6px; font-size:28px; } +.sub { color:var(--muted); font-size:14px; } +.grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(310px,1fr)); gap:16px; } +.card { background: rgba(18,26,47,.92); border:1px solid var(--line); border-radius:18px; padding:16px; box-shadow: 0 10px 30px rgba(0,0,0,.25); } +.name { font-weight:700; font-size:18px; display:flex; align-items:center; gap:8px; word-break:break-all; } +.dot { width:12px; height:12px; border-radius:50%; background:#64748b; box-shadow:0 0 12px currentColor; } +.meta { margin-top:8px; color:var(--muted); font-size:13px; line-height:1.5; } +.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:12px; } +button, select, input[type=number], input[type=text] { border:1px solid var(--line); background:#0f172a; color:var(--text); border-radius:10px; padding:9px 10px; font-size:14px; } +button { cursor:pointer; transition:.15s; } +button:hover { border-color:var(--accent); transform: translateY(-1px); } +button.primary { background:#0f766e; border-color:#14b8a6; } +button.off { background:#3f1d2a; border-color:#7f1d1d; } +button.warn { background:#422006; border-color:#92400e; } +input[type=range] { width:100%; accent-color: var(--accent); } +.range-line { display:grid; grid-template-columns: 1fr 64px; gap:10px; align-items:center; margin-top:14px; } +.badge { color:#0b1020; background:var(--accent); padding:2px 7px; border-radius:999px; font-size:12px; font-weight:700; } +.err { background:#450a0a; border:1px solid #7f1d1d; color:#fecaca; padding:12px; border-radius:12px; margin-bottom:16px; } +.toast { position:fixed; right:16px; bottom:16px; max-width:520px; padding:12px 14px; border-radius:12px; background:#052e2b; border:1px solid #14b8a6; display:none; } +.small { font-size:12px; color:var(--muted); } +a { color:var(--accent); } +@media (max-width: 640px) { .container{padding:14px;} .header{display:block;} } +""" + +JS = r""" +async function api(path, body) { + const opt = body ? {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)} : {}; + const r = await fetch(path, opt); + const data = await r.json(); + if (!r.ok || data.ok === false) throw new Error(data.error || ('HTTP '+r.status)); + return data; +} +function toast(msg, bad=false){ const t=document.getElementById('toast'); t.textContent=msg; t.style.display='block'; t.style.background=bad?'#450a0a':'#052e2b'; t.style.borderColor=bad?'#fb7185':'#14b8a6'; setTimeout(()=>t.style.display='none',2600); } +async function setBrightness(name, value){ try{ await api('/api/brightness',{name,value:Number(value)}); toast(name+' brightness='+value); setTimeout(load,300);}catch(e){toast(e.message,true);} } +async function setTrigger(name, trigger){ try{ await api('/api/trigger',{name,trigger}); toast(name+' trigger='+trigger); setTimeout(load,300);}catch(e){toast(e.message,true);} } +async function setDelay(name, field, value){ try{ await api('/api/delay',{name,field,value:Number(value)}); toast(name+' '+field+'='+value); }catch(e){toast(e.message,true);} } +async function identify(name){ try{ await api('/api/identify',{name}); toast('Blink '+name); setTimeout(load,3200);}catch(e){toast(e.message,true);} } +function colorOf(n,b){ const s=n.toLowerCase(); if(Number(b)<=0) return '#475569'; if(s.includes('red')) return '#ef4444'; if(s.includes('green')) return '#22c55e'; if(s.includes('blue')) return '#3b82f6'; if(s.includes('wlan')) return '#22c55e'; if(s.includes('wan')) return '#3b82f6'; if(s.includes('power')) return '#ef4444'; if(s.includes('sim')) return '#f59e0b'; return '#5eead4'; } +function esc(s){ return String(s).replace(/[&<>'"]/g, c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); } +function card(led){ + const pct = led.max_brightness>0 ? Math.round(led.brightness*100/led.max_brightness) : 0; + const triggers = led.triggers || []; + const triggerSelect = triggers.length ? `` : '无 trigger 文件'; + const delay = (led.delay_on !== null || led.delay_off !== null) ? `
timer:
` : ''; + return `
+
${esc(led.name)} ${led.writable?'可写':'只读/权限不足'}
+
brightness: ${led.brightness} / ${led.max_brightness} (${pct}%)
trigger: ${esc(led.active_trigger || '-')}
path: ${esc(led.path)}
+
+
+
trigger${triggerSelect}
${delay} +
`; +} +async function load(){ + try { const data = await api('/api/leds'); document.getElementById('root').innerHTML = data.leds.map(card).join('') || '
没有发现 LED:/sys/class/leds 为空
'; document.getElementById('count').textContent=data.leds.length; } + catch(e){ document.getElementById('root').innerHTML='
'+esc(e.message)+'
'; } +} +window.addEventListener('load', ()=>{ load(); setInterval(load, 5000); }); +""" + + +def safe_name(name: str) -> str: + name = urllib.parse.unquote(name) + if not NAME_RE.match(name) or "/" in name or ".." in name: + raise ValueError("invalid led name") + return name + + +def led_path(name: str) -> Path: + name = safe_name(name) + p = LED_ROOT / name + try: + root = LED_ROOT.resolve(strict=True) + resolved = p.resolve(strict=True) + except FileNotFoundError: + raise ValueError("led not found") + if root not in resolved.parents and resolved != root: + raise ValueError("invalid led path") + return p + + +def read_text(path: Path) -> Optional[str]: + try: + return path.read_text().strip() + except Exception: + return None + + +def write_text(path: Path, value: str) -> None: + path.write_text(str(value)) + + +def parse_triggers(raw: Optional[str]) -> Tuple[List[Dict[str, object]], Optional[str]]: + if not raw: + return [], None + out = [] + active = None + for part in raw.split(): + is_active = part.startswith("[") and part.endswith("]") + name = part[1:-1] if is_active else part + if is_active: + active = name + out.append({"name": name, "active": is_active}) + return out, active + + +def list_leds() -> List[Dict[str, object]]: + leds = [] + if not LED_ROOT.exists(): + return leds + for p in sorted(LED_ROOT.iterdir(), key=lambda x: x.name): + if not p.is_dir(): + continue + name = p.name + b = read_text(p / "brightness") + mb = read_text(p / "max_brightness") + tr_raw = read_text(p / "trigger") + triggers, active = parse_triggers(tr_raw) + delay_on = read_text(p / "delay_on") + delay_off = read_text(p / "delay_off") + try: + brightness = int(b or 0) + except ValueError: + brightness = 0 + try: + max_brightness = int(mb or 1) + except ValueError: + max_brightness = 1 + writable = os.access(p / "brightness", os.W_OK) + leds.append({ + "name": name, + "path": str(p), + "brightness": brightness, + "max_brightness": max_brightness, + "triggers": triggers, + "active_trigger": active, + "delay_on": int(delay_on) if delay_on and delay_on.isdigit() else None, + "delay_off": int(delay_off) if delay_off and delay_off.isdigit() else None, + "writable": writable, + }) + return leds + + +class Handler(BaseHTTPRequestHandler): + server_version = "LedPanel/1.0" + + def log_message(self, fmt: str, *args): + sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), fmt % args)) + + def send_json(self, obj: object, status: int = 200): + data = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def read_body(self) -> Dict[str, object]: + length = int(self.headers.get("Content-Length", "0") or 0) + if length > 4096: + raise ValueError("body too large") + raw = self.rfile.read(length).decode("utf-8") if length else "{}" + return json.loads(raw or "{}") + + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + if parsed.path == "/": + body = f"""LED Manager

LED Manager Panel

管理 {html.escape(str(LED_ROOT))} 下的 LED;当前 - 个。页面每 5 秒自动刷新。
""" + data = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + elif parsed.path == "/api/leds": + self.send_json({"ok": True, "leds": list_leds()}) + else: + self.send_json({"ok": False, "error": "not found"}, 404) + + def do_POST(self): + parsed = urllib.parse.urlparse(self.path) + try: + data = self.read_body() + name = safe_name(str(data.get("name", ""))) + p = led_path(name) + if parsed.path == "/api/brightness": + max_brightness = int(read_text(p / "max_brightness") or 1) + value = int(data.get("value", 0)) + value = max(0, min(max_brightness, value)) + # manual brightness usually needs trigger none first, ignore if unsupported + if (p / "trigger").exists(): + try: + write_text(p / "trigger", "none") + except Exception: + pass + write_text(p / "brightness", str(value)) + self.send_json({"ok": True}) + elif parsed.path == "/api/trigger": + trigger = str(data.get("trigger", "")) + if not NAME_RE.match(trigger): + raise ValueError("invalid trigger") + write_text(p / "trigger", trigger) + self.send_json({"ok": True}) + elif parsed.path == "/api/delay": + field = str(data.get("field", "")) + if field not in ("delay_on", "delay_off"): + raise ValueError("invalid delay field") + value = max(0, int(data.get("value", 0))) + write_text(p / field, str(value)) + self.send_json({"ok": True}) + elif parsed.path == "/api/identify": + old_trigger = read_text(p / "trigger") + old_brightness = read_text(p / "brightness") + try: + if (p / "trigger").exists(): + write_text(p / "trigger", "timer") + if (p / "delay_on").exists(): write_text(p / "delay_on", "200") + if (p / "delay_off").exists(): write_text(p / "delay_off", "200") + else: + mb = int(read_text(p / "max_brightness") or 1) + for _ in range(6): + write_text(p / "brightness", str(mb)); time.sleep(0.2) + write_text(p / "brightness", "0"); time.sleep(0.2) + time.sleep(3) + finally: + if old_trigger and "[" in old_trigger: + _, active = parse_triggers(old_trigger) + if active: + try: write_text(p / "trigger", active) + except Exception: pass + if old_brightness is not None: + try: write_text(p / "brightness", old_brightness) + except Exception: pass + self.send_json({"ok": True}) + else: + self.send_json({"ok": False, "error": "not found"}, 404) + except PermissionError: + self.send_json({"ok": False, "error": "权限不足:请用 root 运行,或给 /sys/class/leds 写权限"}, 403) + except Exception as e: + self.send_json({"ok": False, "error": str(e)}, 400) + + +def main(): + global LED_ROOT + ap = argparse.ArgumentParser(description="Tiny web panel for /sys/class/leds") + ap.add_argument("--host", default="0.0.0.0", help="listen host, default 0.0.0.0") + ap.add_argument("--port", type=int, default=8088, help="listen port, default 8088") + ap.add_argument("--led-root", default=str(LED_ROOT), help="LED sysfs root, default /sys/class/leds") + args = ap.parse_args() + LED_ROOT = Path(args.led_root) + httpd = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"LED Manager Panel listening on http://{args.host}:{args.port} ; LED_ROOT={LED_ROOT}", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main()