"""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