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>
469 lines
17 KiB
Python
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
|