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

188 lines
7.1 KiB
Python

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 operation.get("error_code") is not None:
code = operation["error_code"]
safe["error_code"] = code if code in {"AUTH_FAILED", "NETWORK_UNAVAILABLE", "IP_CONFIG_FAILED", "UNKNOWN"} else "UNKNOWN"
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"),
)