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