Files
matrix-screen-controller/核桃派软件源代码/app/network/service.py
T

376 lines
16 KiB
Python

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)