#!/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()