van-leds: drive the R5C power/LAN/WAN/WiFi LEDs (ENABLE_LEDS, LED_* vars, post-check probe)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9
This commit is contained in:
2026-09-23 22:04:11 -04:00
co-authored by Claude Sonnet 5
parent e2036c295d
commit 0f643be719
9 changed files with 382 additions and 2 deletions
+4
View File
@@ -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).
+44 -2
View File
@@ -27,6 +27,7 @@
# VANLINK_SYSNET=<dir> # test override: sysfs net dir used by --post-check and --apply-preflight (default /sys/class/net)
# VANLINK_HOSTNAME=<name> # test override: host name used by --apply-preflight (default `hostname -s`; ignored when run as root)
# VANLINK_FAILOVER_STATE=<file> # test override: van-failover state file read by --post-check (default /run/van-failover/state.json)
# VANLINK_SYSLEDS=<dir> # test override: sysfs LED dir read by --post-check (default /sys/class/leds; ignored when run as root)
# VANLINK_APPLY_STATUS=<file> # test override: apply-network status file read by --post-check (default /var/lib/vanlink/apply-network.status)
# VANLINK_WAN_NETPLAN=<file> # test override: installed WAN netplan compared by nm_wan_state_netplan (default /etc/netplan/50-van-wan.yaml)
# VANLINK_NM_DIR=<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 <file> — 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
+6
View File
@@ -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
+7
View File
@@ -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)
+6
View File
@@ -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
+1
View File
@@ -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}
Executable
+180
View File
@@ -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))
+16
View File
@@ -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
+118
View File
@@ -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 <dir> <led> <trigger> [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"