from __future__ import annotations import ipaddress import logging import threading import time from copy import deepcopy from typing import Any, Callable from uuid import uuid4 from app.display.service import DisplayService from .manager import NetworkBackend, NetworkManagerError from .store import WifiConfigError, WifiConfigStore logger = logging.getLogger(__name__) def _utf8_length(value: str) -> int: return len(value.encode("utf-8")) def validate_wifi_settings( raw: dict[str, Any], *, previous: dict[str, Any] | None = None, ) -> dict[str, Any]: ssid = str(raw.get("ssid") or "").strip() if not 1 <= _utf8_length(ssid) <= 32: raise WifiConfigError("ssid must contain 1..32 UTF-8 bytes") password_value = raw.get("password") security = raw.get('security', 'wpa-psk') if security not in ('open', 'wpa-psk'): raise WifiConfigError('unsupported WiFi security') password = None if password_value in (None, "") else str(password_value) if password is not None and not 8 <= _utf8_length(password) <= 63: raise WifiConfigError("password must contain 8..63 UTF-8 bytes") if security == 'open': if password is not None: raise WifiConfigError('open networks cannot have a password') elif previous is None or previous.get("ssid") != ssid: if password is None: raise WifiConfigError("a new password is required when the SSID changes") elif password is None and not previous.get("password_configured"): raise WifiConfigError("the managed WiFi connection does not have a saved password") mode = str(raw.get("ipv4_mode") or "dhcp") if mode not in {"dhcp", "manual"}: raise WifiConfigError("ipv4_mode must be dhcp or manual") address = gateway = None prefix = 24 dns_servers: list[str] = [] if mode == "manual": try: address = str(ipaddress.IPv4Address(str(raw.get("address") or ""))) except ipaddress.AddressValueError as exc: raise WifiConfigError("manual IPv4 address is required") from exc try: gateway = str(ipaddress.IPv4Address(str(raw.get("gateway") or ""))) except ipaddress.AddressValueError as exc: raise WifiConfigError("manual IPv4 gateway is required") from exc prefix_value = raw.get("prefix") if prefix_value not in (None, ""): if type(prefix_value) is not int: raise WifiConfigError("IPv4 prefix must be an integer") prefix = prefix_value if not 1 <= prefix <= 32: raise WifiConfigError("IPv4 prefix must be in 1..32") network = ipaddress.IPv4Network(f"{address}/{prefix}", strict=False) if ipaddress.IPv4Address(address) in {network.network_address, network.broadcast_address}: raise WifiConfigError("manual IPv4 address cannot be the network or broadcast address") if ipaddress.IPv4Address(gateway) not in network: raise WifiConfigError("manual IPv4 gateway must be in the selected subnet") for item in raw.get("dns_servers") or []: try: normalized = str(ipaddress.IPv4Address(str(item).strip())) except ipaddress.AddressValueError as exc: raise WifiConfigError("DNS servers must be valid IPv4 addresses") from exc if normalized not in dns_servers: dns_servers.append(normalized) if not dns_servers: dns_servers = [gateway] return { "ssid": ssid, "security": security, "password": password, "password_configured": security != 'open' and bool(password or (previous or {}).get("password_configured")), "ipv4_mode": mode, "address": address, "prefix": prefix if mode == "manual" else None, "gateway": gateway, "dns_servers": dns_servers, } class WifiNetworkService: def __init__( self, store: WifiConfigStore, backend: NetworkBackend, display: DisplayService, *, monotonic: Callable[[], float] = time.monotonic, poll_seconds: float = 2.0, ) -> None: self.store = store self.backend = backend self.display = display self.monotonic = monotonic self.poll_seconds = poll_seconds self._lock = threading.RLock() self._indicator_lock = threading.RLock() self.configuration_lock = self._lock self.settings_revision = str(uuid4()) self._stop = threading.Event() self._thread: threading.Thread | None = None self._activation_active = False self._status_cache: dict[str, Any] | None = None self._ensure_managed_connection() def _ensure_managed_connection(self) -> None: if self.store.config["managed_connection_uuid"] is not None: return try: discovered = self.backend.discover_connection_uuid() except NetworkManagerError: logger.warning("Unable to discover a managed WiFi connection") return if discovered: self.store.update({"managed_connection_uuid": discovered}) logger.info("Bound the active NetworkManager WiFi connection") def start(self) -> None: with self._lock: if self._thread is not None and self._thread.is_alive(): return # Board-side NetworkManager queries can take around a second. # Populate once before serving requests, then refresh in the # coordinator instead of blocking every /api/status response. self.get_status() self._stop.clear() self._thread = threading.Thread( target=self._run, name="wifi-boot-coordinator", daemon=True, ) self._thread.start() def close(self) -> None: self._stop.set() thread = self._thread if thread is not None and thread is not threading.current_thread(): thread.join(timeout=2.5) self.display.dismiss_wifi_indicator() def dismiss_for_boot(self) -> None: with self._indicator_lock: try: changed = self.store.dismiss_for_boot() except Exception: logger.exception("Failed to persist WiFi indicator dismissal") changed = True if changed or self.display.get_status()["state"].get("wifi_indicator_active"): self.display.dismiss_wifi_indicator() def get_status(self, *, include_secret: bool = False) -> dict[str, Any]: managed_uuid = self.store.config["managed_connection_uuid"] saved: dict[str, Any] | None = None error = None try: if managed_uuid: saved = self.backend.read_saved(managed_uuid, include_secret=include_secret) active = self.backend.read_active() except NetworkManagerError as exc: active = { "connected": False, "connection_uuid": None, "ssid": None, "ipv4_address": None, "control_url": None, } error = str(exc) if saved is not None and not include_secret: saved = {key: value for key, value in saved.items() if key != "password"} session = self.store.session result = { "available": error is None, "error": error, "saved": saved, "active": active, "prompt_delay_seconds": self.store.config["prompt_delay_seconds"], "prompt": { "dismissed_for_boot": session["dismissed"], "deadline_monotonic": session["deadline_monotonic"], "active": self.display.get_status()["state"].get("wifi_indicator_active", False), }, "operation": deepcopy(session["operation"]), } if not include_secret: with self._lock: self._status_cache = deepcopy(result) return result def get_cached_status(self) -> dict[str, Any]: with self._lock: cached = deepcopy(self._status_cache) if cached is None: return self.get_status() session = self.store.session cached["prompt"] = { "dismissed_for_boot": session["dismissed"], "deadline_monotonic": session["deadline_monotonic"], "active": self.display.get_status()["state"].get("wifi_indicator_active", False), } cached["operation"] = deepcopy(session["operation"]) cached["prompt_delay_seconds"] = self.store.config["prompt_delay_seconds"] return cached def save_settings(self, raw: dict[str, Any]) -> dict[str, Any]: activation = str(raw.get("activation") or "") if activation not in {"immediate", "next_boot"}: raise WifiConfigError("activation must be immediate or next_boot") delay = raw.get("prompt_delay_seconds") if delay is not None and type(delay) is not int: raise WifiConfigError("prompt_delay_seconds must be an integer") with self._lock: if self._activation_active: raise WifiConfigError("another WiFi activation is already running") managed_uuid = self.store.config["managed_connection_uuid"] previous = None if managed_uuid: previous = self.backend.read_saved(managed_uuid, include_secret=False) settings = validate_wifi_settings(raw, previous=previous) if managed_uuid: rollback = self.backend.read_saved(managed_uuid, include_secret=True) try: self.backend.save_connection(managed_uuid, settings) if delay is not None: self.store.update({"prompt_delay_seconds": delay}) except Exception: try: self.backend.save_connection(managed_uuid, rollback) except Exception: logger.exception("Failed to roll back NetworkManager profile metadata") raise else: managed_uuid = self.backend.create_connection(settings) try: store_update = {"managed_connection_uuid": managed_uuid} if delay is not None: store_update["prompt_delay_seconds"] = delay self.store.update(store_update) except Exception: logger.exception("WiFi profile was created but its managed UUID could not be saved") raise operation_id = str(uuid4()) state = "scheduled" if activation == "immediate" else "succeeded" message = ( "WiFi 配置已保存,正在准备立即切换" if activation == "immediate" else "WiFi 配置已保存,将在下次断电开机后生效" ) self.store.set_operation({"id": operation_id, "state": state, "message": message}) self.settings_revision = str(uuid4()) return { "operation_id": operation_id, "activation": activation, "message": message, } def save_prompt_delay(self, value: Any) -> dict[str, Any]: if type(value) is not int: raise WifiConfigError("prompt_delay_seconds must be an integer") with self._lock: config = self.store.update({"prompt_delay_seconds": value}) return { "prompt_delay_seconds": config["prompt_delay_seconds"], "message": "网络提示等待时间已保存", } def activate_saved(self, operation_id: str) -> None: with self._lock: if self._activation_active: return self._activation_active = True try: managed_uuid = self.store.config["managed_connection_uuid"] if not managed_uuid: raise NetworkManagerError("managed WiFi connection is unavailable") self.store.set_operation({ "id": operation_id, "state": "applying", "message": "正在切换 WiFi 与 IPv4 配置", }) self.backend.activate(managed_uuid, timeout=30) self.store.set_operation({ "id": operation_id, "state": "succeeded", "message": "WiFi 与 IPv4 配置已生效", }) except Exception as exc: logger.warning("WiFi activation failed without exposing NetworkManager output") self.store.set_operation({ "id": operation_id, "state": "failed", "message": "WiFi activation failed", "error_code": getattr(exc, "error_code", "UNKNOWN"), }) finally: with self._lock: self._activation_active = False def _run(self) -> None: session = self.store.session deadline = float(session["deadline_monotonic"]) managed_uuid = self.store.config["managed_connection_uuid"] if self.store.is_new_boot_session and managed_uuid: while not self._stop.is_set() and self.monotonic() < deadline: try: self.backend.activate(managed_uuid, timeout=5) if self.backend.read_active().get("connected"): break except NetworkManagerError: pass self._stop.wait(min(self.poll_seconds, max(0.0, deadline - self.monotonic()))) remaining = deadline - self.monotonic() if remaining > 0 and self._stop.wait(remaining): return while not self._stop.is_set(): self.get_status() if self.store.session["dismissed"]: self.display.dismiss_wifi_indicator() else: self._refresh_indicator() self._stop.wait(self.poll_seconds) def _refresh_indicator(self) -> None: if self.store.session["dismissed"]: return managed_uuid = self.store.config["managed_connection_uuid"] try: active = self.backend.read_active() if active.get("connected") and active.get("ssid") and active.get("ipv4_address"): message = ( f"SSID: {active['ssid']} " f"IP: http://{active['ipv4_address']}:8080/" ) self._show_indicator_unless_dismissed(connected=True, message=message) return if managed_uuid: saved = self.backend.read_saved(managed_uuid, include_secret=True) password = saved.get("password") or "未保存" message = f"SSID: {saved.get('ssid') or '未配置'} 密码: {password}" else: message = "尚未配置 WiFi" self._show_indicator_unless_dismissed(connected=False, message=message) except NetworkManagerError: self._show_indicator_unless_dismissed(connected=False, message="WiFi 状态不可用") def _show_indicator_unless_dismissed(self, *, connected: bool, message: str) -> None: # NetworkManager reads happen outside this lock so opening a mobile # session never waits for them. Recheck at the actual submission point. with self._indicator_lock: if not self.store.session["dismissed"]: self.display.show_wifi_indicator(connected=connected, message=message)