From ac75a5f9b7a4dbfea48634dcf109d5de204379b4 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 15 May 2026 15:25:40 +0800 Subject: [PATCH] feat: persist led config on debian startup --- README.md | 23 ++++++++ install.sh | 69 +++++++++++------------ led-manager.service | 7 ++- led_panel.py | 132 +++++++++++++++++++++++++++++++++++++++++--- test_led_panel.py | 16 +++++- 5 files changed, 203 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index daebef1..c3e4641 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,29 @@ blue:wan green:wlan mmc0:: red:power sim:en sim:en2 sim:sel sim:sel2 - “识别 3 秒”闪烁指定 LED - 无数据库、无 Node、无 pip 依赖 +## 持久化配置 / 开机恢复 + +这是 Debian 版本的一键脚本:安装后会创建 systemd 服务并开机启动: + +```text +led-manager.service +``` + +面板里每个 LED 卡片都有: + +```text +保存持久化 +取消持久化 +``` + +点击“保存持久化”后,当前 LED 的 `trigger`、`brightness`、`delay_on`、`delay_off` 会保存到: + +```text +/etc/led-manager/config.json +``` + +系统重启后,`led-manager.service` 会自动启动,并在启动时读取该配置恢复 LED 状态。 + ## 更新已有安装 ```bash diff --git a/install.sh b/install.sh index 985b408..eaaa27f 100755 --- a/install.sh +++ b/install.sh @@ -3,6 +3,8 @@ set -eu APP_NAME="led-manager" APP_DIR="/opt/led-manager" +CONFIG_DIR="/etc/led-manager" +CONFIG_FILE="$CONFIG_DIR/config.json" PORT="${PORT:-8088}" HOST="${HOST:-0.0.0.0}" RAW_BASE="${RAW_BASE:-https://gitea.chickliu.fun/Hermes/led-manager-panel/raw/branch/main}" @@ -32,60 +34,59 @@ download() { 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 + if has_cmd apt-get; then apt-get update - apt-get install -y python3 + apt-get install -y python3 ca-certificates else - echo "未发现 opkg/apt-get,请先手动安装 python3" >&2 + echo "Debian 系统请先安装 python3:apt update && apt install -y 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" + if ! has_cmd systemctl; then + echo "未发现 systemctl,已安装文件但不能配置开机启动。手动启动:" + echo "python3 $APP_DIR/led_panel.py --host $HOST --port $PORT --config $CONFIG_FILE" + exit 0 + fi + + echo "安装 systemd 服务:$APP_NAME" 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 + # Patch host/port/config in service if env overrides were provided. + sed -i "s#--host [^ ]* --port [0-9]* --config [^ ]*#--host $HOST --port $PORT --config $CONFIG_FILE#" "/etc/systemd/system/$APP_NAME.service" || true systemctl daemon-reload - systemctl enable --now "$APP_NAME" + systemctl enable "$APP_NAME" systemctl restart "$APP_NAME" systemctl status "$APP_NAME" --no-pager || true } need_root install_python_if_possible -mkdir -p "$APP_DIR" +mkdir -p "$APP_DIR" "$CONFIG_DIR" + +# Preserve existing persistent config across reinstall/update. +if [ ! -f "$CONFIG_FILE" ]; then + cat > "$CONFIG_FILE" <<'JSON' +{ + "version": 1, + "leds": {} +} +JSON +fi +chmod 755 "$CONFIG_DIR" || true +chmod 644 "$CONFIG_FILE" || true + 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 +# Debian-focused installer: always prefer systemd. OpenWrt is intentionally not handled here. +install_systemd_service ipaddr="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" echo -echo "安装完成。" +echo "安装/更新完成。" +echo "服务:systemctl status $APP_NAME" +echo "配置:$CONFIG_FILE" echo "访问地址: http://${ipaddr:-设备IP}:$PORT/" +echo "面板里点『保存持久化』后,重启系统会由 systemd 启动本项目并自动读取配置恢复 LED。" echo "如需修改端口:PORT=8090 sh install.sh" diff --git a/led-manager.service b/led-manager.service index c3d88a1..96d89be 100644 --- a/led-manager.service +++ b/led-manager.service @@ -1,12 +1,15 @@ [Unit] Description=Linux LED Manager Web Panel -After=network.target +Documentation=https://gitea.chickliu.fun/Hermes/led-manager-panel +After=local-fs.target network-online.target +Wants=network-online.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 +ExecStartPre=/bin/mkdir -p /etc/led-manager +ExecStart=/usr/bin/python3 /opt/led-manager/led_panel.py --host 0.0.0.0 --port 8088 --config /etc/led-manager/config.json Restart=on-failure RestartSec=3 diff --git a/led_panel.py b/led_panel.py index 508495c..ed43008 100755 --- a/led_panel.py +++ b/led_panel.py @@ -20,6 +20,7 @@ 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")) NAME_RE = re.compile(r"^[A-Za-z0-9_.:+@=-]+$") CSS = r""" @@ -65,6 +66,9 @@ async function setBrightness(name, value){ try{ const r=await api('/api/brightne 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);} } +async function saveLed(name){ try{ await api('/api/save',{name}); toast('已保存 '+name+',重启后自动恢复'); setTimeout(load,300);}catch(e){toast(e.message,true);} } +async function deleteSaved(name){ try{ await api('/api/delete-saved',{name}); toast('已取消持久化 '+name); setTimeout(load,300);}catch(e){toast(e.message,true);} } +async function applySaved(){ try{ const r=await api('/api/apply-saved',{}); toast('已应用持久化配置:'+(r.applied||0)+' 个'); setTimeout(load,300);}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 attr(s){ return esc(s); } @@ -75,10 +79,10 @@ function card(led){ const triggerSelect = triggers.length ? `` : '无 trigger 文件'; const delay = (led.delay_on !== null || led.delay_off !== null) ? `
timer:
` : ''; return `
-
${esc(led.name)} ${led.writable?'可写':'只读/权限不足'}
+
${esc(led.name)} ${led.writable?'可写':'只读/权限不足'} ${led.saved?'已持久化':''}
brightness: ${led.brightness} / ${led.max_brightness} (${pct}%)
trigger: ${esc(led.active_trigger || '-')}
path: ${esc(led.path)}
-
+
trigger${triggerSelect}
${delay}
`; } @@ -161,8 +165,97 @@ def parse_triggers(raw: Optional[str]) -> Tuple[List[Dict[str, object]], Optiona 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 {} + return {safe_name(str(k)): v for k, v in leds.items() if isinstance(v, dict)} + 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") + payload = {"version": 1, "leds": config} + 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 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): @@ -194,6 +287,7 @@ def list_leds() -> List[Dict[str, object]]: "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 @@ -223,7 +317,7 @@ class Handler(BaseHTTPRequestHandler): 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 秒自动刷新。
""" + body = f"""LED Manager

LED Manager Panel

管理 {html.escape(str(LED_ROOT))} 下的 LED;配置保存到 {html.escape(str(CONFIG_PATH))};当前 - 个。页面每 5 秒自动刷新。
""" data = body.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") @@ -239,8 +333,11 @@ class Handler(BaseHTTPRequestHandler): parsed = urllib.parse.urlparse(self.path) try: data = self.read_body() - name = safe_name(str(data.get("name", ""))) - p = led_path(name) + name = None + p = None + if parsed.path not in ("/api/apply-saved",): + 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)) @@ -269,6 +366,20 @@ class Handler(BaseHTTPRequestHandler): 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/identify": old_trigger = read_text(p / "trigger") old_brightness = read_text(p / "brightness") @@ -302,15 +413,22 @@ class Handler(BaseHTTPRequestHandler): def main(): - global LED_ROOT + global LED_ROOT, CONFIG_PATH 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("--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) + 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}", flush=True) + print(f"LED Manager Panel listening on http://{args.host}:{args.port} ; LED_ROOT={LED_ROOT}; CONFIG_PATH={CONFIG_PATH}", flush=True) httpd.serve_forever() diff --git a/test_led_panel.py b/test_led_panel.py index 58cf251..228586b 100644 --- a/test_led_panel.py +++ b/test_led_panel.py @@ -29,7 +29,9 @@ def main(): (d / 'trigger').write_text('[none] timer') (d / 'delay_on').write_text('500') (d / 'delay_off').write_text('500') + config_path = root / 'config.json' led_panel.LED_ROOT = root + led_panel.CONFIG_PATH = config_path httpd = ThreadingHTTPServer(('127.0.0.1', 0), Handler) port = httpd.server_address[1] t = threading.Thread(target=httpd.serve_forever, daemon=True) @@ -44,7 +46,19 @@ def main(): assert (d / 'trigger').read_text() == 'timer' delay = req(port, '/api/delay', {'name':'blue:wan','field':'delay_on','value':123}) assert delay['ok'] and (d / 'delay_on').read_text() == '123' - print('OK: API write regression passed') + saved = req(port, '/api/save', {'name':'blue:wan'}) + assert saved['ok'] and config_path.exists() + cfg = json.loads(config_path.read_text()) + assert cfg['leds']['blue:wan']['delay_on'] == 123 + # Simulate reboot/default reset, then apply persisted config. + (d / 'brightness').write_text('0') + (d / 'trigger').write_text('[none] timer') + (d / 'delay_on').write_text('500') + applied = req(port, '/api/apply-saved', {}) + assert applied['ok'] and applied['applied'] == 1 + assert (d / 'brightness').read_text() == '1' + assert (d / 'delay_on').read_text() == '123' + print('OK: API write + persistence regression passed') finally: httpd.shutdown()