Files
pyGUI/src/pygui/backend/data/local_settings_model.py
T

333 lines
12 KiB
Python

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