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
+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