185 lines
6.9 KiB
Python
185 lines
6.9 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 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"),
|
|
)
|