初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
"""NetworkManager-backed WiFi configuration and boot coordination."""
|
||||
|
||||
from .manager import MockNetworkManager, NetworkManagerError, NmcliNetworkManager
|
||||
from .service import WifiNetworkService
|
||||
from .store import WifiConfigError, WifiConfigStore
|
||||
|
||||
__all__ = [
|
||||
"MockNetworkManager",
|
||||
"NetworkManagerError",
|
||||
"NmcliNetworkManager",
|
||||
"WifiConfigError",
|
||||
"WifiConfigStore",
|
||||
"WifiNetworkService",
|
||||
]
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class NetworkDiagnostics:
|
||||
def __init__(
|
||||
self,
|
||||
status_provider: Callable[[], dict[str, Any]],
|
||||
*,
|
||||
runner=subprocess.run,
|
||||
resolver=socket.getaddrinfo,
|
||||
connector=socket.create_connection,
|
||||
) -> None:
|
||||
self.status_provider = status_provider
|
||||
self.runner = runner
|
||||
self.resolver = resolver
|
||||
self.connector = connector
|
||||
|
||||
@staticmethod
|
||||
def _check(code: str, ok: bool, message: str, *, warning: bool = False) -> dict[str, Any]:
|
||||
return {"code": code, "ok": ok, "warning": warning, "message": message}
|
||||
|
||||
def _command(self, command: list[str], timeout: int = 3) -> subprocess.CompletedProcess[str] | None:
|
||||
try:
|
||||
return self.runner(command, capture_output=True, text=True, timeout=timeout)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
checks: list[dict[str, Any]] = []
|
||||
active = (self.status_provider().get("active") or {})
|
||||
connected = active.get("connected") is True and bool(active.get("ipv4_address"))
|
||||
checks.append(self._check("interface", connected, "网络接口已连接" if connected else "网卡或 WiFi 未连接"))
|
||||
if not connected:
|
||||
return self._result(checks, started, "failed", "网卡或 WiFi 未连接")
|
||||
|
||||
route = self._command(["/usr/sbin/ip", "-4", "route", "show", "default"])
|
||||
route_ok = bool(route and route.returncode == 0 and route.stdout.strip())
|
||||
checks.append(self._check("default_route", route_ok, "默认路由正常" if route_ok else "默认网关缺失"))
|
||||
if not route_ok:
|
||||
return self._result(checks, started, "failed", "默认网关缺失")
|
||||
|
||||
dns_ok = False
|
||||
for host in ("www.baidu.com", "www.qq.com"):
|
||||
try:
|
||||
if self.resolver(host, 443, type=socket.SOCK_STREAM):
|
||||
dns_ok = True
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
checks.append(self._check("dns", dns_ok, "DNS 解析正常" if dns_ok else "DNS 解析失败"))
|
||||
if not dns_ok:
|
||||
return self._result(checks, started, "failed", "DNS 解析失败,请检查 DNS 或网关配置")
|
||||
|
||||
reachable = False
|
||||
tls_ok = False
|
||||
for host in ("www.baidu.com", "www.qq.com"):
|
||||
try:
|
||||
raw = self.connector((host, 443), timeout=3)
|
||||
reachable = True
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
with context.wrap_socket(raw, server_hostname=host):
|
||||
tls_ok = True
|
||||
break
|
||||
finally:
|
||||
try:
|
||||
raw.close()
|
||||
except OSError:
|
||||
pass
|
||||
except (OSError, ssl.SSLError):
|
||||
continue
|
||||
checks.append(self._check("internet", reachable, "外网 TCP 连接正常" if reachable else "网关或上游网络不可达"))
|
||||
checks.append(self._check("tls", tls_ok, "TLS 校验正常" if tls_ok else "TLS 或系统时间可能异常"))
|
||||
|
||||
ping = self._command(["/usr/bin/ping", "-c", "1", "-W", "2", "www.baidu.com"])
|
||||
ping_ok = bool(ping and ping.returncode == 0)
|
||||
checks.append(self._check(
|
||||
"icmp", ping_ok,
|
||||
"百度 ping 正常" if ping_ok else "目标未响应 ping;部分网络会屏蔽 ICMP",
|
||||
warning=not ping_ok and tls_ok,
|
||||
))
|
||||
if tls_ok:
|
||||
return self._result(checks, started, "ok", "网络连接正常")
|
||||
if reachable:
|
||||
return self._result(checks, started, "failed", "TLS 或系统时间可能异常")
|
||||
return self._result(checks, started, "failed", "网关或上游网络不可达")
|
||||
|
||||
@staticmethod
|
||||
def _result(checks: list[dict[str, Any]], started: float, overall: str, diagnosis: str) -> dict[str, Any]:
|
||||
return {
|
||||
"overall": overall,
|
||||
"diagnosis": diagnosis,
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
class MockNetworkDiagnostics:
|
||||
def __init__(self, result: dict[str, Any] | None = None) -> None:
|
||||
self.result = result or {
|
||||
"overall": "ok",
|
||||
"diagnosis": "网络连接正常(mock)",
|
||||
"duration_ms": 1,
|
||||
"checks": [
|
||||
{"code": "interface", "ok": True, "warning": False, "message": "网络接口已连接"},
|
||||
{"code": "dns", "ok": True, "warning": False, "message": "DNS 解析正常"},
|
||||
{"code": "internet", "ok": True, "warning": False, "message": "外网连接正常"},
|
||||
],
|
||||
}
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
return deepcopy(self.result)
|
||||
@@ -0,0 +1,297 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class NetworkManagerError(RuntimeError):
|
||||
"""A sanitized NetworkManager operation failure."""
|
||||
|
||||
|
||||
class NetworkBackend(Protocol):
|
||||
def discover_connection_uuid(self) -> str | None: ...
|
||||
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: ...
|
||||
def read_active(self) -> dict[str, Any]: ...
|
||||
def create_connection(self, settings: dict[str, Any]) -> str: ...
|
||||
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None: ...
|
||||
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None: ...
|
||||
|
||||
|
||||
def _decode_terse(value: str) -> str:
|
||||
output: list[str] = []
|
||||
escaped = False
|
||||
for character in value:
|
||||
if escaped:
|
||||
output.append(character)
|
||||
escaped = False
|
||||
elif character == "\\":
|
||||
escaped = True
|
||||
else:
|
||||
output.append(character)
|
||||
if escaped:
|
||||
output.append("\\")
|
||||
return "".join(output)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NmcliNetworkManager:
|
||||
executable: str = "/usr/bin/nmcli"
|
||||
interface: str = "wlan0"
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: Sequence[str],
|
||||
*,
|
||||
timeout: int = 10,
|
||||
) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self.executable, *arguments],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except FileNotFoundError as exc:
|
||||
raise NetworkManagerError("NetworkManager nmcli is unavailable") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise NetworkManagerError("NetworkManager operation timed out") from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
# stderr may echo a secret on some nmcli failures, so never include it.
|
||||
raise NetworkManagerError("NetworkManager rejected the requested operation") from exc
|
||||
|
||||
def _connection_value(
|
||||
self,
|
||||
connection_uuid: str,
|
||||
field: str,
|
||||
*,
|
||||
include_secret: bool = False,
|
||||
) -> str:
|
||||
arguments = []
|
||||
if include_secret:
|
||||
arguments.append("--show-secrets")
|
||||
arguments.extend(["--get-values", field, "connection", "show", "uuid", connection_uuid])
|
||||
return self._run(arguments)
|
||||
|
||||
def discover_connection_uuid(self) -> str | None:
|
||||
active = self._run([
|
||||
"--get-values", "GENERAL.CON-UUID", "device", "show", self.interface,
|
||||
])
|
||||
if active and active != "--":
|
||||
return active.splitlines()[0].strip()
|
||||
|
||||
rows = self._run([
|
||||
"--terse", "--fields", "UUID,TYPE,AUTOCONNECT", "connection", "show",
|
||||
])
|
||||
candidates = []
|
||||
for row in rows.splitlines():
|
||||
parts = row.split(":")
|
||||
if len(parts) == 3 and parts[1] == "802-11-wireless" and parts[2] == "yes":
|
||||
candidates.append(parts[0])
|
||||
return candidates[0] if len(candidates) == 1 else None
|
||||
|
||||
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]:
|
||||
ssid = self._connection_value(connection_uuid, "802-11-wireless.ssid")
|
||||
method = self._connection_value(connection_uuid, "ipv4.method") or "auto"
|
||||
raw_addresses = self._connection_value(connection_uuid, "ipv4.addresses")
|
||||
address = raw_addresses.splitlines()[0].strip() if raw_addresses else ""
|
||||
raw_dns = self._connection_value(connection_uuid, "ipv4.dns")
|
||||
password = None
|
||||
password_configured = False
|
||||
if include_secret:
|
||||
password = self._connection_value(
|
||||
connection_uuid,
|
||||
"802-11-wireless-security.psk",
|
||||
include_secret=True,
|
||||
)
|
||||
password_configured = bool(password and password != "--")
|
||||
else:
|
||||
key_mgmt = self._connection_value(
|
||||
connection_uuid,
|
||||
"802-11-wireless-security.key-mgmt",
|
||||
)
|
||||
password_configured = bool(key_mgmt and key_mgmt != "--")
|
||||
prefix = None
|
||||
plain_address = address
|
||||
if "/" in address:
|
||||
plain_address, raw_prefix = address.rsplit("/", 1)
|
||||
try:
|
||||
prefix = int(raw_prefix)
|
||||
except ValueError:
|
||||
prefix = None
|
||||
return {
|
||||
"connection_uuid": connection_uuid,
|
||||
"ssid": ssid,
|
||||
"password": password if password_configured else None,
|
||||
"password_configured": password_configured,
|
||||
"ipv4_mode": "dhcp" if method == "auto" else "manual",
|
||||
"address": plain_address or None,
|
||||
"prefix": prefix,
|
||||
"gateway": self._connection_value(connection_uuid, "ipv4.gateway") or None,
|
||||
"dns_servers": [item.strip() for item in raw_dns.splitlines() if item.strip()],
|
||||
}
|
||||
|
||||
def read_active(self) -> dict[str, Any]:
|
||||
raw_state = self._run([
|
||||
"--get-values", "GENERAL.STATE", "device", "show", self.interface,
|
||||
])
|
||||
connected = raw_state.startswith("100")
|
||||
connection_uuid = self._run([
|
||||
"--get-values", "GENERAL.CON-UUID", "device", "show", self.interface,
|
||||
]) or None
|
||||
addresses = [
|
||||
item.strip() for item in self._run([
|
||||
"--get-values", "IP4.ADDRESS", "device", "show", self.interface,
|
||||
]).splitlines() if item.strip()
|
||||
] if connected else []
|
||||
ssid = None
|
||||
if connected:
|
||||
rows = self._run([
|
||||
"--terse", "--fields", "IN-USE,SSID", "device", "wifi", "list",
|
||||
"ifname", self.interface,
|
||||
])
|
||||
for row in rows.splitlines():
|
||||
if row.startswith("*:"):
|
||||
ssid = _decode_terse(row[2:])
|
||||
break
|
||||
ipv4 = addresses[0].split("/", 1)[0] if addresses else None
|
||||
return {
|
||||
"connected": bool(connected and ipv4),
|
||||
"connection_uuid": connection_uuid,
|
||||
"ssid": ssid,
|
||||
"ipv4_address": ipv4,
|
||||
"control_url": f"http://{ipv4}:8080/" if ipv4 else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _modify_arguments(settings: dict[str, Any]) -> list[str]:
|
||||
arguments = [
|
||||
"802-11-wireless.ssid", settings["ssid"],
|
||||
"802-11-wireless-security.key-mgmt", "wpa-psk",
|
||||
]
|
||||
if settings.get("password") is not None:
|
||||
arguments.extend(["802-11-wireless-security.psk", settings["password"]])
|
||||
if settings["ipv4_mode"] == "dhcp":
|
||||
arguments.extend([
|
||||
"ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", "",
|
||||
"ipv4.dns", "",
|
||||
])
|
||||
else:
|
||||
dns = settings["dns_servers"] or [settings["gateway"]]
|
||||
arguments.extend([
|
||||
"ipv4.method", "manual",
|
||||
"ipv4.addresses", f"{settings['address']}/{settings['prefix']}",
|
||||
"ipv4.gateway", settings["gateway"],
|
||||
"ipv4.dns", ",".join(dns),
|
||||
])
|
||||
return arguments
|
||||
|
||||
def create_connection(self, settings: dict[str, Any]) -> str:
|
||||
name = "matrix-screen-controller-wifi"
|
||||
self._run([
|
||||
"connection", "add", "type", "wifi", "ifname", self.interface,
|
||||
"con-name", name, "ssid", settings["ssid"],
|
||||
])
|
||||
connection_uuid = self._run([
|
||||
"--get-values", "connection.uuid", "connection", "show", "id", name,
|
||||
])
|
||||
self.save_connection(connection_uuid, settings)
|
||||
return connection_uuid
|
||||
|
||||
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None:
|
||||
self._run([
|
||||
"connection", "modify", "uuid", connection_uuid,
|
||||
*self._modify_arguments(settings),
|
||||
])
|
||||
|
||||
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None:
|
||||
bounded = max(1, min(int(timeout), 30))
|
||||
self._run([
|
||||
"--wait", str(bounded), "connection", "up", "uuid", connection_uuid,
|
||||
], timeout=bounded + 2)
|
||||
|
||||
|
||||
class MockNetworkManager:
|
||||
def __init__(
|
||||
self,
|
||||
saved: dict[str, Any] | None = None,
|
||||
active: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.saved = deepcopy(saved)
|
||||
self.active = deepcopy(active) if active is not None else {
|
||||
"connected": False,
|
||||
"connection_uuid": None,
|
||||
"ssid": None,
|
||||
"ipv4_address": None,
|
||||
"control_url": None,
|
||||
}
|
||||
self.activations: list[str] = []
|
||||
self.fail_save = False
|
||||
self.fail_activate = False
|
||||
|
||||
def discover_connection_uuid(self) -> str | None:
|
||||
with self._lock:
|
||||
if self.active.get("connection_uuid"):
|
||||
return self.active["connection_uuid"]
|
||||
return self.saved.get("connection_uuid") if self.saved else None
|
||||
|
||||
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if not self.saved or self.saved.get("connection_uuid") != connection_uuid:
|
||||
raise NetworkManagerError("managed WiFi connection is unavailable")
|
||||
value = deepcopy(self.saved)
|
||||
if not include_secret:
|
||||
value["password"] = None
|
||||
return value
|
||||
|
||||
def read_active(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return deepcopy(self.active)
|
||||
|
||||
def create_connection(self, settings: dict[str, Any]) -> str:
|
||||
connection_uuid = str(uuid4())
|
||||
with self._lock:
|
||||
self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)}
|
||||
self.saved["password_configured"] = bool(settings.get("password"))
|
||||
return connection_uuid
|
||||
|
||||
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None:
|
||||
if self.fail_save:
|
||||
raise NetworkManagerError("NetworkManager rejected the requested operation")
|
||||
with self._lock:
|
||||
previous_password = self.saved.get("password") if self.saved else None
|
||||
self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)}
|
||||
if settings.get("password") is None:
|
||||
self.saved["password"] = previous_password
|
||||
self.saved["password_configured"] = bool(self.saved.get("password"))
|
||||
|
||||
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None:
|
||||
del timeout
|
||||
if self.fail_activate:
|
||||
raise NetworkManagerError("NetworkManager rejected the requested operation")
|
||||
with self._lock:
|
||||
self.activations.append(connection_uuid)
|
||||
if self.saved and self.saved.get("connection_uuid") == connection_uuid:
|
||||
existing_address = None
|
||||
if (
|
||||
self.saved.get("ipv4_mode") == "dhcp"
|
||||
and self.active.get("connected")
|
||||
and self.active.get("connection_uuid") == connection_uuid
|
||||
):
|
||||
existing_address = self.active.get("ipv4_address")
|
||||
address = self.saved.get("address") or existing_address or "192.168.1.100"
|
||||
self.active = {
|
||||
"connected": True,
|
||||
"connection_uuid": connection_uuid,
|
||||
"ssid": self.saved.get("ssid"),
|
||||
"ipv4_address": address,
|
||||
"control_url": f"http://{address}:8080/",
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
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")
|
||||
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 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,
|
||||
"password": password,
|
||||
"password_configured": 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._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:
|
||||
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})
|
||||
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": str(exc),
|
||||
})
|
||||
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:
|
||||
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.display.show_wifi_indicator(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.display.show_wifi_indicator(connected=False, message=message)
|
||||
except NetworkManagerError:
|
||||
self.display.show_wifi_indicator(connected=False, message="WiFi 状态不可用")
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from uuid import UUID
|
||||
|
||||
from app.display.startup_indicator import read_boot_id
|
||||
from app.persistence import atomic_write_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WIFI_CONFIG_SCHEMA_VERSION = 1
|
||||
DEFAULT_PROMPT_DELAY_SECONDS = 30
|
||||
MIN_PROMPT_DELAY_SECONDS = 1
|
||||
MAX_PROMPT_DELAY_SECONDS = 3600
|
||||
WIFI_CONFIG_FIELDS = frozenset({"managed_connection_uuid", "prompt_delay_seconds"})
|
||||
|
||||
|
||||
class WifiConfigError(ValueError):
|
||||
"""Raised when persisted WiFi metadata is unsafe or invalid."""
|
||||
|
||||
|
||||
def validate_wifi_config(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise WifiConfigError("wifi config must be a JSON object")
|
||||
unknown = set(value) - WIFI_CONFIG_FIELDS
|
||||
if unknown:
|
||||
raise WifiConfigError(f"unknown wifi config fields: {', '.join(sorted(unknown))}")
|
||||
missing = WIFI_CONFIG_FIELDS - set(value)
|
||||
if missing:
|
||||
raise WifiConfigError(f"wifi config is missing fields: {', '.join(sorted(missing))}")
|
||||
|
||||
managed_uuid = value["managed_connection_uuid"]
|
||||
if managed_uuid is not None:
|
||||
if not isinstance(managed_uuid, str):
|
||||
raise WifiConfigError("managed_connection_uuid must be a UUID string or null")
|
||||
try:
|
||||
managed_uuid = str(UUID(managed_uuid))
|
||||
except ValueError as exc:
|
||||
raise WifiConfigError("managed_connection_uuid must be a valid UUID") from exc
|
||||
|
||||
delay = value["prompt_delay_seconds"]
|
||||
if type(delay) is not int:
|
||||
raise WifiConfigError("prompt_delay_seconds must be an integer")
|
||||
if not MIN_PROMPT_DELAY_SECONDS <= delay <= MAX_PROMPT_DELAY_SECONDS:
|
||||
raise WifiConfigError(
|
||||
f"prompt_delay_seconds must be in {MIN_PROMPT_DELAY_SECONDS}.."
|
||||
f"{MAX_PROMPT_DELAY_SECONDS}"
|
||||
)
|
||||
return {
|
||||
"managed_connection_uuid": managed_uuid,
|
||||
"prompt_delay_seconds": delay,
|
||||
}
|
||||
|
||||
|
||||
class WifiConfigStore:
|
||||
def __init__(
|
||||
self,
|
||||
data_dir: Path,
|
||||
runtime_dir: Path,
|
||||
*,
|
||||
boot_id: str | None = None,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.path = Path(data_dir) / "wifi_config.json"
|
||||
self.session_path = Path(runtime_dir) / "wifi_session.json"
|
||||
self.boot_id = boot_id or read_boot_id()
|
||||
self.monotonic = monotonic
|
||||
self._lock = threading.RLock()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.session_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._config = self._load_config()
|
||||
self._session, self._new_boot_session = self._load_session()
|
||||
|
||||
@property
|
||||
def config(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._config)
|
||||
|
||||
@property
|
||||
def is_new_boot_session(self) -> bool:
|
||||
return self._new_boot_session
|
||||
|
||||
@property
|
||||
def session(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._session)
|
||||
|
||||
def update(self, values: dict[str, Any]) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
unknown = set(values) - WIFI_CONFIG_FIELDS
|
||||
if unknown:
|
||||
raise WifiConfigError(f"unknown wifi config fields: {', '.join(sorted(unknown))}")
|
||||
checked = validate_wifi_config({**self._config, **values})
|
||||
if checked != self._config:
|
||||
self._write_config(checked)
|
||||
self._config = checked
|
||||
return dict(self._config)
|
||||
|
||||
def dismiss_for_boot(self) -> bool:
|
||||
with self._lock:
|
||||
if self._session["dismissed"]:
|
||||
return False
|
||||
self._session["dismissed"] = True
|
||||
self._write_session(self._session)
|
||||
return True
|
||||
|
||||
def set_operation(self, operation: dict[str, Any]) -> None:
|
||||
safe = {
|
||||
"id": str(operation.get("id") or ""),
|
||||
"state": str(operation.get("state") or "idle"),
|
||||
"message": str(operation.get("message") or ""),
|
||||
}
|
||||
if safe["state"] not in {"idle", "scheduled", "applying", "succeeded", "failed"}:
|
||||
raise WifiConfigError("invalid wifi operation state")
|
||||
with self._lock:
|
||||
self._session["operation"] = safe
|
||||
self._write_session(self._session)
|
||||
|
||||
def _load_config(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
value = {
|
||||
"managed_connection_uuid": None,
|
||||
"prompt_delay_seconds": DEFAULT_PROMPT_DELAY_SECONDS,
|
||||
}
|
||||
self._write_config(value)
|
||||
return value
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise WifiConfigError(f"persisted wifi config is unreadable: {self.path}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise WifiConfigError("persisted wifi config must be a JSON object")
|
||||
document = dict(raw)
|
||||
version = document.pop("schema_version", None)
|
||||
if type(version) is not int:
|
||||
raise WifiConfigError("wifi config schema_version must be an integer")
|
||||
if version != WIFI_CONFIG_SCHEMA_VERSION:
|
||||
raise WifiConfigError(
|
||||
f"unsupported wifi config schema version {version}; "
|
||||
f"supported version is {WIFI_CONFIG_SCHEMA_VERSION}"
|
||||
)
|
||||
return validate_wifi_config(document)
|
||||
|
||||
def _load_session(self) -> tuple[dict[str, Any], bool]:
|
||||
now = float(self.monotonic())
|
||||
default = {
|
||||
"boot_id": self.boot_id,
|
||||
"deadline_monotonic": now + self._config["prompt_delay_seconds"],
|
||||
"dismissed": False,
|
||||
"operation": {"id": "", "state": "idle", "message": ""},
|
||||
}
|
||||
if self.session_path.exists():
|
||||
try:
|
||||
raw = json.loads(self.session_path.read_text(encoding="utf-8"))
|
||||
if (
|
||||
isinstance(raw, dict)
|
||||
and raw.get("boot_id") == self.boot_id
|
||||
and isinstance(raw.get("deadline_monotonic"), (int, float))
|
||||
and type(raw.get("dismissed")) is bool
|
||||
and isinstance(raw.get("operation"), dict)
|
||||
):
|
||||
return raw, False
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
logger.warning("Resetting unreadable runtime WiFi session")
|
||||
self._write_session(default)
|
||||
return default, True
|
||||
|
||||
def _write_config(self, value: dict[str, Any]) -> None:
|
||||
payload = {"schema_version": WIFI_CONFIG_SCHEMA_VERSION, **value}
|
||||
atomic_write_bytes(
|
||||
self.path,
|
||||
(json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
|
||||
)
|
||||
|
||||
def _write_session(self, value: dict[str, Any]) -> None:
|
||||
atomic_write_bytes(
|
||||
self.session_path,
|
||||
(json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
|
||||
)
|
||||
Reference in New Issue
Block a user