van-leds fix round 1: realistic sysfs fake, error paths, stale failover state, log-once, interval and empty-iface guards
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9
This commit is contained in:
+53
-9
@@ -23,6 +23,8 @@ import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
DEFAULTS = {
|
||||
"led_power": "", "led_lan": "", "led_wan": "", "led_wlan": "",
|
||||
@@ -31,6 +33,7 @@ DEFAULTS = {
|
||||
"sysfs_leds": "/sys/class/leds",
|
||||
"sysfs_net": "/sys/class/net",
|
||||
"failover_state": "/run/van-failover/state.json",
|
||||
"state_max_age_s": 60, # older failover state is ignored (wired-uplink fallback)
|
||||
}
|
||||
LED_KEYS = (("power", "led_power"), ("lan", "led_lan"), ("wan", "led_wan"), ("wlan", "led_wlan"))
|
||||
|
||||
@@ -41,6 +44,27 @@ def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
_last = {}
|
||||
|
||||
|
||||
def log_once(key, msg):
|
||||
"""Log msg unless it is what this key last logged (a persistent failure is logged once)."""
|
||||
if _last.get(key) != msg:
|
||||
_last[key] = msg
|
||||
log(msg)
|
||||
|
||||
|
||||
def clear_log(key):
|
||||
_last.pop(key, None)
|
||||
|
||||
|
||||
def interval(cfg):
|
||||
try:
|
||||
return max(1.0, float(cfg["poll_interval_s"]))
|
||||
except (TypeError, ValueError):
|
||||
return 5.0
|
||||
|
||||
|
||||
def load_config(path):
|
||||
cfg = dict(DEFAULTS)
|
||||
with open(path) as f:
|
||||
@@ -65,8 +89,19 @@ def write_attr(path, value):
|
||||
f.write(str(value))
|
||||
|
||||
|
||||
def desired_wan(state, uplink_carrier, uplink=None):
|
||||
"""-> ("netdev", device) | ("heartbeat", None) | ("none", None)."""
|
||||
def state_stale(state, now, max_age):
|
||||
"""True when state["updated"] parses and is older than max_age seconds (unparseable: not stale)."""
|
||||
try:
|
||||
t = datetime.strptime(state["updated"], "%Y-%m-%dT%H:%M:%S%z").timestamp()
|
||||
except (TypeError, KeyError, ValueError):
|
||||
return False
|
||||
return now - t > max_age
|
||||
|
||||
|
||||
def desired_wan(state, uplink_carrier, uplink=None, now=None, max_age=60):
|
||||
"""-> ("netdev", device) | ("heartbeat", None) | ("none", None). `now`: epoch seconds (default: clock)."""
|
||||
if isinstance(state, dict) and state_stale(state, time.time() if now is None else now, max_age):
|
||||
state = 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:
|
||||
@@ -113,9 +148,10 @@ def carrier(cfg, iface):
|
||||
return bool(iface) and read_attr(os.path.join(cfg["sysfs_net"], iface, "carrier")) == "1"
|
||||
|
||||
|
||||
def wanted(cfg):
|
||||
def wanted(cfg, now=None):
|
||||
"""{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)
|
||||
wan = desired_wan(load_state(cfg["failover_state"]), carrier(cfg, cfg["uplink_iface"]),
|
||||
cfg["uplink_iface"] or None, now, cfg["state_max_age_s"])
|
||||
return {
|
||||
"power": ("default-on", None),
|
||||
"lan": ("netdev", cfg["lan_iface"]),
|
||||
@@ -133,6 +169,11 @@ def usable_leds(cfg, announce=False):
|
||||
if announce:
|
||||
log(f"LED {role}: no name configured, skipped")
|
||||
continue
|
||||
iface_key = {"lan": "lan_iface", "wlan": "wlan_iface"}.get(role)
|
||||
if iface_key and not cfg[iface_key]:
|
||||
if announce:
|
||||
log(f"LED {role}: {iface_key} is empty, skipped")
|
||||
continue
|
||||
d = os.path.join(cfg["sysfs_leds"], name)
|
||||
if not os.path.isdir(d):
|
||||
if announce:
|
||||
@@ -142,17 +183,18 @@ def usable_leds(cfg, announce=False):
|
||||
return out
|
||||
|
||||
|
||||
def poll_once(cfg):
|
||||
def poll_once(cfg, now=None):
|
||||
"""One evaluation of every usable LED. Returns the files written."""
|
||||
want = wanted(cfg)
|
||||
want = wanted(cfg, now)
|
||||
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}")
|
||||
log_once(role, f"LED {role}: cannot set {trig}: {e}")
|
||||
continue
|
||||
clear_log(role)
|
||||
if w:
|
||||
log(f"LED {role}: {trig}" + (f" {dev}" if dev else ""))
|
||||
writes += w
|
||||
@@ -170,8 +212,10 @@ def main(argv):
|
||||
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_once("poll", f"poll failed: {e}")
|
||||
else:
|
||||
clear_log("poll")
|
||||
stop.wait(interval(cfg))
|
||||
log("van-leds stopping, LEDs left as they are")
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user