初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -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