初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -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"),
)