Files

559 lines
32 KiB
Python
Executable File
Raw Permalink 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"))
CONFIG_PATH = Path(os.environ.get("LED_CONFIG", "/etc/led-manager/config.json"))
# BOSS's device exposes more LED class entries than the physically controllable lights.
# Keep the panel focused on the real RGB lights by default; override with --colors if needed.
CONTROLLABLE_COLORS = tuple(c.strip().lower() for c in os.environ.get("LED_COLORS", "red,green,blue").split(",") if c.strip())
NAME_RE = re.compile(r"^[A-Za-z0-9_.:+@=-]+$")
CSS = r"""
:root {
color-scheme: light;
--island-sky:#8ed8f8;
--island-sea:#65c7d0;
--island-mint:#82d5bb;
--island-leaf:#8ac68a;
--island-sand:#f7e6a5;
--island-paper:#fff8df;
--island-paper-2:#fff1bd;
--island-ink:#725d42;
--island-muted:#9a835a;
--island-line:#d9b970;
--island-teal:#2bb3a3;
--island-blue:#889df0;
--island-red:#fc736d;
--island-orange:#e59266;
--island-shadow:rgba(114,93,66,.23);
--text:var(--island-ink);
--muted:var(--island-muted);
--accent:var(--island-teal);
--danger:var(--island-red);
--warn:#f7cd67;
}
* { box-sizing: border-box; }
body {
margin:0;
min-height:100vh;
font-family: 'Nunito','Zen Maru Gothic','Noto Sans SC', -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;
color:var(--text);
background:
radial-gradient(circle at 12% 14%, rgba(255,255,255,.72) 0 0.62rem, transparent .66rem),
radial-gradient(circle at 84% 12%, rgba(255,255,255,.70) 0 0.48rem, transparent .52rem),
radial-gradient(circle at 22% 82%, rgba(130,213,187,.35), transparent 17rem),
linear-gradient(180deg, #b7ecff 0%, #d8f7ff 36%, #fff6d3 37%, #f7e6a5 100%);
}
body::before { content:''; position:fixed; left:0; right:0; bottom:0; height:118px; pointer-events:none; background: radial-gradient(65px 32px at 10% 75%, rgba(82,178,112,.30), transparent 70%), radial-gradient(80px 38px at 88% 85%, rgba(43,179,163,.28), transparent 72%), linear-gradient(180deg, transparent, rgba(101,199,208,.23)); }
.container { max-width: 1180px; margin: 0 auto; padding: 24px; position:relative; z-index:1; }
.header { display:grid; grid-template-columns: minmax(0,1fr) auto; gap:16px; align-items:start; margin-bottom:18px; }
.hero { position:relative; overflow:hidden; border:3px solid rgba(114,93,66,.22); background:linear-gradient(180deg,var(--island-paper),var(--island-paper-2)); border-radius:34px 34px 38px 38px; padding:22px 24px; box-shadow:0 14px 0 rgba(114,93,66,.10), 0 24px 55px var(--island-shadow); }
.hero::after { content:'🏝'; position:absolute; right:22px; top:12px; font-size:54px; opacity:.20; transform:rotate(8deg); }
h1 { margin:0 0 8px; font-size:32px; letter-spacing:.4px; color:#6b5137; text-shadow:0 2px 0 rgba(255,255,255,.65); }
.sub { color:var(--muted); font-size:14px; line-height:1.7; max-width:760px; }
.sub code { background:#fff4c5; border:1px dashed #d9b970; border-radius:10px; padding:2px 6px; color:#6b5137; }
.toolbar { display:flex; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
.summary { display:flex; gap:10px; flex-wrap:wrap; margin-top:15px; }
.pill { border:2px solid rgba(114,93,66,.13); background:#fffefa; color:var(--muted); padding:8px 12px; border-radius:999px; font-size:13px; box-shadow:0 4px 0 rgba(114,93,66,.08); }
.pill b { color:#6b5137; }
.grid { display:grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap:18px; }
.card { position:relative; overflow:hidden; background:linear-gradient(180deg,#fffefa,#fff3cf); border:3px solid rgba(114,93,66,.18); border-radius:30px; padding:18px; box-shadow:0 10px 0 rgba(114,93,66,.12), 0 20px 36px rgba(114,93,66,.18); transition:.18s ease; }
.card::before { content:''; position:absolute; inset:-52px -42px auto auto; width:170px; height:170px; border-radius:45% 55% 52% 48%; background:radial-gradient(circle,var(--led-color),transparent 68%); opacity:.18; pointer-events:none; }
.card::after { content:''; position:absolute; left:18px; right:18px; top:8px; height:10px; border-radius:999px; background:repeating-linear-gradient(90deg, rgba(255,255,255,.58) 0 10px, transparent 10px 19px); opacity:.55; pointer-events:none; }
.card.on { border-color: color-mix(in srgb, var(--led-color) 45%, #d9b970); transform:translateY(-2px); }
.card.off { opacity:.88; filter:saturate(.78); }
.card-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:4px; }
.name { font-weight:900; font-size:20px; display:flex; align-items:center; gap:10px; word-break:break-all; color:#6b5137; }
.dot { width:16px; height:16px; border-radius:50%; background:var(--led-color); color:var(--led-color); box-shadow:0 0 16px currentColor, 0 2px 0 rgba(114,93,66,.18); border:2px solid rgba(255,255,255,.76); }
.bulb-wrap { display:grid; place-items:center; margin:20px 0 13px; }
.bulb { width:126px; height:126px; border-radius:48% 52% 47% 53%; display:grid; place-items:center; color:#5a452e; font-size:29px; font-weight:1000; background:radial-gradient(circle at 34% 28%,#fff 0 5%,color-mix(in srgb,var(--led-color) 84%,#fff6c9) 11%,var(--led-color) 58%,rgba(255,246,211,.56) 74%); box-shadow:0 0 calc(12px + var(--level)*44px) color-mix(in srgb, var(--led-color) 70%, transparent), inset 0 -12px 20px rgba(114,93,66,.10), 0 9px 0 rgba(114,93,66,.12); filter:saturate(calc(.65 + var(--level))); border:3px solid rgba(255,255,255,.65); }
.card.off .bulb { background:#eadfbe; color:#aa9671; box-shadow:inset 0 -10px 18px rgba(114,93,66,.12), 0 8px 0 rgba(114,93,66,.10); }
.meta { margin-top:10px; color:var(--muted); font-size:13px; line-height:1.58; background:rgba(255,255,255,.45); border:1px dashed rgba(114,93,66,.18); border-radius:16px; padding:10px; }
.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:12px; }
.stack { display:grid; gap:10px; margin-top:14px; }
button, select, input[type=number], input[type=text] { border:2px solid rgba(114,93,66,.18); background:#fffefa; color:var(--text); border-radius:999px; padding:10px 13px; font-size:14px; font-family:inherit; box-shadow:0 4px 0 rgba(114,93,66,.10); }
select { max-width: 100%; }
button { cursor:pointer; transition:.15s ease; font-weight:900; }
button:hover { border-color:#2bb3a3; transform: translateY(-2px); box-shadow:0 6px 0 rgba(114,93,66,.12); }
button:active { transform: translateY(2px); box-shadow:0 2px 0 rgba(114,93,66,.14); }
button.primary { background:linear-gradient(180deg,#91dfc8,#55c2aa); border-color:#2bb3a3; color:#fffefa; text-shadow:0 1px 0 rgba(0,0,0,.16); }
button.off { background:linear-gradient(180deg,#ffaaa6,#fc736d); border-color:#d95b56; color:#fffefa; text-shadow:0 1px 0 rgba(0,0,0,.18); }
button.warn { background:linear-gradient(180deg,#ffe082,#f7cd67); border-color:#d9a844; color:#725d42; }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:9px; }
.actions button { width:100%; }
.sub-actions button { padding:8px 10px; font-size:13px; background:#fff8df; }
input[type=range] { width:100%; accent-color: var(--led-color); }
.range-line { display:grid; grid-template-columns: 1fr 68px; gap:10px; align-items:center; margin-top:12px; }
.range-line input[type=number] { border-radius:16px; padding:9px 8px; text-align:center; }
.badge { color:#fffefa; background:#2bb3a3; padding:4px 9px; border-radius:999px; font-size:12px; font-weight:900; box-shadow:0 3px 0 rgba(114,93,66,.12); }
.badge.saved { background:#f7cd67; color:#725d42; }
.err { background:#fff1bd; border:2px dashed #fc736d; color:#92513d; padding:14px; border-radius:20px; margin-bottom:16px; }
.toast { position:fixed; right:16px; bottom:16px; max-width:520px; padding:13px 16px; border-radius:20px; background:#fffefa; color:#725d42; border:3px solid #82d5bb; box-shadow:0 14px 0 rgba(114,93,66,.10), 0 20px 40px rgba(114,93,66,.22); display:none; z-index:20; font-weight:800; }
.small { font-size:12px; color:var(--muted); }
.path { word-break:break-all; }
a { color:#2b8f86; }
@media (max-width: 900px) { .grid{grid-template-columns:1fr;} .header{grid-template-columns:1fr;} .toolbar{justify-content:flex-start;} }
@media (max-width: 640px) { .container{padding:14px;} h1{font-size:26px;} .hero{padding:17px;} .bulb{width:102px;height:102px;} .actions{grid-template-columns:1fr;} button,select,input[type=number],input[type=text]{width:100%;} }
"""
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+' 亮度='+(r.brightness ?? value)+(r.warning?''+r.warning:''), !!r.warning); setTimeout(load,260);}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,260);}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,260);}catch(e){toast(e.message,true);} }
async function identify(name){ try{ await api('/api/identify',{name}); toast('正在识别 '+name); setTimeout(load,3200);}catch(e){toast(e.message,true);} }
async function saveLed(name){ try{ await api('/api/save',{name}); toast('已保存 '+name+',重启后自动恢复'); setTimeout(load,260);}catch(e){toast(e.message,true);} }
async function deleteSaved(name){ try{ await api('/api/delete-saved',{name}); toast('已取消持久化 '+name); setTimeout(load,260);}catch(e){toast(e.message,true);} }
async function applySaved(){ try{ const r=await api('/api/apply-saved',{}); toast('已应用持久化配置:'+(r.applied||0)+''); setTimeout(load,260);}catch(e){toast(e.message,true);} }
async function turnOffAll(){ try{ const r=await api('/api/all-off',{}); const failed=(r.results||[]).filter(x=>!x.ok); toast(failed.length ? ('已关闭 '+(r.changed||0)+' 个,失败 '+failed.length+'') : ('已关闭全部 LED'+(r.changed||0)+''), failed.length>0); setTimeout(load,260);}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 labelOf(n){ const s=n.toLowerCase(); if(s.includes('red')) return '红灯'; if(s.includes('green') || s.includes('wlan')) return '绿灯'; if(s.includes('blue') || s.includes('wan')) return '蓝灯'; return n; }
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 level = Math.max(0, Math.min(1, led.max_brightness>0 ? led.brightness/led.max_brightness : 0));
const on = Number(led.brightness)>0;
const triggers = led.triggers || [];
const n = attr(led.name);
const color = colorOf(led.name, led.brightness);
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">闪烁间隔</span><input data-led="${n}" type="number" min="0" value="${led.delay_on ?? 500}" onchange="setDelay(this.dataset.led,'delay_on',this.value)" title="亮起毫秒"><input data-led="${n}" type="number" min="0" value="${led.delay_off ?? 500}" onchange="setDelay(this.dataset.led,'delay_off',this.value)" title="熄灭毫秒"></div>` : '';
return `<div class="card ${on?'on':'off'}" style="--led-color:${color};--level:${level}">
<div class="card-head"><div class="name"><span class="dot"></span><span>${esc(labelOf(led.name))}</span></div><div>${led.saved?'<span class="badge saved">已保存</span>':(led.writable?'<span class="badge">可写</span>':'<span class="small">只读</span>')}</div></div>
<div class="bulb-wrap"><div class="bulb">${pct}%</div></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="actions"><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="stack"><div class="row"><span class="small">模式</span>${triggerSelect}</div>${delay}<div class="row sub-actions"><button data-led="${n}" onclick="saveLed(this.dataset.led)">保存开机状态</button><button data-led="${n}" onclick="deleteSaved(this.dataset.led)">取消保存</button></div></div>
<div class="meta">名称:<b>${esc(led.name)}</b><br>trigger<b>${esc(led.active_trigger || '-')}</b><br><span class="path">${esc(led.path)}</span></div>
</div>`;
}
async function load(){
try { const data = await api('/api/leds'); const leds=data.leds||[]; document.getElementById('root').innerHTML = leds.map(card).join('') || '<div class="err">没有发现可控制的红绿蓝 LED</div>'; document.getElementById('count').textContent=leds.length; document.getElementById('oncount').textContent=leds.filter(x=>Number(x.brightness)>0).length; document.getElementById('savedcount').textContent=leds.filter(x=>x.saved).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_color(name: str) -> Optional[str]:
low = name.lower()
for color in CONTROLLABLE_COLORS:
if low == color or low.startswith(color + ":") or low.startswith(color + "-") or low.startswith(color + "_"):
return color
return None
def is_controllable_led_name(name: str) -> bool:
return not CONTROLLABLE_COLORS or led_color(name) is not None
def led_path(name: str) -> Path:
name = safe_name(name)
if not is_controllable_led_name(name):
raise ValueError("led hidden by color filter")
# /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 load_config() -> Dict[str, Dict[str, object]]:
try:
if not CONFIG_PATH.exists():
return {}
data = json.loads(CONFIG_PATH.read_text())
if not isinstance(data, dict):
return {}
leds = data.get("leds", data)
if not isinstance(leds, dict):
return {}
out = {}
for k, v in leds.items():
if not isinstance(v, dict):
continue
name = safe_name(str(k))
if is_controllable_led_name(name):
out[name] = v
return out
except Exception:
return {}
def save_config(config: Dict[str, Dict[str, object]]) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_PATH.with_suffix(CONFIG_PATH.suffix + ".tmp")
filtered = {safe_name(str(k)): v for k, v in config.items() if isinstance(v, dict) and is_controllable_led_name(safe_name(str(k)))}
payload = {"version": 1, "colors": list(CONTROLLABLE_COLORS), "leds": filtered}
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
os.replace(tmp, CONFIG_PATH)
try:
os.chmod(CONFIG_PATH, 0o644)
except Exception:
pass
def current_led_config(name: str) -> Dict[str, object]:
p = led_path(name)
cfg: Dict[str, object] = {}
active = active_trigger_name(read_text(p / "trigger"))
if active:
cfg["trigger"] = active
b = read_text(p / "brightness")
if b is not None:
try: cfg["brightness"] = int(b)
except ValueError: pass
for field in ("delay_on", "delay_off"):
v = read_text(p / field)
if v is not None:
try: cfg[field] = int(v)
except ValueError: pass
return cfg
def apply_led_config(name: str, cfg: Dict[str, object]) -> Dict[str, object]:
p = led_path(name)
result: Dict[str, object] = {"name": name, "ok": True, "errors": []}
# Apply trigger first so timer/netdev files appear before setting delay fields.
if "trigger" in cfg:
try:
set_trigger(p, str(cfg["trigger"]))
except Exception as e:
result["ok"] = False
result["errors"].append(f"trigger: {e}")
for field in ("delay_on", "delay_off"):
if field in cfg and (p / field).exists():
try:
write_text(p / field, str(int(cfg[field])))
except Exception as e:
result["ok"] = False
result["errors"].append(f"{field}: {e}")
if "brightness" in cfg:
try:
max_brightness = int(read_text(p / "max_brightness") or 1)
value = max(0, min(max_brightness, int(cfg["brightness"])))
# If trigger is not explicitly set, try none for manual brightness.
if "trigger" not in cfg and (p / "trigger").exists():
try: set_trigger(p, "none")
except Exception: pass
write_text(p / "brightness", str(value))
except Exception as e:
result["ok"] = False
result["errors"].append(f"brightness: {e}")
return result
def apply_saved_config() -> List[Dict[str, object]]:
config = load_config()
results = []
for name, cfg in config.items():
try:
results.append(apply_led_config(name, cfg))
except Exception as e:
results.append({"name": name, "ok": False, "errors": [str(e)]})
return results
def turn_off_all_leds() -> List[Dict[str, object]]:
results = []
for led in list_leds():
name = str(led["name"])
result: Dict[str, object] = {"name": name, "ok": True, "errors": []}
try:
p = led_path(name)
if (p / "trigger").exists():
try:
set_trigger(p, "none")
except Exception as e:
result["ok"] = False
result["errors"].append(f"trigger: {e}")
write_text(p / "brightness", "0")
try:
result["brightness"] = int(read_text(p / "brightness") or 0)
except ValueError:
result["brightness"] = read_text(p / "brightness")
except Exception as e:
result["ok"] = False
result["errors"].append(str(e))
results.append(result)
return results
def list_leds() -> List[Dict[str, object]]:
leds = []
saved_config = load_config()
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
if not is_controllable_led_name(name):
continue
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,
"saved": name in saved_config,
})
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 Island Console</title><style>{CSS}</style></head><body><div class='container'><div class='header'><div class='hero'><h1>🏝 RGB LED 岛屿控制台</h1><div class='sub'>Animal-Island 风格面板:只显示实际可控的红 / 绿 / 蓝灯;配置保存到 <code>{html.escape(str(CONFIG_PATH))}</code>,页面每 5 秒自动刷新。</div><div class='summary'><span class='pill'>可控灯 <b id='count'>-</b></span><span class='pill'>已点亮 <b id='oncount'>-</b></span><span class='pill'>开机保存 <b id='savedcount'>-</b></span><span class='pill'>LED 目录 <b>{html.escape(str(LED_ROOT))}</b></span></div></div><div class='toolbar'><button class='off' onclick='turnOffAll()'>关闭全部</button><button class='warn' onclick='applySaved()'>应用保存</button><button onclick='load()'>刷新</button></div></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 = None
p = None
if parsed.path not in ("/api/apply-saved", "/api/all-off"):
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/save":
config = load_config()
config[name] = current_led_config(name)
save_config(config)
self.send_json({"ok": True, "config": config[name], "path": str(CONFIG_PATH)})
elif parsed.path == "/api/delete-saved":
config = load_config()
existed = name in config
config.pop(name, None)
save_config(config)
self.send_json({"ok": True, "deleted": existed, "path": str(CONFIG_PATH)})
elif parsed.path == "/api/apply-saved":
results = apply_saved_config()
self.send_json({"ok": True, "applied": sum(1 for r in results if r.get("ok")), "results": results})
elif parsed.path == "/api/all-off":
results = turn_off_all_leds()
self.send_json({"ok": all(r.get("ok") for r in results), "changed": sum(1 for r in results if r.get("brightness") == 0), "results": results})
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, CONFIG_PATH, CONTROLLABLE_COLORS
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")
ap.add_argument("--config", default=str(CONFIG_PATH), help="persistent config path, default /etc/led-manager/config.json")
ap.add_argument("--colors", default=",".join(CONTROLLABLE_COLORS), help="comma-separated LED color prefixes to expose, default red,green,blue; empty means expose all")
ap.add_argument("--no-apply", action="store_true", help="do not apply saved config on startup")
args = ap.parse_args()
LED_ROOT = Path(args.led_root)
CONFIG_PATH = Path(args.config)
CONTROLLABLE_COLORS = tuple(c.strip().lower() for c in args.colors.split(",") if c.strip())
if not args.no_apply:
results = apply_saved_config()
if results:
print(f"Applied saved LED config: {results}", flush=True)
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"LED Manager Panel listening on http://{args.host}:{args.port} ; LED_ROOT={LED_ROOT}; CONFIG_PATH={CONFIG_PATH}; COLORS={','.join(CONTROLLABLE_COLORS) or 'all'}", flush=True)
httpd.serve_forever()
if __name__ == "__main__":
main()