from __future__ import annotations from copy import deepcopy from pathlib import Path from typing import Any, Callable from PySide6.QtCore import Property, QDateTime, QObject, Signal, Slot from pygui.backend.data.local_settings import LocalSettings MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z") class LocalSettingsModel(QObject): # Draft attrs are seeded via setattr() in __init__ from _new_defaults(); # declared here so mypy knows their types. _chamber_id: str _reverse_z_wafer: bool _debug_mode: bool _wafer_read_timeout: int _wafer_detect_timeout: int _wafer_retries: int _masters: dict[str, str] chamberIdChanged = Signal() reverseZWaferChanged = Signal() debugModeChanged = Signal() waferReadTimeoutChanged = Signal() waferDetectTimeoutChanged = Signal() waferRetriesChanged = Signal() mastersChanged = Signal() isDirtyChanged = Signal() isValidChanged = Signal() validationMessageChanged = Signal() saveStatusChanged = Signal() lastSavedAtChanged = Signal() # Draft attr (this class) <-> LocalSettings attr (the shared, persisted # instance also held by DeviceController) <-> coercer applied on both # load and revert/reset. One table drives load/save/revert/reset instead # of four hand-written field-by-field copies. _FIELD_MAP: tuple[tuple[str, str, Callable[[Any], Any]], ...] = ( ("_chamber_id", "chamber_id", str), ("_reverse_z_wafer", "reverse_z_wafer", bool), ("_debug_mode", "debug", bool), ("_wafer_read_timeout", "wafer_read_timeout", int), ("_wafer_detect_timeout", "wafer_detect_timeout", int), ("_wafer_retries", "wafer_read_retries", int), ) def __init__( self, settings: LocalSettings, data_dir: Path | str, parent: QObject | None = None, ) -> None: super().__init__(parent) # Shared with DeviceController — saving here must mutate this same # instance, not a throwaway copy, or the device layer keeps using # stale config until restart. self._settings = settings self._data_dir = Path(data_dir) self._defaults = self._new_defaults() for draft_attr, default_value in self._defaults.items(): setattr(self, draft_attr, deepcopy(default_value)) self._is_dirty = False self._is_valid = True self._validation_message = "" self._save_status = "Ready" self._last_saved_at = "" self._saved_snapshot = self._snapshot() self._recompute_derived() def _new_defaults(self) -> dict[str, Any]: return { "_chamber_id": "2", "_reverse_z_wafer": False, "_debug_mode": False, "_wafer_read_timeout": 120000, "_wafer_detect_timeout": 5000, "_wafer_retries": 8, "_masters": {family: "" for family in MASTER_FAMILIES}, } def _snapshot(self) -> dict[str, Any]: snap = {attr: getattr(self, attr) for attr, _, _ in self._FIELD_MAP} snap["_masters"] = deepcopy(self._masters) return snap def _set_is_dirty(self, value: bool) -> None: if self._is_dirty != value: self._is_dirty = value self.isDirtyChanged.emit() def _set_is_valid(self, value: bool) -> None: if self._is_valid != value: self._is_valid = value self.isValidChanged.emit() def _set_validation_message(self, value: str) -> None: if self._validation_message != value: self._validation_message = value self.validationMessageChanged.emit() def _set_save_status(self, value: str) -> None: if self._save_status != value: self._save_status = value self.saveStatusChanged.emit() def _set_last_saved_at(self, value: str) -> None: if self._last_saved_at != value: self._last_saved_at = value self.lastSavedAtChanged.emit() def _validate(self) -> str: if not self._chamber_id.strip(): return "Chamber ID is required." if self._wafer_retries < 0 or self._wafer_retries > 10: return "Retries must be between 0 and 10." if self._wafer_read_timeout < 100 or self._wafer_read_timeout > 300000: return "Read timeout must be between 100 and 300000 ms." if self._wafer_detect_timeout < 100 or self._wafer_detect_timeout > 300000: return "Detect timeout must be between 100 and 300000 ms." for family, path_text in self._masters.items(): cleaned = path_text.strip() if not cleaned: continue path = Path(cleaned) if path.suffix.lower() != ".csv": return f"Master {family} must point to a CSV file." if not path.exists(): return f"Master {family} file was not found." return "" def _recompute_derived(self) -> None: message = self._validate() self._set_is_valid(message == "") self._set_validation_message(message) self._set_is_dirty(self._snapshot() != self._saved_snapshot) def _emit_all_changed(self) -> None: self.chamberIdChanged.emit() self.reverseZWaferChanged.emit() self.debugModeChanged.emit() self.waferReadTimeoutChanged.emit() self.waferDetectTimeoutChanged.emit() self.waferRetriesChanged.emit() self.mastersChanged.emit() def _normalized_masters(self, source: dict[str, str]) -> dict[str, str]: masters = {family: "" for family in MASTER_FAMILIES} for family, value in source.items(): normalized = str(family).strip().upper() if normalized in masters: masters[normalized] = str(value).strip() return masters @Property(str, notify=chamberIdChanged) def chamberId(self) -> str: return self._chamber_id @chamberId.setter # type: ignore[no-redef] def chamberId(self, value: str) -> None: next_value = str(value).strip() if self._chamber_id == next_value: return self._chamber_id = next_value self.chamberIdChanged.emit() self._recompute_derived() @Property(bool, notify=reverseZWaferChanged) def reverseZWafer(self) -> bool: return self._reverse_z_wafer @reverseZWafer.setter # type: ignore[no-redef] def reverseZWafer(self, value: bool) -> None: if self._reverse_z_wafer == value: return self._reverse_z_wafer = bool(value) self.reverseZWaferChanged.emit() self._recompute_derived() @Property(bool, notify=debugModeChanged) def debugMode(self) -> bool: return self._debug_mode @debugMode.setter # type: ignore[no-redef] def debugMode(self, value: bool) -> None: if self._debug_mode == value: return self._debug_mode = bool(value) self.debugModeChanged.emit() self._recompute_derived() @Property(int, notify=waferReadTimeoutChanged) def waferReadTimeout(self) -> int: return self._wafer_read_timeout @waferReadTimeout.setter # type: ignore[no-redef] def waferReadTimeout(self, value: int) -> None: if self._wafer_read_timeout == value: return self._wafer_read_timeout = int(value) self.waferReadTimeoutChanged.emit() self._recompute_derived() @Property(int, notify=waferDetectTimeoutChanged) def waferDetectTimeout(self) -> int: return self._wafer_detect_timeout @waferDetectTimeout.setter # type: ignore[no-redef] def waferDetectTimeout(self, value: int) -> None: if self._wafer_detect_timeout == value: return self._wafer_detect_timeout = int(value) self.waferDetectTimeoutChanged.emit() self._recompute_derived() @Property(int, notify=waferRetriesChanged) def waferRetries(self) -> int: return self._wafer_retries @waferRetries.setter # type: ignore[no-redef] def waferRetries(self, value: int) -> None: if self._wafer_retries == value: return self._wafer_retries = int(value) self.waferRetriesChanged.emit() self._recompute_derived() @Property(dict, notify=mastersChanged) def masters(self) -> dict[str, str]: return dict(self._masters) @Property(bool, notify=isDirtyChanged) def isDirty(self) -> bool: return self._is_dirty @Property(bool, notify=isValidChanged) def isValid(self) -> bool: return self._is_valid @Property(str, notify=validationMessageChanged) def validationMessage(self) -> str: return self._validation_message @Property(str, notify=saveStatusChanged) def saveStatus(self) -> str: return self._save_status @Property(str, notify=lastSavedAtChanged) def lastSavedAt(self) -> str: return self._last_saved_at @Property(str, notify=saveStatusChanged) def settingsFilePath(self) -> str: return str(self._data_dir / "settings.json") @Slot() def loadSettings(self) -> None: """Seed draft fields from the shared LocalSettings instance.""" for draft_attr, settings_attr, coerce in self._FIELD_MAP: setattr(self, draft_attr, coerce(getattr(self._settings, settings_attr))) self._chamber_id = self._chamber_id.strip() or str(self._defaults["_chamber_id"]) self._masters = self._normalized_masters(self._settings.master) self._saved_snapshot = self._snapshot() self._emit_all_changed() self._recompute_derived() self._set_save_status("Loaded settings") @Slot() def saveSettings(self) -> None: """Write draft fields onto the shared LocalSettings instance and persist it.""" self._recompute_derived() if not self._is_valid: self._set_save_status("error: invalid settings") return try: for draft_attr, settings_attr, _coerce in self._FIELD_MAP: setattr(self._settings, settings_attr, getattr(self, draft_attr)) self._settings.master = deepcopy(self._masters) LocalSettings.save_settings(str(self._data_dir), self._settings) self._saved_snapshot = self._snapshot() self._recompute_derived() self._set_save_status("Saved") timestamp = QDateTime.currentDateTime().toString("yyyy-MM-dd HH:mm:ss") self._set_last_saved_at(timestamp) except Exception as exc: # pragma: no cover - defensive self._set_save_status(f"error: {exc}") @Slot() def revertChanges(self) -> None: snap = self._saved_snapshot for draft_attr, _settings_attr, coerce in self._FIELD_MAP: setattr(self, draft_attr, coerce(snap[draft_attr])) self._masters = deepcopy(snap["_masters"]) self._emit_all_changed() self._recompute_derived() self._set_save_status("Reverted unsaved changes") @Slot() def resetDefaults(self) -> None: for draft_attr, _settings_attr, coerce in self._FIELD_MAP: setattr(self, draft_attr, coerce(self._defaults[draft_attr])) self._masters = deepcopy(self._defaults["_masters"]) self._emit_all_changed() self._recompute_derived() self._set_save_status("Defaults restored (not saved)") @Slot(str, str) def setMaster(self, family: str, file_path: str) -> None: normalized = family.strip().upper() if normalized not in self._masters: return next_path = file_path.strip() if self._masters[normalized] == next_path: return self._masters[normalized] = next_path self.mastersChanged.emit() self._recompute_derived() @Slot(str) def clearMaster(self, family: str) -> None: self.setMaster(family, "")