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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user