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"