365 lines
15 KiB
Python
365 lines
15 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.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:
|
|
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:
|
|
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 状态不可用")
|