Initial commit: epaper-dashboard for 7.3" ACeP 7-Color display
- dashboard.py: plugin-based renderer with 4x4 grid layout - admin.py: web UI with layout editor + plugin configs - layout.py: pack algorithm, item placement, grid system - plugins/: clock, weather, system, spotify, strava, gmail, minimax, hello - network_watchdog.py: WiFi AP/client mode management - waveshare_epd_init.py: vendor driver stub
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
"""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()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Watchdog
|
||||
# ============================================================================
|
||||
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
|
||||
Reference in New Issue
Block a user