From 9ff66372d33c09300ae506168f14603ed038c0dd Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 15 May 2026 19:01:37 +0800 Subject: [PATCH] feat: focus panel on rgb leds and speed boot --- README.md | 24 +++++++++++++++++++++++- install.sh | 12 +++++++++--- led-manager.service | 3 +-- led_panel.py | 37 +++++++++++++++++++++++++++++++++---- test_led_panel.py | 26 +++++++++++++++++++++++--- 5 files changed, 89 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c228834..a3714e6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ blue:wan green:wlan mmc0:: red:power sim:en sim:en2 sim:sel sim:sel2 ## 功能 -- 列出 `/sys/class/leds/` 下所有 LED +- 默认只列出实际可控的红 / 绿 / 蓝 LED(`red:*`、`green:*`、`blue:*`),隐藏 SIM/mmc 等不可控条目 - 查看 brightness / max_brightness / 当前 trigger - 打开 / 关闭单个 LED - 页面顶部一键关闭全部 LED @@ -49,6 +49,28 @@ led-manager.service 系统重启后,`led-manager.service` 会自动启动,并在启动时读取该配置恢复 LED 状态。 +为加快开机启动,systemd 服务不再等待 `network-online.target`,只依赖本地文件系统就启动;内网地址可用后浏览器再访问即可。 + +## LED 过滤 + +默认只显示/控制红绿蓝三类实际可控灯: + +```text +red,green,blue +``` + +如果以后要临时显示全部 `/sys/class/leds` 条目,可以这样重装/更新: + +```bash +COLORS= sh -c "$(curl -fsSL https://gitea.chickliu.fun/Hermes/led-manager-panel/raw/branch/main/install.sh)" +``` + +也可以指定颜色前缀,例如只显示红蓝: + +```bash +COLORS=red,blue sh -c "$(curl -fsSL https://gitea.chickliu.fun/Hermes/led-manager-panel/raw/branch/main/install.sh)" +``` + ## 更新已有安装 ```bash diff --git a/install.sh b/install.sh index eaaa27f..a678ef7 100755 --- a/install.sh +++ b/install.sh @@ -7,6 +7,7 @@ CONFIG_DIR="/etc/led-manager" CONFIG_FILE="$CONFIG_DIR/config.json" PORT="${PORT:-8088}" HOST="${HOST:-0.0.0.0}" +COLORS="${COLORS:-red,green,blue}" RAW_BASE="${RAW_BASE:-https://gitea.chickliu.fun/Hermes/led-manager-panel/raw/branch/main}" need_root() { @@ -52,12 +53,15 @@ install_systemd_service() { echo "安装 systemd 服务:$APP_NAME" download "$RAW_BASE/led-manager.service" "/etc/systemd/system/$APP_NAME.service" - # 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 + # Patch host/port/config/colors in service if env overrides were provided. + sed -i "s#--host [^ ]* --port [0-9]* --config [^ ]*#--host $HOST --port $PORT --config $CONFIG_FILE --colors $COLORS#" "/etc/systemd/system/$APP_NAME.service" || true + if ! grep -q -- '--colors' "/etc/systemd/system/$APP_NAME.service"; then + sed -i "s#\(ExecStart=.*\)#\1 --colors $COLORS#" "/etc/systemd/system/$APP_NAME.service" || true + fi systemctl daemon-reload systemctl enable "$APP_NAME" systemctl restart "$APP_NAME" - systemctl status "$APP_NAME" --no-pager || true + systemctl is-active "$APP_NAME" >/dev/null 2>&1 && echo "服务已启动:$APP_NAME" || systemctl status "$APP_NAME" --no-pager || true } need_root @@ -89,4 +93,6 @@ echo "服务:systemctl status $APP_NAME" echo "配置:$CONFIG_FILE" echo "访问地址: http://${ipaddr:-设备IP}:$PORT/" echo "面板里点『保存持久化』后,重启系统会由 systemd 启动本项目并自动读取配置恢复 LED。" +echo "当前只显示/控制颜色:$COLORS" echo "如需修改端口:PORT=8090 sh install.sh" +echo "如需显示全部 LED:COLORS= sh install.sh" diff --git a/led-manager.service b/led-manager.service index 96d89be..a1f597b 100644 --- a/led-manager.service +++ b/led-manager.service @@ -1,8 +1,7 @@ [Unit] Description=Linux LED Manager Web Panel Documentation=https://gitea.chickliu.fun/Hermes/led-manager-panel -After=local-fs.target network-online.target -Wants=network-online.target +After=local-fs.target [Service] Type=simple diff --git a/led_panel.py b/led_panel.py index ffd3b97..8353ae0 100755 --- a/led_panel.py +++ b/led_panel.py @@ -21,6 +21,9 @@ 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""" @@ -102,8 +105,22 @@ def safe_name(name: str) -> str: 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. @@ -177,7 +194,14 @@ def load_config() -> Dict[str, Dict[str, object]]: 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)} + 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 {} @@ -185,7 +209,8 @@ def load_config() -> Dict[str, Dict[str, object]]: 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} + 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: @@ -289,6 +314,8 @@ def list_leds() -> List[Dict[str, object]]: 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") @@ -443,22 +470,24 @@ class Handler(BaseHTTPRequestHandler): def main(): - global LED_ROOT, CONFIG_PATH + 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}", flush=True) + 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() diff --git a/test_led_panel.py b/test_led_panel.py index 926b7e6..5f51d25 100644 --- a/test_led_panel.py +++ b/test_led_panel.py @@ -2,6 +2,7 @@ import json import tempfile import threading +import urllib.error import urllib.request from pathlib import Path from led_panel import Handler, ThreadingHTTPServer @@ -34,6 +35,16 @@ def main(): (d2 / 'brightness').write_text('1') (d2 / 'max_brightness').write_text('1') (d2 / 'trigger').write_text('[none] timer') + d3 = root / 'green:wlan' + d3.mkdir() + (d3 / 'brightness').write_text('1') + (d3 / 'max_brightness').write_text('1') + (d3 / 'trigger').write_text('[none] timer') + hidden = root / 'sim:en' + hidden.mkdir() + (hidden / 'brightness').write_text('1') + (hidden / 'max_brightness').write_text('1') + (hidden / 'trigger').write_text('[none] timer') config_path = root / 'config.json' led_panel.LED_ROOT = root led_panel.CONFIG_PATH = config_path @@ -43,7 +54,13 @@ def main(): t.start() try: leds = req(port, '/api/leds') - assert leds['ok'] and leds['leds'][0]['name'] == 'blue:wan' + names = [led['name'] for led in leds['leds']] + assert leds['ok'] and names == ['blue:wan', 'green:wlan', 'red:power'] + try: + req(port, '/api/brightness', {'name':'sim:en','value':0}) + raise AssertionError('hidden LED unexpectedly writable') + except urllib.error.HTTPError as e: + assert e.code == 400 b = req(port, '/api/brightness', {'name':'blue:wan','value':1}) assert b['ok'] and (d / 'brightness').read_text() == '1' tr = req(port, '/api/trigger', {'name':'blue:wan','trigger':'timer'}) @@ -64,12 +81,15 @@ def main(): assert (d / 'brightness').read_text() == '1' assert (d / 'delay_on').read_text() == '123' all_off = req(port, '/api/all-off', {}) - assert all_off['ok'] and all_off['changed'] == 2 + assert all_off['ok'] and all_off['changed'] == 3 assert (d / 'brightness').read_text() == '0' assert (d2 / 'brightness').read_text() == '0' + assert (d3 / 'brightness').read_text() == '0' + assert (hidden / 'brightness').read_text() == '1' assert '[none]' in (d / 'trigger').read_text() or (d / 'trigger').read_text() == 'none' assert '[none]' in (d2 / 'trigger').read_text() or (d2 / 'trigger').read_text() == 'none' - print('OK: API write + persistence + all-off regression passed') + assert '[none]' in (d3 / 'trigger').read_text() or (d3 / 'trigger').read_text() == 'none' + print('OK: RGB filter + API write + persistence + all-off regression passed') finally: httpd.shutdown()