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"), )