Files
epaper-dashboard/.backup/2026-08-26-active/network_watchdog.py
T
epaper-dashboardandHermes 1f142f5245 Sync to Pi: alle Features die live deployed sind
Aus dem Backup und Live-Pull vom Pi (10.11.3.144):
- dashboard.py: Grid-Linien nur im freien Hintergrund (nicht durch Widgets)
- templates/index.html: komplett redesigned mit Sidebar + Topbar + Toast + Modal
- plugins/clock.py: responsive Layout (1x1 bis 4x4)
- plugins/system.py, weather.py, minimax.py: mit Threshold-Bars und Color-Variants
- plugins/base.py: NEU — fetch_with_retry Helper (3x retry mit backoff)
  + render_error_banner für fehlgeschlagene API-Plugins
  (grosses rotes "!" Icon mit Plugin-Name und Fehler statt Crash)

Cleanup: Helfer-Chaos (renderer.py/2/3, design_a/b/c.html, clock_classic.py,
23x clock_*.png, alte test_*.py) wurde bereits im vorherigen Commit entfernt.

Co-Authored-By: Hermes <noreply@hermes.local>
2026-08-26 22:11:36 +04:00

469 lines
17 KiB
Python

"""Network-Watchdog: überwacht WLAN-Verbindung und startet Recovery-AP bei Ausfall.
Strategie:
1. Default-Gateway-Ping alle CHECK_INTERVAL_S
2. failed_pings zählt; bei FAIL_THRESHOLD sofortiger Reconnect-Versuch
3. Nach REASSURE_TIMEOUT_S erfolglos: AP-Modus starten
4. Im AP-Modus: alle AP_RECHECK_S prüfen ob echtes WLAN zurück ist, dann AP aus
"""
from __future__ import annotations
import subprocess, time, threading, logging, os
from pathlib import Path
from dataclasses import dataclass, field
# Defaults
WLAN_IFACE = "wlan0" # Pi 4 builtin
CLIENT_CON_NAME = "dashboard-wifi" # NetworkManager connection profile name
AP_SSID = "epaper-recovery"
AP_PASSWORD = "recovery1234"
CHECK_INTERVAL_S = 30 # alle 30s pingen
FAIL_THRESHOLD = 2 # 2 misses hintereinander → reconnect versuchen
RECONNECT_COOLDOWN_S = 90 # zwischen zwei Reconnect-Versuchen mind. 90s warten
AP_RECHECK_S = 60 # im AP-Modus: alle 60s versuchen zurück zu wechseln
AP_ENABLE_TIMEOUT_S = 30 # warten bis AP steht, dann gilt: "AP ist aktiv"
PING_TIMEOUT_S = 3
PING_TARGETS = ["1.1.1.1", "8.8.8.8"] # wenn eins antwortet → online
@dataclass
class NetState:
mode: str = "unknown" # "client" | "ap" | "connecting" | "offline" | "unknown"
ssid: str = ""
ip: str = ""
signal: int = 0 # 0..100
gateway_ping_ms: float = 0.0
failed_pings: int = 0
last_state_change: float = field(default_factory=time.time)
last_reconnect_attempt: float = 0.0
error: str = ""
def _nm(args: list[str], timeout: int = 15) -> tuple[int, str, str]:
"""Run nmcli with timeout, return (rc, stdout, stderr)."""
try:
r = subprocess.run(["nmcli", *args], capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except Exception as e:
return -1, "", str(e)
def ping(target: str) -> float | None:
try:
r = subprocess.run(
["ping", "-c", "1", "-W", str(PING_TIMEOUT_S), target],
capture_output=True, text=True, timeout=PING_TIMEOUT_S + 2,
)
if r.returncode != 0:
return None
for line in r.stdout.splitlines():
if "time=" in line:
return float(line.split("time=")[1].split()[0])
except Exception:
return None
return None
def get_active_wifi_info() -> dict:
"""Liest aktuelle WLAN-Informationen aus NetworkManager."""
rc, out, err = _nm(["-t", "-f", "ACTIVE,SSID,SIGNAL,FREQ,CHAN,RATE,BARS", "device", "wifi"])
info = {"ssid": "", "signal": 0, "bars": "", "active": False}
if rc != 0:
return info
for line in out.splitlines():
parts = line.split(":")
if len(parts) < 6:
continue
# Format: yes:ssid:signal:...
if parts[0] == "yes":
info["active"] = True
info["ssid"] = parts[1] if len(parts) > 1 else ""
try:
info["signal"] = int(parts[2]) if len(parts) > 2 and parts[2] else 0
except ValueError:
pass
info["bars"] = parts[6] if len(parts) > 6 else ""
break
return info
def get_wifi_ip() -> str:
"""Hole IPv4-Adresse von WLAN-Interface."""
try:
r = subprocess.run(
["ip", "-4", "-o", "addr", "show", "dev", WLAN_IFACE],
capture_output=True, text=True, timeout=5,
)
for line in r.stdout.splitlines():
if "inet " in line:
return line.split("inet ")[1].split("/")[0]
except Exception:
pass
return ""
def list_wifi_networks() -> list[dict]:
"""Scannt verfügbare WLANs."""
rc, out, err = _nm(["-t", "-f", "SSID,SIGNAL,SECURITY,FREQ,CHAN", "device", "wifi",
"list", "--rescan", "yes"])
if rc != 0:
# zweiter versuch ohne rescan
rc, out, err = _nm(["-t", "-f", "SSID,SIGNAL,SECURITY,FREQ,CHAN", "device", "wifi", "list"])
seen = set()
result = []
for line in out.splitlines():
parts = line.split(":")
if len(parts) < 3: continue
ssid = parts[0]
if not ssid or ssid in seen: continue
seen.add(ssid)
try:
sig = int(parts[1]) if parts[1] else 0
except ValueError:
sig = 0
result.append({"ssid": ssid, "signal": sig, "security": parts[2] if len(parts) > 2 else "",
"freq": parts[3] if len(parts) > 3 else "",
"chan": parts[4] if len(parts) > 4 else ""})
result.sort(key=lambda w: w["signal"], reverse=True)
return result
def list_saved_connections() -> list[dict]:
"""Liste alle gespeicherten WLAN-Profile."""
rc, out, err = _nm(["-t", "-f", "NAME,TYPE,UUID", "connection", "show"])
result = []
for line in out.splitlines():
parts = line.split(":")
if len(parts) < 3: continue
if parts[1] != "802-11-wireless": continue
if parts[0] in (AP_SSID, "Hotspot"): continue # skip AP profile
result.append({"name": parts[0], "uuid": parts[2]})
return result
def save_wifi(ssid: str, password: str, security: str = "wpa-psk") -> tuple[bool, str]:
"""Speichere (oder update) ein WLAN-Profil mit gegebenen Credentials.
Wenn schon ein Profil mit gleicher SSID existiert, wird es aktualisiert;
sonst wird ein neues angelegt.
"""
# Existierendes Profil finden
saved = list_saved_connections()
target_name = None
for c in saved:
if c["name"].startswith(ssid):
target_name = c["name"]
break
if target_name is None:
target_name = CLIENT_CON_NAME + "-" + ssid.replace(" ", "_")
args = ["connection", "modify" if target_name in [c["name"] for c in saved] else "add",
"type", "wifi",
"con-name", target_name,
"ifname", WLAN_IFACE,
"ssid", ssid]
if security and security != "" and password:
if "WPA" in security.upper() or "WPA2" in security.upper() or "WPA3" in security.upper():
args += ["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]
elif "WEP" in security.upper():
args += ["wifi-sec.key-mgmt", "none", "wifi-sec.wep-key-type", "1",
"wifi-sec.wep-key0", password]
else:
args += ["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]
else:
args += ["wifi-sec.key-mgmt", "none"]
rc, out, err = _nm(args, timeout=20)
if rc != 0:
return False, err or out or "unknown error"
return True, target_name
def connect_wifi(connection_name: str) -> tuple[bool, str]:
rc, out, err = _nm(["connection", "up", connection_name], timeout=30)
if rc != 0:
return False, err or out or "unknown error"
return True, "connected"
def disconnect_wifi() -> tuple[bool, str]:
rc, out, err = _nm(["device", "disconnect", WLAN_IFACE], timeout=10)
return rc == 0, err if rc != 0 else "ok"
def start_ap() -> tuple[bool, str]:
"""Startet einen Hotspot auf wlan0 mit fester SSID/PW."""
# Alten Hotspot ggf. löschen (idempotent)
_nm(["connection", "delete", AP_SSID], timeout=10)
rc, out, err = _nm([
"device", "wifi", "hotspot",
"ifname", WLAN_IFACE,
"ssid", AP_SSID,
"password", AP_PASSWORD,
], timeout=30)
if rc != 0:
return False, err or out
return True, "hotspot up"
def stop_ap() -> tuple[bool, str]:
rc, out, err = _nm(["connection", "down", AP_SSID], timeout=15)
if rc != 0:
return False, err or out
_nm(["connection", "delete", AP_SSID], timeout=10)
return True, "ap stopped"
def is_ap_active() -> bool:
rc, out, _ = _nm(["-t", "-f", "NAME", "connection", "show", "--active"])
if rc != 0:
return False
return AP_SSID in out.splitlines()
def is_wifi_connected() -> bool:
rc, out, _ = _nm(["-t", "-f", "STATE", "device", "show", WLAN_IFACE])
if rc != 0:
return False
return "connected" in out.lower()
# ============================================================================
# Recovery threshold helper (used by admin.py)
# ============================================================================
def get_recovery_threshold() -> int:
"""Liest recovery_threshold_s aus config.json (default 60s)."""
try:
from pathlib import Path as _P
cfg_path = _P("/home/koptikp/epaper-dashboard/port/config.json")
if cfg_path.exists():
import json as _json
cfg = _json.loads(cfg_path.read_text())
return int(cfg.get("recovery_threshold_s", 60))
except Exception:
pass
return 60
# ============================================================================
# Watchdog
# ============================================================================
def _check_recovery_needed(state: dict, threshold_s: int) -> dict:
"""Entscheidet, ob die Recovery-Info UI angezeigt werden soll.
Returns dict mit:
show: bool
reason: 'offline' | 'ap_active' | 'connecting_timeout' | 'no_ip'
duration_s: Sekunden im aktuellen (nicht-client) Zustand
"""
mode = state.get("mode", "unknown")
if mode == "client":
return {"show": False, "reason": "client", "duration_s": 0}
# Im AP-Modus: immer anzeigen
if mode == "ap":
return {"show": True, "reason": "ap_active", "duration_s": state.get("since_change_s", 0)}
if mode == "offline":
return {"show": True, "reason": "offline", "duration_s": state.get("since_change_s", 0)}
if mode == "connecting":
# Connecting dauert zu lange? Zeige info.
return {"show": state.get("since_change_s", 0) > threshold_s,
"reason": "connecting_timeout",
"duration_s": state.get("since_change_s", 0)}
return {"show": False, "reason": "unknown", "duration_s": state.get("since_change_s", 0)}
class Watchdog:
def __init__(self):
self.state = NetState()
self._lock = threading.Lock()
self._stop = threading.Event()
def get_state(self) -> dict:
with self._lock:
return {
"mode": self.state.mode,
"ssid": self.state.ssid,
"ip": self.state.ip,
"signal": self.state.signal,
"gateway_ping_ms": round(self.state.gateway_ping_ms, 1),
"failed_pings": self.state.failed_pings,
"since_change_s": int(time.time() - self.state.last_state_change),
"error": self.state.error,
}
def _set_mode(self, new_mode: str):
with self._lock:
if self.state.mode != new_mode:
logging.info(f"network mode: {self.state.mode}{new_mode}")
self.state.mode = new_mode
self.state.last_state_change = time.time()
self.state.error = ""
def run_once(self) -> dict:
"""Führt einen Check durch und triggert ggf. State-Übergänge."""
with self._lock:
cur_mode = self.state.mode
ping_ms = None
for tgt in PING_TARGETS:
r = ping(tgt)
if r is not None:
ping_ms = r
break
wifi_info = get_active_wifi_info()
ip = get_wifi_ip()
if wifi_info["active"] and ip and ping_ms is not None:
# ONLINE
with self._lock:
self.state.ssid = wifi_info["ssid"]
self.state.signal = wifi_info["signal"]
self.state.ip = ip
self.state.gateway_ping_ms = ping_ms
self.state.failed_pings = 0
# Falls im AP-Modus → AP aus
if cur_mode == "ap":
logging.info("client wifi back, stopping AP")
stop_ap()
self._set_mode("client")
return self.get_state()
# OFFLINE PATH
with self._lock:
if ping_ms is None:
self.state.failed_pings += 1
self.state.gateway_ping_ms = ping_ms or 0.0
self.state.ip = ip
self.state.ssid = wifi_info["ssid"]
self.state.signal = wifi_info["signal"]
failed = self.state.failed_pings
last_recon = self.state.last_reconnect_attempt
if cur_mode == "ap":
# Bereits im AP — nur gelegentlich versuchen zurück zu wechseln
# (durch den regulären 60s-Loop in self._loop)
return self.get_state()
if failed >= FAIL_THRESHOLD:
now = time.time()
if now - last_recon < RECONNECT_COOLDOWN_S:
return self.get_state()
# Reconnect versuchen
with self._lock:
self.state.last_reconnect_attempt = now
saved = list_saved_connections()
if saved:
logging.info(f"wifi lost ({failed} failed pings), reconnecting to {saved[0]['name']}")
self._set_mode("connecting")
ok, msg = connect_wifi(saved[0]["name"])
if ok:
time.sleep(AP_ENABLE_TIMEOUT_S)
# Nach reconnect: wenn immer noch offline → AP
return self.run_once()
else:
logging.error(f"reconnect failed: {msg}")
with self._lock:
self.state.error = msg
else:
# Kein Profil → direkt in AP
self._set_mode("connecting")
return self._enter_ap()
return self.get_state()
def _enter_ap(self) -> dict:
logging.warning(f"entering AP mode ({AP_SSID})")
ok, msg = start_ap()
if ok:
self._set_mode("ap")
with self._lock:
self.state.ip = "10.42.0.1"
self.state.error = ""
else:
logging.error(f"failed to start AP: {msg}")
self._set_mode("offline")
with self._lock:
self.state.error = msg
return self.get_state()
def _try_exit_ap(self) -> dict:
"""Versuch, aus dem AP zurück in Client-Modus zu wechseln."""
if not is_ap_active():
self._set_mode("client")
return self.get_state()
saved = list_saved_connections()
if not saved:
return self.get_state()
logging.info("trying to exit AP back to client")
ok, msg = connect_wifi(saved[0]["name"])
if ok:
time.sleep(AP_ENABLE_TIMEOUT_S)
wifi_info = get_active_wifi_info()
ip = get_wifi_ip()
if wifi_info["active"] and ip and ping(PING_TARGETS[0]) is not None:
logging.info("back online, stopping AP")
stop_ap()
self._set_mode("client")
with self._lock:
self.state.failed_pings = 0
else:
# Immer noch kein echtes WLAN → AP wieder hoch
start_ap()
self._set_mode("ap")
return self.get_state()
def _loop(self):
# Initial: state aus NM ableiten
wifi_info = get_active_wifi_info()
ip = get_wifi_ip()
if is_ap_active():
self._set_mode("ap")
with self._lock:
self.state.ssid = AP_SSID
self.state.ip = "10.42.0.1"
elif wifi_info["active"] and ip:
self._set_mode("client")
with self._lock:
self.state.ssid = wifi_info["ssid"]
self.state.signal = wifi_info["signal"]
self.state.ip = ip
else:
self._set_mode("offline")
while not self._stop.is_set():
try:
if self.state.mode == "ap":
# im AP-Modus: alle AP_RECHECK_S versuchen zurück zu wechseln
if self._stop.wait(timeout=AP_RECHECK_S):
break
self._try_exit_ap()
else:
if self._stop.wait(timeout=CHECK_INTERVAL_S):
break
self.run_once()
except Exception as e:
logging.error(f"watchdog error: {e}")
time.sleep(5)
def start(self):
t = threading.Thread(target=self._loop, name="network-watchdog", daemon=True)
t.start()
def stop(self):
self._stop.set()
# ============================================================================
# Singleton für Web-UI
# ============================================================================
_watchdog: Watchdog | None = None
def get_watchdog() -> Watchdog:
global _watchdog
if _watchdog is None:
_watchdog = Watchdog()
_watchdog.start()
return _watchdog