feat: publish linux led manager panel

This commit is contained in:
2026-05-15 14:48:09 +08:00
commit f8d3170028
5 changed files with 560 additions and 0 deletions
+145
View File
@@ -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/<led-name>/trigger
```
面板在手动设置亮度时会自动尝试切到 `none`
### 4. netdev trigger 怎么绑定网卡?
如果某个 LED 支持 `netdev` trigger,切到 netdev 后可能会出现这些文件:
```text
device_name
link
tx
rx
```
当前版本先做通用亮度/trigger/timer 管理;如果你要把 `blue:wan` 绑定到具体网卡,我可以继续加 netdev 绑定配置。
Executable
+91
View File
@@ -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"
+23
View File
@@ -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
}
+14
View File
@@ -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
Executable
+287
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[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 ? `<select onchange="setTrigger('${esc(led.name)}', this.value)">${triggers.map(t=>`<option value="${esc(t.name)}" ${t.active?'selected':''}>${esc(t.name)}</option>`).join('')}</select>` : '<span class="small">无 trigger 文件</span>';
const delay = (led.delay_on !== null || led.delay_off !== null) ? `<div class="row"><span class="small">timer:</span><input type="number" min="0" value="${led.delay_on ?? 500}" onchange="setDelay('${esc(led.name)}','delay_on',this.value)" title="delay_on ms"><input type="number" min="0" value="${led.delay_off ?? 500}" onchange="setDelay('${esc(led.name)}','delay_off',this.value)" title="delay_off ms"></div>` : '';
return `<div class="card">
<div class="name"><span class="dot" style="background:${colorOf(led.name,led.brightness)};color:${colorOf(led.name,led.brightness)}"></span>${esc(led.name)} ${led.writable?'<span class="badge">可写</span>':'<span class="small">只读/权限不足</span>'}</div>
<div class="meta">brightness: <b>${led.brightness}</b> / ${led.max_brightness} (${pct}%)<br>trigger: <b>${esc(led.active_trigger || '-')}</b><br>path: ${esc(led.path)}</div>
<div class="range-line"><input type="range" min="0" max="${led.max_brightness || 1}" value="${led.brightness}" oninput="this.nextElementSibling.value=this.value" onchange="setBrightness('${esc(led.name)}', this.value)"><input type="number" min="0" max="${led.max_brightness || 1}" value="${led.brightness}" onchange="setBrightness('${esc(led.name)}', this.value)"></div>
<div class="row"><button class="primary" onclick="setBrightness('${esc(led.name)}', ${led.max_brightness || 1})">打开</button><button class="off" onclick="setBrightness('${esc(led.name)}', 0)">关闭</button><button class="warn" onclick="setTrigger('${esc(led.name)}', 'timer')">闪烁</button><button onclick="identify('${esc(led.name)}')">识别3秒</button></div>
<div class="row"><span class="small">trigger</span>${triggerSelect}</div>${delay}
</div>`;
}
async function load(){
try { const data = await api('/api/leds'); document.getElementById('root').innerHTML = data.leds.map(card).join('') || '<div class="err">没有发现 LED/sys/class/leds 为空</div>'; document.getElementById('count').textContent=data.leds.length; }
catch(e){ document.getElementById('root').innerHTML='<div class="err">'+esc(e.message)+'</div>'; }
}
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"""<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>LED Manager</title><style>{CSS}</style></head><body><div class='container'><div class='header'><div><h1>LED Manager Panel</h1><div class='sub'>管理 <code>{html.escape(str(LED_ROOT))}</code> 下的 LED;当前 <span id='count'>-</span> 个。页面每 5 秒自动刷新。</div></div><button onclick='load()'>刷新</button></div><div id='root' class='grid'></div></div><div id='toast' class='toast'></div><script>{JS}</script></body></html>"""
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()