feat: publish linux led manager panel
This commit is contained in:
@@ -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
@@ -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"
|
||||
Executable
+23
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 ? `<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()
|
||||
Reference in New Issue
Block a user