From 0f643be7195a7d4064f1b9e02ed8fc947474b0b1 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Wed, 23 Sep 2026 22:04:11 -0400 Subject: [PATCH] van-leds: drive the R5C power/LAN/WAN/WiFi LEDs (ENABLE_LEDS, LED_* vars, post-check probe) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9 --- README.md | 4 + deploy.sh | 46 +++++++++- hosts/van.conf | 6 ++ hosts/vanlink.conf.example | 7 ++ hosts/wan.conf | 6 ++ leds/config.json | 1 + leds/van-leds | 180 +++++++++++++++++++++++++++++++++++++ leds/van-leds.service | 16 ++++ tests/check-templates.sh | 118 ++++++++++++++++++++++++ 9 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 leds/config.json create mode 100755 leds/van-leds create mode 100644 leds/van-leds.service diff --git a/README.md b/README.md index b595438..cbee2ec 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,10 @@ see that file's own comment). After editing the dashboard, copy both places and - **Tuning**: edit `/etc/van-nvme-watch/config.json` (`grace_period`, `cooldown`, device paths), then `systemctl restart van-nvme-watch`. - Status: `systemctl status van-nvme-watch`, `journalctl -u van-nvme-watch -f`, or `cat /run/van-nvme-watch/state.json`. +### Front LEDs (`van-leds`, NanoPi R5C) + +Drives the R5C's four LEDs from `leds/van-leds` (config `/etc/van-leds/config.json`, rendered from the host file). **Power** is on; **LAN** blinks with activity on `LAN_IFACE`; **WiFi** blinks with activity on `WIFI_5G_IFACE` (the 5GHz AP radio); **WAN** follows the active uplink from van-failover (`/run/van-failover/state.json`): activity on that device while it is healthy, a heartbeat when an uplink has link but no healthy internet (or failover state is unavailable but the wired uplink has carrier), off when there is no uplink. It is re-evaluated every 5 s. Enabled per host with `ENABLE_LEDS=1` and the sysfs names `LED_POWER LED_LAN LED_WAN LED_WLAN` (empty = leave that LED alone; on the R5C `red:power green:lan green:wan green:wlan`); the Pi 5 has `ENABLE_LEDS=0`. Needs the Armbian `nanopi-r5c-leds` overlay in `/boot/armbianEnv.txt` (`--post-check` warns when an LED is missing or a static LED has the wrong trigger). + ### Battery monitor (`van-battery`) - Watches mains vs battery via `/sys/class/power_supply/AC0/online` (0 = on battery) and charge via `BAT0/capacity`. Both resolve by `type` (Mains/Battery) if those names ever differ. - **Only while on battery**, it sends escalating **Pushover** alerts at **25 / 20 / 15 %**, and at **10 %** sends a final alert and runs `systemctl poweroff` (after `shutdown_grace`, default 8 s, so the alert flushes first). diff --git a/deploy.sh b/deploy.sh index b46ffff..645710e 100755 --- a/deploy.sh +++ b/deploy.sh @@ -27,6 +27,7 @@ # VANLINK_SYSNET= # test override: sysfs net dir used by --post-check and --apply-preflight (default /sys/class/net) # VANLINK_HOSTNAME= # test override: host name used by --apply-preflight (default `hostname -s`; ignored when run as root) # VANLINK_FAILOVER_STATE= # test override: van-failover state file read by --post-check (default /run/van-failover/state.json) +# VANLINK_SYSLEDS= # test override: sysfs LED dir read by --post-check (default /sys/class/leds; ignored when run as root) # VANLINK_APPLY_STATUS= # test override: apply-network status file read by --post-check (default /var/lib/vanlink/apply-network.status) # VANLINK_WAN_NETPLAN= # test override: installed WAN netplan compared by nm_wan_state_netplan (default /etc/netplan/50-van-wan.yaml) # VANLINK_NM_DIR= # test override: where existing NM *.nmconnection files are looked up (default /etc/NetworkManager/system-connections; @@ -51,13 +52,14 @@ REQUIRED_VARS=( GATEWAY_IP GATEWAY_PREFIX GATEWAY_NETWORK DHCP_RANGE_START DHCP_RANGE_END DNS_RESOLVERS MODEM_USB_VENDOR AP_SSID_5G AP_SSID_2G AP_5G_RADIO ENABLE_COCKPIT ENABLE_LI3 ENABLE_NVME_WATCH ENABLE_HA ENABLE_FRIGATE - ENABLE_NM_WAN + ENABLE_NM_WAN ENABLE_LEDS ) # May be empty or absent; NVME_* are required only when ENABLE_NVME_WATCH=1. OPTIONAL_VARS=( WIFI_WAN_IFACE CELLULAR_CONN_NAME CELLULAR_APN ONBOARD_LAN_IFACE NVME_DEVICE NVME_FS_DEVICE USB_BACKUP_ROOT_PART USB_BACKUP_MOUNT SD_BACKUP_UUID + LED_POWER LED_LAN LED_WAN LED_WLAN ) # Computed in load_conf; usable as @TOKEN@ like the rest. DERIVED_VARS=(ONBOARD_LAN_UNMANAGED CELLULAR_UUID) @@ -77,7 +79,7 @@ load_conf() { # load_conf — source and validate a host config [ -n "${!v:-}" ] || missing+=("$v") done [ ${#missing[@]} -eq 0 ] || die "$f is missing required variable(s): ${missing[*]}" - for flag in ENABLE_COCKPIT ENABLE_LI3 ENABLE_NVME_WATCH ENABLE_HA ENABLE_FRIGATE ENABLE_NM_WAN; do + for flag in ENABLE_COCKPIT ENABLE_LI3 ENABLE_NVME_WATCH ENABLE_HA ENABLE_FRIGATE ENABLE_NM_WAN ENABLE_LEDS; do case ${!flag} in 0|1) ;; *) die "$f: $flag must be 0 or 1 (got '${!flag}')" ;; @@ -222,6 +224,9 @@ fi if [ "$ENABLE_LI3" = 1 ]; then ENABLE_UNITS+=(van-li3-battery) fi +if [ "$ENABLE_LEDS" = 1 ]; then + ENABLE_UNITS+=(van-leds) +fi if [ "$ENABLE_NM_WAN" = 1 ]; then ENABLE_UNITS+=(NetworkManager ModemManager) fi @@ -337,6 +342,33 @@ post_check() { # read-only live checks; every probe is guarded, warns instead o refused) warn "post-deploy: van-router-mqtt is refused by the MQTT broker — add credentials to /etc/van-router-mqtt/credentials.json (see the service journal)" ;; connected) echo " ok van-router-mqtt: MQTT connection ok" ;; esac + if [ "$ENABLE_LEDS" = 1 ]; then + # g3. front LEDs (van-leds): each configured LED exists; the static ones carry the expected trigger. + # The WAN LED follows the uplink, so its state is only reported, never warned about. + local sysleds=/sys/class/leds lrole lname ltrig lwant ldev + [ "$(id -u)" = 0 ] || sysleds=${VANLINK_SYSLEDS:-$sysleds} + for lrole in "power|${LED_POWER:-}|default-on|" "lan|${LED_LAN:-}|netdev|$LAN_IFACE" "wlan|${LED_WLAN:-}|netdev|$WIFI_5G_IFACE" "wan|${LED_WAN:-}||"; do + IFS='|' read -r lrole lname lwant ldev <<<"$lrole" # LED names contain ':' so '|' separates + [ -n "$lname" ] || continue + if [ ! -d "$sysleds/$lname" ]; then + warn "post-deploy: LED $lname not present — is the nanopi-r5c-leds overlay enabled in /boot/armbianEnv.txt?" + continue + fi + ltrig=$(sed -n 's/.*\[\(.*\)\].*/\1/p' "$sysleds/$lname/trigger" 2>/dev/null) || ltrig= + if [ "$lrole" = wan ]; then + if [ "$ltrig" = netdev ]; then echo " ok LED $lname (wan): netdev $(cat "$sysleds/$lname/device_name" 2>/dev/null || true)" + else echo " ok LED $lname (wan): ${ltrig:-unknown}"; fi + continue + fi + if [ "$ltrig" != "$lwant" ]; then + warn "post-deploy: LED $lname ($lrole) trigger is '${ltrig:-unknown}', expected $lwant" + elif [ -n "$ldev" ] && [ "$(cat "$sysleds/$lname/device_name" 2>/dev/null || true)" != "$ldev" ]; then + warn "post-deploy: LED $lname ($lrole) device_name is '$(cat "$sysleds/$lname/device_name" 2>/dev/null || true)', expected $ldev" + else + echo " ok LED $lname ($lrole): $ltrig${ldev:+ $ldev}" + fi + done + fi if [ "$ENABLE_NM_WAN" = 1 ]; then local sysnet=${VANLINK_SYSNET:-/sys/class/net} nmst fst as_f as_l # h. NetworkManager must leave the AP/LAN radios and ports alone @@ -1116,6 +1148,13 @@ if [ "$ENABLE_NVME_WATCH" = 1 ]; then install -D -m0644 power/van-nvme-watch.service /etc/systemd/system/van-nvme-watch.service fi +if [ "$ENABLE_LEDS" = 1 ]; then + echo "== front LEDs ==" + install -D -m0755 leds/van-leds /usr/local/sbin/van-leds + install_rendered leds/config.json /etc/van-leds/config.json + install -D -m0644 leds/van-leds.service /etc/systemd/system/van-leds.service +fi + echo "== home assistant ==" # Native HA (Podman Quadlet, replaced the ha_van VM). daemon-reload below # regenerates homeassistant.service; started (not restarted) at the end so a @@ -1222,6 +1261,9 @@ if [ "$ENABLE_LI3" = 1 ]; then systemctl restart van-li3-battery fi systemctl restart van-router-mqtt +if [ "$ENABLE_LEDS" = 1 ]; then + systemctl restart van-leds +fi # Pick up unmanaged-devices changes so NM releases/keeps the right interfaces. nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true # restart in dependency order: bridge + members first, then hostapd enslaves the diff --git a/hosts/van.conf b/hosts/van.conf index a7e9bc3..03580d5 100644 --- a/hosts/van.conf +++ b/hosts/van.conf @@ -34,6 +34,12 @@ ENABLE_NVME_WATCH=0 # no NVMe on this board ENABLE_HA=1 ENABLE_FRIGATE=1 ENABLE_NM_WAN=1 +ENABLE_LEDS=1 # R5C front LEDs (van-leds) + +LED_POWER=red:power +LED_LAN=green:lan +LED_WAN=green:wan +LED_WLAN=green:wlan CELLULAR_CONN_NAME=Koodo CELLULAR_APN=sp.koodo.com diff --git a/hosts/vanlink.conf.example b/hosts/vanlink.conf.example index 1b0fc0f..f8abf99 100644 --- a/hosts/vanlink.conf.example +++ b/hosts/vanlink.conf.example @@ -48,6 +48,13 @@ ENABLE_NVME_WATCH=0 # NVMe timeout watchdog (needs an NVMe drive) ENABLE_HA=0 # Home Assistant + ESPHome (podman quadlets) ENABLE_FRIGATE=0 # Frigate NVR (podman quadlet) ENABLE_NM_WAN=1 # NetworkManager owns the WANs (netplan, cellular profile) via --apply-network +ENABLE_LEDS=0 # van-leds: drive the NanoPi R5C's power/LAN/WAN/WiFi LEDs (needs the nanopi-r5c-leds overlay) + +# --- LEDs (used with ENABLE_LEDS=1; sysfs names under /sys/class/leds, empty = leave that LED alone) +LED_POWER= # e.g. red:power (trigger default-on) +LED_LAN= # e.g. green:lan (activity on LAN_IFACE) +LED_WAN= # e.g. green:wan (active uplink from van-failover; heartbeat = link but no healthy internet) +LED_WLAN= # e.g. green:wlan (activity on WIFI_5G_IFACE, the 5GHz AP radio) CELLULAR_CONN_NAME=Koodo # NM name of the cellular connection (also used by van-failover); needed with ENABLE_NM_WAN=1 when a cellular WAN is wanted CELLULAR_APN=sp.koodo.com # cellular APN; needed with ENABLE_NM_WAN=1 when a cellular WAN is wanted (no APN = no cellular profile) diff --git a/hosts/wan.conf b/hosts/wan.conf index 295f975..5ae65f4 100644 --- a/hosts/wan.conf +++ b/hosts/wan.conf @@ -34,6 +34,12 @@ ENABLE_NVME_WATCH=0 # boots from mmcblk0; no NVMe fitted ENABLE_HA=1 ENABLE_FRIGATE=0 ENABLE_NM_WAN=1 +ENABLE_LEDS=0 # Pi 5: its LEDs are left alone + +LED_POWER= +LED_LAN= +LED_WAN= +LED_WLAN= CELLULAR_CONN_NAME=Koodo CELLULAR_APN=sp.koodo.com diff --git a/leds/config.json b/leds/config.json new file mode 100644 index 0000000..6f77106 --- /dev/null +++ b/leds/config.json @@ -0,0 +1 @@ +{"led_power": "@LED_POWER@", "led_lan": "@LED_LAN@", "led_wan": "@LED_WAN@", "led_wlan": "@LED_WLAN@", "lan_iface": "@LAN_IFACE@", "wlan_iface": "@WIFI_5G_IFACE@", "uplink_iface": "@STARLINK_IFACE@", "poll_interval_s": 5} diff --git a/leds/van-leds b/leds/van-leds new file mode 100755 index 0000000..d50281a --- /dev/null +++ b/leds/van-leds @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""van-leds — drive the NanoPi R5C's four front LEDs (power / LAN / WAN / WiFi). + + power default-on + LAN netdev on the LAN port (link + tx + rx) + WiFi netdev on the 5GHz AP radio (link + tx + rx) + WAN follows van-failover (/run/van-failover/state.json), re-evaluated each poll: + active WAN healthy -> netdev on that device + active WAN unhealthy, or no failover info / no active WAN but the wired + uplink has carrier -> heartbeat (link, no healthy internet) + nothing -> trigger none, off + Missing/invalid state file: fall back to the wired uplink (netdev if carrier). + +An LED whose name is empty in the config, or absent under sysfs, is skipped (logged +once at start). Settings are re-asserted every poll but only written when they differ +from what sysfs reads back. SIGTERM/SIGINT exit cleanly and leave the LEDs as they are. +Stdlib only; config is /etc/van-leds/config.json (argv[1] overrides). Test-only config +keys: "sysfs_leds", "sysfs_net", "failover_state". +""" + +import json +import os +import signal +import sys +import threading + +DEFAULTS = { + "led_power": "", "led_lan": "", "led_wan": "", "led_wlan": "", + "lan_iface": "", "wlan_iface": "", "uplink_iface": "", + "poll_interval_s": 5, + "sysfs_leds": "/sys/class/leds", + "sysfs_net": "/sys/class/net", + "failover_state": "/run/van-failover/state.json", +} +LED_KEYS = (("power", "led_power"), ("lan", "led_lan"), ("wan", "led_wan"), ("wlan", "led_wlan")) + +stop = threading.Event() + + +def log(msg): + print(msg, flush=True) + + +def load_config(path): + cfg = dict(DEFAULTS) + with open(path) as f: + cfg.update(json.load(f)) + return cfg + + +def read_attr(path): + """Stripped file content; for a sysfs trigger list ("none [netdev] ...") the bracketed item.""" + try: + with open(path) as f: + s = f.read().strip() + except OSError: + return None + if "[" in s and "]" in s: + s = s[s.index("[") + 1:s.index("]")] + return s + + +def write_attr(path, value): + with open(path, "w") as f: + f.write(str(value)) + + +def desired_wan(state, uplink_carrier, uplink=None): + """-> ("netdev", device) | ("heartbeat", None) | ("none", None).""" + valid = isinstance(state, dict) and isinstance(state.get("wans"), list) and "active_device" in state + if not valid: # no failover info: judge by the wired uplink alone + if not uplink_carrier: + return ("none", None) + return ("netdev", uplink) if uplink else ("heartbeat", None) + active = state.get("active_device") + if active: + for w in state["wans"]: + if isinstance(w, dict) and w.get("device") == active and w.get("healthy") is True: + return ("netdev", active) + return ("heartbeat", None) + return ("heartbeat", None) if uplink_carrier else ("none", None) + + +def apply_led(led_dir, trigger, device=None): + """Make the LED match; write only what differs. Returns the list of files written.""" + writes = [] + + def want(name, value): + p = os.path.join(led_dir, name) + if read_attr(p) != str(value): + write_attr(p, value) + writes.append(p) + + want("trigger", trigger) + if trigger == "netdev": + want("device_name", device) + for a in ("link", "tx", "rx"): + want(a, 1) + elif trigger == "none": + want("brightness", 0) + return writes + + +def load_state(path): + try: + with open(path) as f: + return json.load(f) + except (OSError, ValueError): + return None + + +def carrier(cfg, iface): + return bool(iface) and read_attr(os.path.join(cfg["sysfs_net"], iface, "carrier")) == "1" + + +def wanted(cfg): + """{role: (trigger, device)} for every role (before skipping absent LEDs).""" + wan = desired_wan(load_state(cfg["failover_state"]), carrier(cfg, cfg["uplink_iface"]), cfg["uplink_iface"] or None) + return { + "power": ("default-on", None), + "lan": ("netdev", cfg["lan_iface"]), + "wlan": ("netdev", cfg["wlan_iface"]), + "wan": wan, + } + + +def usable_leds(cfg, announce=False): + """{role: led_dir} for the configured LEDs present in sysfs; optionally logs the skipped ones.""" + out = {} + for role, key in LED_KEYS: + name = cfg[key] + if not name: + if announce: + log(f"LED {role}: no name configured, skipped") + continue + d = os.path.join(cfg["sysfs_leds"], name) + if not os.path.isdir(d): + if announce: + log(f"LED {role}: {name} not present under {cfg['sysfs_leds']}, skipped") + continue + out[role] = d + return out + + +def poll_once(cfg): + """One evaluation of every usable LED. Returns the files written.""" + want = wanted(cfg) + writes = [] + for role, d in usable_leds(cfg).items(): + trig, dev = want[role] + try: + w = apply_led(d, trig, dev) + except OSError as e: + log(f"LED {role}: cannot set {trig}: {e}") + continue + if w: + log(f"LED {role}: {trig}" + (f" {dev}" if dev else "")) + writes += w + return writes + + +def main(argv): + path = argv[1] if len(argv) > 1 else "/etc/van-leds/config.json" + cfg = load_config(path) + signal.signal(signal.SIGTERM, lambda *_: stop.set()) + signal.signal(signal.SIGINT, lambda *_: stop.set()) + usable_leds(cfg, announce=True) + log(f"van-leds started ({path})") + while not stop.is_set(): + try: + poll_once(cfg) + except Exception as e: # never die on a transient sysfs/state oddity + log(f"poll failed: {e}") + stop.wait(float(cfg["poll_interval_s"])) + log("van-leds stopping, LEDs left as they are") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/leds/van-leds.service b/leds/van-leds.service new file mode 100644 index 0000000..7b7b484 --- /dev/null +++ b/leds/van-leds.service @@ -0,0 +1,16 @@ +[Unit] +Description=NanoPi R5C front-panel LEDs (power / LAN / WAN / WiFi) +After=van-failover.service +# Restart limit: max 5 restarts per 5 minutes, then stay failed (as the other van services) +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Service] +Type=simple +ExecStartPre=-/sbin/modprobe ledtrig-netdev +ExecStart=/usr/local/sbin/van-leds +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/tests/check-templates.sh b/tests/check-templates.sh index c93ecdc..f562f18 100755 --- a/tests/check-templates.sh +++ b/tests/check-templates.sh @@ -99,6 +99,7 @@ if awk ' need["van-li3-battery"]="ENABLE_LI3" need["van-nvme-watch"]="ENABLE_NVME_WATCH" need["nvme-watch-config"]="ENABLE_NVME_WATCH" + need["van-leds"]="ENABLE_LEDS" need["homeassistant"]="ENABLE_HA" need["esphome"]="ENABLE_HA" need["frigate"]="ENABLE_FRIGATE" @@ -815,4 +816,121 @@ d=$(./deploy.sh --diff hosts/van.conf 2>&1 || true) ! echo "$d" | grep -q 'credentials.json' && ok "21d: --diff never lists credentials.json" || bad "21d: --diff mentions credentials.json" rm -rf "$t21" +# 22. van-leds: R5C front LEDs (power / LAN / WAN / WiFi) +if python3 -c 'import ast,sys; ast.parse(open(sys.argv[1]).read())' leds/van-leds; then ok "22a: van-leds parses"; else bad "22a: van-leds does not parse"; fi +[ -x leds/van-leds ] && ok "22a: van-leds is executable" || bad "22a: van-leds not executable" +t22=$(mktemp -d) +o=$(python3 - "$t22" 2>&1 <<'PY' +import importlib.machinery, importlib.util, io, json, os, sys, contextlib +ldr = importlib.machinery.SourceFileLoader("vl", "leds/van-leds") +spec = importlib.util.spec_from_loader("vl", ldr) +m = importlib.util.module_from_spec(spec) +ldr.exec_module(m) +tmp = sys.argv[1] +bad = 0 +def check(name, cond): + global bad + print(("ok - " if cond else "FAIL - ") + name) + bad |= (not cond) +def st(active, wans): + return {"updated": "x", "active_device": active, "wans": wans} +H = st("eth1", [{"name": "starlink", "device": "eth1", "healthy": True, "active": True}]) +U = st("eth1", [{"name": "starlink", "device": "eth1", "healthy": False, "active": True}]) +N = st(None, [{"name": "starlink", "device": "eth1", "healthy": False}]) +d = m.desired_wan +check("22b: healthy active -> netdev on it", d(H, True) == ("netdev", "eth1")) +check("22b: healthy active, carrier 0 -> still netdev", d(H, False) == ("netdev", "eth1")) +check("22b: unhealthy active -> heartbeat", d(U, True) == ("heartbeat", None)) +check("22b: no active + carrier -> heartbeat", d(N, True) == ("heartbeat", None)) +check("22b: no active + no carrier -> none", d(N, False) == ("none", None)) +for label, bs in (("missing", None), ("invalid", "garbage"), ("no wans", {"x": 1})): + check(f"22b: {label} state + carrier -> netdev on the uplink", d(bs, True, "up0") == ("netdev", "up0")) + check(f"22b: {label} state + no carrier -> none", d(bs, False, "up0") == ("none", None)) + +# fake sysfs +leds, net = os.path.join(tmp, "leds"), os.path.join(tmp, "net") +for n in ("red:power", "green:lan", "green:wan", "green:wlan"): + os.makedirs(os.path.join(leds, n)) + for f, v in (("trigger", "none"), ("brightness", "0"), ("device_name", ""), ("link", "0"), ("tx", "0"), ("rx", "0")): + open(os.path.join(leds, n, f), "w").write(v + "\n") +for i, c in (("lan0", "1"), ("wl0", "1"), ("up0", "1"), ("eth1", "1")): + os.makedirs(os.path.join(net, i)) + open(os.path.join(net, i, "carrier"), "w").write(c + "\n") +sf = os.path.join(tmp, "state.json") +json.dump(H, open(sf, "w")) +cfg = dict(m.DEFAULTS, led_power="red:power", led_lan="green:lan", led_wan="green:wan", led_wlan="green:wlan", + lan_iface="lan0", wlan_iface="wl0", uplink_iface="up0", sysfs_leds=leds, sysfs_net=net, failover_state=sf) +def rd(led, f): return open(os.path.join(leds, led, f)).read().strip() +def poll(c=cfg): + with contextlib.redirect_stdout(io.StringIO()): + return m.poll_once(c) +w = poll() +check("22c: power default-on", rd("red:power", "trigger") == "default-on") +check("22c: lan netdev on lan0, link/tx/rx 1", (rd("green:lan", "trigger"), rd("green:lan", "device_name"), rd("green:lan", "link"), rd("green:lan", "tx"), rd("green:lan", "rx")) == ("netdev", "lan0", "1", "1", "1")) +check("22c: wlan netdev on wl0", (rd("green:wlan", "trigger"), rd("green:wlan", "device_name")) == ("netdev", "wl0")) +check("22c: wan netdev on the active device", (rd("green:wan", "trigger"), rd("green:wan", "device_name")) == ("netdev", "eth1")) +check("22c: first run writes", len(w) > 0) +w = poll() +check("22c: second run makes no writes", w == []) +json.dump(U, open(sf, "w")); w = poll() +check("22c: unhealthy state -> wan heartbeat", rd("green:wan", "trigger") == "heartbeat" and len(w) == 1) +json.dump(N, open(sf, "w")); open(os.path.join(net, "up0", "carrier"), "w").write("0\n"); poll() +check("22c: nothing -> wan none, brightness 0", rd("green:wan", "trigger") == "none" and rd("green:wan", "brightness") == "0") +open(sf, "w").write("{broken"); open(os.path.join(net, "up0", "carrier"), "w").write("1\n"); poll() +check("22c: invalid state falls back to wired uplink", (rd("green:wan", "trigger"), rd("green:wan", "device_name")) == ("netdev", "up0")) +open(os.path.join(leds, "green:lan", "trigger"), "w").write("none\n"); w = poll() +check("22c: a reset trigger is re-asserted", rd("green:lan", "trigger") == "netdev" and any(x.endswith("trigger") for x in w)) +c2 = dict(cfg, led_lan="green:nope", led_wlan="") +try: + w = poll(c2); ok_ = True +except Exception as e: + ok_ = False +check("22c: missing / empty LED names are skipped without raising", ok_) +buf = io.StringIO() +with contextlib.redirect_stdout(buf): + m.usable_leds(c2, announce=True) +check("22c: skipped LEDs are logged once at start", buf.getvalue().count("skipped") == 2) +check("22c: apply_led writes only on difference", m.apply_led(os.path.join(leds, "red:power"), "default-on") == []) +print("STATUS", int(bad)) +PY +) +echo "$o" | grep -v '^STATUS' +echo "$o" | grep -q '^STATUS 0$' || bad "22b/c: van-leds behavioural tests" +# 22d. config rendering +expect van leds/config.json '"lan_iface": "enP1p17s0"' +expect van leds/config.json '"wlan_iface": "wlp1s0"' +expect van leds/config.json '"uplink_iface": "enP2p33s0"' +expect van leds/config.json '"led_power": "red:power"' +expect van leds/config.json '"led_wan": "green:wan"' +expect van leds/config.json '"led_wlan": "green:wlan"' +expect wan leds/config.json '"led_power": ""' +expect wan leds/config.json '"led_lan": ""' +expect wan leds/config.json '"led_wan": ""' +expect wan leds/config.json '"led_wlan": ""' +r van leds/config.json | python3 -c 'import json,sys; json.load(sys.stdin)' && ok "22d: rendered leds/config.json is valid JSON" || bad "22d: leds/config.json invalid JSON" +# 22e. post_check LED probe against a fake sysfs dir +mkl() { # mkl [device_name] + mkdir -p "$1/$2"; echo "none [$3] heartbeat" > "$1/$2/trigger"; echo "${4:-}" > "$1/$2/device_name"; } +L="$t22/sysleds"; rm -rf "$L" +mkl "$L" red:power default-on; mkl "$L" green:lan netdev enP1p17s0; mkl "$L" green:wlan netdev wlp1s0; mkl "$L" green:wan heartbeat +pl() { VANLINK_SYSLEDS="$1" POSTCHECK_WAIT=0 ./deploy.sh --post-check "${2:-hosts/van.conf}" 2>&1 || true; } +o=$(pl "$L") +{ echo "$o" | grep -qF 'ok LED red:power (power): default-on' && echo "$o" | grep -qF 'ok LED green:lan (lan): netdev enP1p17s0' \ + && echo "$o" | grep -qF 'ok LED green:wlan (wlan): netdev wlp1s0' && echo "$o" | grep -qF 'ok LED green:wan (wan): heartbeat' \ + && ! echo "$o" | grep -qE -- '-> post-deploy: LED'; } && ok "22e: correct LEDs -> ok lines, no warn" || { bad "22e: correct LEDs"; echo "$o" | grep LED | sed 's/^/ /'; } +mkl "$L" green:wan netdev enP2p33s0 +o=$(pl "$L"); echo "$o" | grep -qF 'ok LED green:wan (wan): netdev enP2p33s0' && ok "22e: wan LED netdev named" || bad "22e: wan LED netdev not reported" +rm -rf "$L/green:wlan"; o=$(pl "$L") +echo "$o" | grep -qF -- '-> post-deploy: LED green:wlan not present — is the nanopi-r5c-leds overlay enabled in /boot/armbianEnv.txt?' && ok "22e: missing LED -> warn" || bad "22e: missing LED no warn" +mkl "$L" green:wlan netdev wlp1s0; mkl "$L" green:lan netdev wrongdev +o=$(pl "$L"); echo "$o" | grep -qE -- '-> post-deploy: LED green:lan .*wrongdev' && ok "22e: wrong lan device -> warn" || bad "22e: wrong lan device no warn" +mkl "$L" green:lan netdev enP1p17s0; mkl "$L" red:power heartbeat +o=$(pl "$L"); echo "$o" | grep -qE -- '-> post-deploy: LED red:power .*expected default-on' && ok "22e: wrong power trigger -> warn" || bad "22e: wrong power trigger no warn" +o=$(pl "$L" hosts/wan.conf); { ! echo "$o" | grep -q 'LED'; } && ok "22e: ENABLE_LEDS=0 host -> no LED output" || bad "22e: wan.conf produced LED output" +# 22f. flags; nothing LED-related outside the ENABLE_LEDS guard +(. hosts/van.conf; [ "$ENABLE_LEDS" = 1 ]) && ok "22f: van.conf ENABLE_LEDS=1" || bad "22f: van.conf ENABLE_LEDS" +(. hosts/wan.conf; [ "$ENABLE_LEDS" = 0 ] && [ -z "$LED_POWER$LED_LAN$LED_WAN$LED_WLAN" ]) && ok "22f: wan.conf ENABLE_LEDS=0, no LED names" || bad "22f: wan.conf LEDs" +grep -qF 'need["van-leds"]="ENABLE_LEDS"' tests/check-templates.sh && ok "22f: check 6 gates van-leds" || bad "22f: check 6 lacks van-leds" +rm -rf "$t22" + exit "$fail"