Files
led-manager-panel/led_panel.py
T

319 lines
17 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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{ const r=await api('/api/brightness',{name,value:Number(value)}); toast(name+' brightness='+(r.brightness ?? value)+(r.warning?''+r.warning:''), !!r.warning); setTimeout(load,300);}catch(e){toast(e.message,true);} }
async function setTrigger(name, trigger){ try{ const r=await api('/api/trigger',{name,trigger}); toast(name+' trigger='+(r.active_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); setTimeout(load,300);}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 attr(s){ return esc(s); }
function card(led){
const pct = led.max_brightness>0 ? Math.round(led.brightness*100/led.max_brightness) : 0;
const triggers = led.triggers || [];
const n = attr(led.name);
const triggerSelect = triggers.length ? `<select data-led="${n}" onchange="setTrigger(this.dataset.led, this.value)">${triggers.map(t=>`<option value="${attr(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 data-led="${n}" type="number" min="0" value="${led.delay_on ?? 500}" onchange="setDelay(this.dataset.led,'delay_on',this.value)" title="delay_on ms"><input data-led="${n}" type="number" min="0" value="${led.delay_off ?? 500}" onchange="setDelay(this.dataset.led,'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 data-led="${n}" type="range" min="0" max="${led.max_brightness || 1}" value="${led.brightness}" oninput="this.nextElementSibling.value=this.value" onchange="setBrightness(this.dataset.led, this.value)"><input data-led="${n}" type="number" min="0" max="${led.max_brightness || 1}" value="${led.brightness}" onchange="setBrightness(this.dataset.led, this.value)"></div>
<div class="row"><button class="primary" data-led="${n}" data-max="${led.max_brightness || 1}" onclick="setBrightness(this.dataset.led, this.dataset.max)">打开</button><button class="off" data-led="${n}" onclick="setBrightness(this.dataset.led, 0)">关闭</button><button class="warn" data-led="${n}" onclick="setTrigger(this.dataset.led, 'timer')">闪烁</button><button data-led="${n}" onclick="identify(this.dataset.led)">识别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)
# /sys/class/leds entries are usually symlinks to /sys/devices/...
# Do NOT require the resolved target to stay under LED_ROOT, otherwise every real sysfs LED POST is rejected.
# safe_name() forbids slash and '..', so joining one basename under LED_ROOT is enough here.
p = LED_ROOT / name
if not p.exists():
raise ValueError("led not found")
if not (p / "brightness").exists():
raise ValueError("not a led sysfs entry")
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:
with path.open("w") as f:
f.write(str(value))
f.flush()
def active_trigger_name(raw: Optional[str]) -> Optional[str]:
_, active = parse_triggers(raw)
return active
def set_trigger(p: Path, trigger: str) -> bool:
trigger_file = p / "trigger"
if not trigger_file.exists():
return False
raw = read_text(trigger_file)
triggers, active = parse_triggers(raw)
names = {str(t["name"]) for t in triggers}
if names and trigger not in names:
raise ValueError(f"trigger not supported: {trigger}")
if active == trigger:
return True
write_text(trigger_file, trigger)
# Verify because LED sysfs can accept write but keep previous trigger if unsupported/raced.
new_active = active_trigger_name(read_text(trigger_file))
if new_active and new_active != trigger:
raise ValueError(f"trigger write did not take effect: requested={trigger}, active={new_active}")
return True
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 only takes effect when trigger is none.
# Some device LEDs do not support none; in that case still write brightness and report the actual state.
trigger_warning = None
if (p / "trigger").exists():
try:
set_trigger(p, "none")
except Exception as e:
trigger_warning = str(e)
write_text(p / "brightness", str(value))
actual = read_text(p / "brightness")
self.send_json({"ok": True, "brightness": actual, "warning": trigger_warning})
elif parsed.path == "/api/trigger":
trigger = str(data.get("trigger", ""))
if not NAME_RE.match(trigger):
raise ValueError("invalid trigger")
set_trigger(p, trigger)
self.send_json({"ok": True, "active_trigger": active_trigger_name(read_text(p / "trigger"))})
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():
set_trigger(p, "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: set_trigger(p, 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()