Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9
181 lines
5.7 KiB
Python
Executable File
181 lines
5.7 KiB
Python
Executable File
#!/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))
|