From 67158ed7ab3d1a9f8a655498722876e056c1b7e6 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 12 Jul 2026 20:02:25 -0700 Subject: [PATCH] feat(license): wafer license gate, runtime warning, and first-launch wizard - Gate READ/ERASE/DEBUG behind wafer license check; log hint to load .bin - waferRuntimeExceeded() warns when onboard runtime > 350s - LicenseModel: remove-license (soft hide, .bin stays on disk) via removed_licenses.txt; re-loading un-hides - FirstLaunchWizard: 2-step popup (license + save dir) shown on first run - Per-tab file memory in HomePage (map/graph/data don't bleed state) - Merge test_device_controller_license into test_device_controller --- src/pygui/ISC/AboutDialog.qml | 9 + src/pygui/ISC/FirstLaunchWizard.qml | 148 ++++++++++++++++ src/pygui/ISC/HomePage.qml | 138 +++++++++++++-- .../ISC/Tabs/components/RailActionButton.qml | 14 +- src/pygui/ISC/Tabs/components/SourcePanel.qml | 11 +- .../Tabs/components/StatusActionsPanel.qml | 12 +- .../Tabs/components/StreamControlPanel.qml | 16 ++ src/pygui/ISC/qmldir | 1 + src/pygui/__main__.py | 16 +- .../backend/controllers/device_controller.py | 55 +++++- src/pygui/backend/license/license_model.py | 75 +++++++- .../backend/visualization/wafer_map_item.py | 14 +- tests/test_device_controller.py | 162 ++++++++++++++++++ tests/test_device_controller_license.py | 8 - tests/test_license.py | 74 ++++++++ 15 files changed, 699 insertions(+), 54 deletions(-) create mode 100644 src/pygui/ISC/FirstLaunchWizard.qml delete mode 100644 tests/test_device_controller_license.py diff --git a/src/pygui/ISC/AboutDialog.qml b/src/pygui/ISC/AboutDialog.qml index 3bf7058..386efab 100644 --- a/src/pygui/ISC/AboutDialog.qml +++ b/src/pygui/ISC/AboutDialog.qml @@ -111,6 +111,7 @@ Popup { Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 } Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 } Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true } + Item { Layout.preferredWidth: 28 } } Rectangle { @@ -132,6 +133,14 @@ Popup { Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 } Label { text: modelData.level; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 80 } Label { text: modelData.licDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.fillWidth: true } + ToolButton { + Layout.preferredWidth: 28 + text: "✕" + font.pixelSize: Theme.fontSm + ToolTip.visible: hovered + ToolTip.text: "Remove license" + onClicked: licenseModel.removeLicense(modelData.serial, modelData.level) + } } } diff --git a/src/pygui/ISC/FirstLaunchWizard.qml b/src/pygui/ISC/FirstLaunchWizard.qml new file mode 100644 index 0000000..2b43083 --- /dev/null +++ b/src/pygui/ISC/FirstLaunchWizard.qml @@ -0,0 +1,148 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import QtQuick.Layouts +import ISC + +// ===== First-Launch Wizard ===== +// Shown once, on first launch (no settings.json yet). Two skippable steps: +// load a license, then pick a save folder. Either step can be skipped — +// review-mode features work without a license, and save dir already has a +// sensible default, so this is guidance, not a gate. +Popup { + id: root + modal: true + dim: true + closePolicy: Popup.NoAutoClose + width: 520 + height: 320 + anchors.centerIn: Overlay.overlay + + property int step: 0 + + signal finished() + + background: Rectangle { + radius: Theme.radiusMd + color: Theme.cardBackground + border.color: Theme.cardBorder + border.width: 1 + } + + FileDialog { + id: licenseFileDialog + title: "Load License" + nameFilters: ["License files (*.bin)"] + onAccepted: { + licenseModel.loadLicenseFile(selectedFile.toString()) + root.step = 1 + } + } + + FolderDialog { + id: saveDirDialog + title: "Choose a folder to save data" + onAccepted: { + var dir = decodeURIComponent(String(selectedFolder).replace(/^file:\/\//, "")) + deviceController.setSaveDataDir(dir) + file_browser.setCurrentDirectory(dir) + root.finished() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 28 + spacing: 20 + + Label { + text: "Welcome to ISenseCloud" + font.pixelSize: Theme.font2xl + font.bold: true + color: Theme.headingColor + } + + // ── Step 1: License ── + ColumnLayout { + visible: root.step === 0 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + + Label { + text: "Load your wafer license to enable live device actions\n(Detect, Read, Erase, Debug). You can also do this later\nfrom About." + font.pixelSize: Theme.fontMd + color: Theme.bodyColor + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + Item { Layout.fillHeight: true } + } + + // ── Step 2: Save Directory ── + ColumnLayout { + visible: root.step === 1 + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + + Label { + text: "Choose where wafer read data gets saved. If you skip\nthis, it defaults to a \"csv\" folder inside your app data\ndirectory — changeable anytime from Status." + font.pixelSize: Theme.fontMd + color: Theme.bodyColor + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + Item { Layout.fillHeight: true } + } + + // ── Buttons ── + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Button { + text: "Skip" + Layout.preferredWidth: 100 + Layout.preferredHeight: 36 + onClicked: root.step === 0 ? (root.step = 1) : root.finished() + + background: Rectangle { + radius: Theme.radiusSm + color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground + border.color: Theme.fieldBorder + border.width: 1 + } + contentItem: Label { + text: parent.text + color: Theme.buttonNeutralText + font.pixelSize: Theme.fontMd + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + Item { Layout.fillWidth: true } + + Button { + text: root.step === 0 ? "Load License" : "Choose Folder" + Layout.preferredWidth: 160 + Layout.preferredHeight: 36 + onClicked: root.step === 0 ? licenseFileDialog.open() : saveDirDialog.open() + + background: Rectangle { + radius: Theme.radiusSm + color: Theme.primaryAccent + } + contentItem: Label { + text: parent.text + color: "#FFFFFF" + font.pixelSize: Theme.fontMd + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } +} diff --git a/src/pygui/ISC/HomePage.qml b/src/pygui/ISC/HomePage.qml index f376e5e..302b877 100644 --- a/src/pygui/ISC/HomePage.qml +++ b/src/pygui/ISC/HomePage.qml @@ -19,20 +19,70 @@ Rectangle { // ===== View State ===== property int selectedTabIndex: 0 + + // Map, Graph, and Data each keep their OWN remembered file selection — + // switching tabs must not bleed one tab's pick into another. streamController + // itself only holds ONE loaded file at a time (it's a single review session), + // so "per-tab selection" means: remember the filename per tab here, and + // re-load the right one into that single session whenever the tab becomes + // active again. Data tab's compareFileA/B are tracked the same way further + // below (Connections on dataTabLoader.item). + property string mapReviewFile: "" + property string graphReviewFile: "" + property string dataCompareFileA: "" + property string dataCompareFileB: "" + onSelectedTabIndexChanged: { if (selectedTabIndex === 2) { file_browser.refreshFiles(); - streamController.unloadFile() - } else if (streamController.mode === "live") { - // Leaving the map tab mid-stream: stop and blank the live map. - streamController.setMode("review") + if (root.mapReviewFile !== "") { + if (streamController.loadedFile !== root.mapReviewFile) + streamController.loadFile(root.mapReviewFile) + } else if (streamController.loadedFile !== "") { + // Map has no memory of its own yet — don't inherit whatever + // another tab (e.g. Data's last Run A/B pick) left loaded. + streamController.unloadFile() + } + } else if (selectedTabIndex === 3) { + if (root.graphReviewFile !== "") { + if (streamController.loadedFile !== root.graphReviewFile) + streamController.loadFile(root.graphReviewFile) + } else if (streamController.loadedFile !== "") { + streamController.unloadFile() + } } + // Leaving Map tab no longer force-stops a live stream: streamController + // (StreamReader + timers) is parented to itself, not to this QML tree, + // so acquisition keeps running in the background. The Map Loader just + // deactivates — trendChart's local 60s buffer resets on return (it's + // QML-item-local state), but the wafer map and stream resume live. + } + + // Records which tab's memory a file pick belongs to. Only fires for + // picks made while actually on Map(2) or Graph(3) — Data tab's own + // Connections (further below) captures its own picks independently, + // and this is silent while on Status(0)/Data(1) for any other load. + Connections { + target: streamController + function onLoadedFileChanged() { + if (root.selectedTabIndex === 2) root.mapReviewFile = streamController.loadedFile + else if (root.selectedTabIndex === 3) root.graphReviewFile = streamController.loadedFile + } + } + + // Data tab's Run A/B picks survive it being destroyed/recreated by its + // Loader (see dataTabLoader below) by mirroring them here. + Connections { + target: dataTabLoader.item + function onCompareFileAChanged() { root.dataCompareFileA = dataTabLoader.item.compareFileA } + function onCompareFileBChanged() { root.dataCompareFileB = dataTabLoader.item.compareFileB } } property bool memoryRead: false property bool waferDetected: false + property bool waferLicensed: false Connections { target: deviceController @@ -41,16 +91,41 @@ Rectangle { // as an opaque wrapper whose fields read as undefined, so only // test truthiness (None on failure, dict on success). root.waferDetected = !!result + root.waferLicensed = root.waferDetected && deviceController.waferLicensed() + // Wafer's own onboard runtime — not a local file a technician can + // reset — is what starts the review trial clock. startReplayTrial() + // is a true no-op once the marker exists (see license_model.py), + // so re-detecting the same over-limit wafer never extends it. + if (root.waferDetected && deviceController.waferRuntimeExceeded()) { + licenseModel.startReplayTrial() + } } function onParsedDataReady(result) { if (result.success && result.csv_path) { streamController.loadFile(result.csv_path) - if (root.selectedTabIndex === 0) { + // Fires while still on Status(0), before the tab switch below — + // the generic per-tab tracker only attributes picks made while + // already on Map/Graph, so record this one explicitly. + root.mapReviewFile = result.csv_path + if (root.selectedTabIndex === 0 && licenseModel.licenses.length > 0) { root.selectedTabIndex = 2 } } } } + Connections { + target: licenseModel + function onLicensesChanged() { + // Map tab is license-gated: if its licenses vanish while it's + // selected, snap back to STATUS instead of showing a bright + // pill over an unloaded tab. Also re-check the wafer license + // live so Read/Erase don't stay stale until the next detect. + if (root.selectedTabIndex === 2 && licenseModel.licenses.length === 0) { + root.selectedTabIndex = 0 + } + root.waferLicensed = root.waferDetected && deviceController.waferLicensed() + } + } Connections { target: deviceController function onReadResult(result) { @@ -181,6 +256,26 @@ Rectangle { id: aboutDialog } + // ===== First-Launch Wizard ===== + FirstLaunchWizard { + id: firstLaunchWizard + onFinished: { + close() + // Wizard closed with no license loaded (skipped, or Load License + // never landed a valid file) — start the 30-day review trial now + // rather than waiting for a wafer to be detected over-runtime. + if (licenseModel.licenses.length === 0) { + licenseModel.startReplayTrial() + } + settingsModel.saveSettings() + } + } + Component.onCompleted: { + if (isFirstLaunch) { + firstLaunchWizard.open() + } + } + // ===== Main Layout: Rail + Workspace ===== RowLayout { anchors.fill: parent @@ -246,6 +341,11 @@ Rectangle { delegate: Button { id: pillBtn checked: root.selectedTabIndex === index + // MAP (index 2) is locked until a valid license .bin is installed. + // Stays enabled (not `enabled: false`) so hover still fires and the + // tooltip can explain why — a disabled Item receives no mouse/hover + // events at all, which would silently kill the tooltip below. + property bool locked: index === 2 && licenseModel.licenses.length === 0 flat: true Layout.fillWidth: true Layout.fillHeight: true @@ -276,7 +376,7 @@ Rectangle { source: model.icon sourceSize.width: 16; sourceSize.height: 16 color: (pillBtn.checked || pillBtn.hovered) ? Theme.headingColor : Theme.sideMutedText - opacity: pillBtn.checked ? 0.9 : pillBtn.hovered ? 0.85 : 0.4 + opacity: !pillBtn.locked ? (pillBtn.checked ? 0.9 : pillBtn.hovered ? 0.85 : 0.4) : 0.2 Behavior on color { ColorAnimation { duration: Theme.durationFast } } @@ -295,7 +395,7 @@ Rectangle { font.pixelSize: Theme.fontSm font.bold: true font.letterSpacing: 1.0 - opacity: pillBtn.checked ? 1.0 : 0.0 + opacity: !pillBtn.locked ? (pillBtn.checked ? 1.0 : 0.0) : 0.25 clip: true Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } @@ -311,10 +411,12 @@ Rectangle { AppToolTip { visible: pillBtn.hovered - text: "" + model.label + ": " + model.desc + text: (index === 2 && licenseModel.licenses.length === 0) + ? "MAP: No license loaded — load one in About to unlock" + : "" + model.label + ": " + model.desc } - onClicked: root.selectedTabIndex = index + onClicked: if (!pillBtn.locked) root.selectedTabIndex = index } } } @@ -594,6 +696,8 @@ Rectangle { SourcePanel { anchors.fill: parent anchors.margins: 14 + selectedTabIndex: root.selectedTabIndex + dataTab: dataTabLoader.item } } } @@ -632,10 +736,24 @@ Rectangle { id: dataTabLoader source: "Tabs/DataTab.qml" active: StackLayout.index === root.selectedTabIndex + // Component is still recreated lazily on every tab switch + // (memory-cheap, matches Status/Map/Graph) — what survives + // is just the two filenames (root.dataCompareFileA/B), + // restored here so Run A/B picks and their two-color + // highlight aren't lost on return. Computed DTW results + // are NOT re-run automatically — re-fetching is a real + // (if fast) backend call, so the user re-clicks "Run DTW + // Comparison" rather than it silently firing on tab entry. + onLoaded: { + item.compareFileA = root.dataCompareFileA + item.compareFileB = root.dataCompareFileB + item.activeBox = root.dataCompareFileB !== "" ? 0 + : root.dataCompareFileA !== "" ? 2 : 1 + } } Loader { source: "Tabs/WaferMapTab.qml" - active: StackLayout.index === root.selectedTabIndex + active: StackLayout.index === root.selectedTabIndex && licenseModel.licenses.length > 0 } Loader { source: "Tabs/GraphTab.qml" diff --git a/src/pygui/ISC/Tabs/components/RailActionButton.qml b/src/pygui/ISC/Tabs/components/RailActionButton.qml index 47a1207..71d66ce 100644 --- a/src/pygui/ISC/Tabs/components/RailActionButton.qml +++ b/src/pygui/ISC/Tabs/components/RailActionButton.qml @@ -15,6 +15,9 @@ Rectangle { // Set true to show the button as "active" (e.g. a one-shot toggle // that's mid-operation), independent of hover/press state. property bool toggled: false + // Shown on hover even while disabled, e.g. to explain *why* a greyed-out + // action is unavailable. Empty string shows no tooltip. + property string tooltipText: "" property color _textColor: !root.enabled ? Theme.sideMutedText : root.destructive ? Theme.statusErrorColor : Theme.headingColor @@ -23,7 +26,7 @@ Rectangle { implicitHeight: Theme.sideButtonHeight radius: 8 - color: root.toggled || mouseArea.containsMouse || mouseArea.pressed + color: root.toggled || (root.enabled && (mouseArea.containsMouse || mouseArea.pressed)) ? Theme.sideActiveBackground : "transparent" border.width: 0 @@ -64,10 +67,17 @@ Rectangle { MouseArea { id: mouseArea anchors.fill: parent - hoverEnabled: root.enabled + // Hover stays live while disabled so tooltipText can explain why — + // click is still gated on root.enabled below. + hoverEnabled: true cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: { if (root.enabled) root.clicked() } } + + AppToolTip { + visible: root.tooltipText.length > 0 && mouseArea.containsMouse + text: root.tooltipText + } } diff --git a/src/pygui/ISC/Tabs/components/SourcePanel.qml b/src/pygui/ISC/Tabs/components/SourcePanel.qml index 5df2bac..a76116b 100644 --- a/src/pygui/ISC/Tabs/components/SourcePanel.qml +++ b/src/pygui/ISC/Tabs/components/SourcePanel.qml @@ -13,6 +13,11 @@ ColumnLayout { property string exportSummary: "" property string importSummary: "" + // Set by HomePage.qml — a separate .qml file has its own id scope, so + // dataTabLoader/root from HomePage aren't reachable here directly. + property int selectedTabIndex: 0 + property var dataTab: null + Connections { target: batchExportController function onExportFinished(summary) { @@ -352,11 +357,7 @@ ColumnLayout { return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0; } - readonly property var dataTab: { - try { - return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null; - } catch (e) { return null; } - } + readonly property var dataTab: root.selectedTabIndex === 1 ? root.dataTab : null // Run A's effective file: the master file when "Compare vs Master // File" is toggled on, otherwise the manually selected file. diff --git a/src/pygui/ISC/Tabs/components/StatusActionsPanel.qml b/src/pygui/ISC/Tabs/components/StatusActionsPanel.qml index 746c09c..24ed6b7 100644 --- a/src/pygui/ISC/Tabs/components/StatusActionsPanel.qml +++ b/src/pygui/ISC/Tabs/components/StatusActionsPanel.qml @@ -44,7 +44,9 @@ ColumnLayout { label: "READ MEMORY" iconSource: "../icons/read.svg" Layout.fillWidth: true - enabled: deviceController.waferDetected && !deviceController.operationInProgress + enabled: deviceController.waferDetected && root.waferLicensed && !deviceController.operationInProgress + tooltipText: (deviceController.waferDetected && !root.waferLicensed) + ? "Wafer not licensed — load its license .bin (About dialog)" : "" onClicked: deviceController.readMemoryAsync() } @@ -53,7 +55,9 @@ ColumnLayout { iconSource: "../icons/erase.svg" Layout.fillWidth: true destructive: true - enabled: deviceController.waferDetected && !deviceController.operationInProgress + enabled: deviceController.waferDetected && root.waferLicensed && !deviceController.operationInProgress + tooltipText: (deviceController.waferDetected && !root.waferLicensed) + ? "Wafer not licensed — load its license .bin (About dialog)" : "" onClicked: deviceController.eraseMemory( deviceController.selectedPort) } @@ -65,7 +69,9 @@ ColumnLayout { // One-shot toggle: "on" while the debug read is in flight, // resets automatically once deviceController flips status. toggled: deviceController.connectionStatus === "Reading debug..." - enabled: deviceController.waferDetected && !deviceController.operationInProgress + enabled: deviceController.waferDetected && root.waferLicensed && !deviceController.operationInProgress + tooltipText: (deviceController.waferDetected && !root.waferLicensed) + ? "Wafer not licensed — load its license .bin (About dialog)" : "" onClicked: deviceController.readDebug(deviceController.selectedPort) } } diff --git a/src/pygui/ISC/Tabs/components/StreamControlPanel.qml b/src/pygui/ISC/Tabs/components/StreamControlPanel.qml index b6cbf2d..8908cfb 100644 --- a/src/pygui/ISC/Tabs/components/StreamControlPanel.qml +++ b/src/pygui/ISC/Tabs/components/StreamControlPanel.qml @@ -62,7 +62,22 @@ Rectangle { TabBar { id: modeBar + // Guards the programmatic sync below from re-triggering + // onCurrentIndexChanged's setMode()/startStream() side effects. + property bool _syncing: false currentIndex: 0 + Component.onCompleted: { + // WaferMapTab (and this panel) is destroyed/recreated by + // its Loader on every tab switch, so this TabBar always + // starts fresh at index 0 — but streamController.mode is + // backend state that outlives the switch (streaming now + // continues off-tab). Sync the visual index to the real + // mode on creation instead of defaulting to "Review" and + // silently disagreeing with a still-live stream. + _syncing = true + currentIndex = streamController.mode === "live" ? 1 : 0 + _syncing = false + } spacing: 2 padding: 2 Layout.fillWidth: true @@ -152,6 +167,7 @@ Rectangle { } onCurrentIndexChanged: { + if (_syncing) return; if (currentIndex === 1) { if (deviceController.connectionStatus !== "Connected") { currentIndex = 0; diff --git a/src/pygui/ISC/qmldir b/src/pygui/ISC/qmldir index b6d2841..258c288 100644 --- a/src/pygui/ISC/qmldir +++ b/src/pygui/ISC/qmldir @@ -5,6 +5,7 @@ module ISC Main 1.0 Main.qml HomePage 1.0 HomePage.qml AboutDialog 1.0 AboutDialog.qml +FirstLaunchWizard 1.0 FirstLaunchWizard.qml # ===== Singleton Theme ===== singleton Theme 1.0 Theme.qml diff --git a/src/pygui/__main__.py b/src/pygui/__main__.py index 72c5cae..eb4c02f 100644 --- a/src/pygui/__main__.py +++ b/src/pygui/__main__.py @@ -49,6 +49,9 @@ def main() -> int: # Settings tab is visible to the device layer immediately — no reload, # no second copy to go stale. data_dir = str(default_data_dir()) + # settings.json only exists once something has saved — absence means this + # machine has never run the app before (first-launch wizard trigger). + is_first_launch = not (Path(data_dir) / "settings.json").exists() raw_settings = LocalSettings.read_settings(data_dir) settings_model = LocalSettingsModel(raw_settings, data_dir) @@ -56,6 +59,7 @@ def main() -> int: settings_model.loadSettings() engine.rootContext().setContextProperty("settingsModel", settings_model) engine.rootContext().setContextProperty("file_browser", select_file_dialog_model) + engine.rootContext().setContextProperty("isFirstLaunch", is_first_launch) # App version from package metadata so the About dialog can't drift # from pyproject.toml. @@ -65,17 +69,19 @@ def main() -> int: app_version = "dev" engine.rootContext().setContextProperty("appVersion", app_version) + # ===== License Model (About dialog grid, replay trial) ===== + license_model = LicenseModel(data_dir) + engine.rootContext().setContextProperty("licenseModel", license_model) + # ===== Device Controller (serial comm) ===== - device_controller = DeviceController(raw_settings, data_dir) + device_controller = DeviceController( + raw_settings, data_dir, license_lookup=license_model.levelForWafer + ) engine.rootContext().setContextProperty("deviceController", device_controller) # Source panel browses the same folder recordings/reads are saved to. select_file_dialog_model.setCurrentDirectory(str(device_controller.saveDataDir)) - # ===== License Model (About dialog grid, replay trial) ===== - license_model = LicenseModel(data_dir) - engine.rootContext().setContextProperty("licenseModel", license_model) - # ===== Session Controller (live/review wafer dashboard) ===== stream_controller = SessionController() engine.rootContext().setContextProperty("streamController", stream_controller) diff --git a/src/pygui/backend/controllers/device_controller.py b/src/pygui/backend/controllers/device_controller.py index 96f65e8..31ce017 100644 --- a/src/pygui/backend/controllers/device_controller.py +++ b/src/pygui/backend/controllers/device_controller.py @@ -9,7 +9,7 @@ import sys import threading from datetime import datetime from pathlib import Path -from typing import Any, Optional +from typing import Any, Callable, Optional from PySide6.QtCore import Property, QObject, Qt, Signal, Slot @@ -29,6 +29,12 @@ from pygui.serialcomm.serial_port import WaferInfo log = logging.getLogger(__name__) +# ponytail: placeholder pending real hardware spec — the C# reference had two +# runtime numbers (350, 21600s) but neither was ever an enforced comparison, +# just a dead constant and a display string. Recalibrate once product gives +# the actual recommended figure. +WAFER_RUNTIME_WARNING_SECONDS = 350 + class DeviceController(QObject): """Controls serial communication with the temperature-sensing wafer. @@ -57,10 +63,17 @@ class DeviceController(QObject): _eraseFinished = Signal(bool) # (success) _debugFinished = Signal(dict) # (result_dict) - def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None: + def __init__( + self, + settings: LocalSettings, + data_dir: str, + license_lookup: Callable[[str], str] = lambda s: "", + parent: QObject | None = None, + ) -> None: super().__init__(parent) self._settings = settings self._data_dir = data_dir + self._license_lookup = license_lookup from pygui.serialcomm.device_service import DeviceService self._service = DeviceService(settings) @@ -266,6 +279,21 @@ class DeviceController(QObject): "series": series_data, } + @Slot(result=bool) + def waferLicensed(self) -> bool: + """Check if the current wafer has a valid license.""" + return self._wafer_license_level() != "" + + @Slot(result=bool) + def waferRuntimeExceeded(self) -> bool: + """True if the detected wafer's onboard runtime is past the recommended limit.""" + runtime = int(self._last_wafer_info.get("runtime", 0)) + return runtime > WAFER_RUNTIME_WARNING_SECONDS + + def _wafer_license_level(self) -> str: + """Get license level for the current wafer serial number.""" + return self._license_lookup(self._last_wafer_info.get("serialNumber", "")) + def _append_log(self, message: str) -> None: """Append a message to the activity log and emit updated log.""" ts = datetime.now().strftime("%H:%M:%S") @@ -383,6 +411,12 @@ class DeviceController(QObject): self._last_wafer_info = wafer_dict self._set_wafer_detected(True) self._set_connection_status("Connected") + if self.waferRuntimeExceeded(): + self._append_log( + f"Warning: wafer runtime {wafer_dict['runtime']}s exceeds " + f"recommended {WAFER_RUNTIME_WARNING_SECONDS}s — " + "consider ordering a replacement wafer" + ) self.detectResult.emit(wafer_dict) self.selectedPortChanged.emit() self._save_status() @@ -410,6 +444,11 @@ class DeviceController(QObject): self._append_log("Already busy — ignoring read request") return + if self._wafer_license_level() == "": + self._append_log("Wafer not licensed — load its license .bin (About dialog)") + self.readResult.emit({"error": "Wafer not licensed"}) + return + if not self._selected_port: self._append_log("No wafer detected — run Detect Wafer first") self.readResult.emit({"error": "No port — detect wafer first"}) @@ -470,6 +509,11 @@ class DeviceController(QObject): self._append_log("Already busy — ignoring erase request") return + if self._wafer_license_level() == "": + self._append_log("Wafer not licensed — load its license .bin (About dialog)") + self.eraseResult.emit({"success": False}) + return + self._set_operation_progress(True) self._set_connection_status("Erasing...") self.portsUpdated.emit(self._service.enumerate_ports()) @@ -510,6 +554,13 @@ class DeviceController(QObject): if self._operation_in_progress: self._append_log("Already busy — ignoring debug read request") return + + # Debug read returns D1 sensor data — same license boundary as READ MEMORY. + if self._wafer_license_level() == "": + self._append_log("Wafer not licensed — load its license .bin (About dialog)") + self.debugResult.emit({"success": False, "error": "Wafer not licensed"}) + return + self._set_operation_progress(True) self._set_connection_status("Reading debug...") self.portsUpdated.emit(self._service.enumerate_ports()) diff --git a/src/pygui/backend/license/license_model.py b/src/pygui/backend/license/license_model.py index c2fd12e..e6d6068 100644 --- a/src/pygui/backend/license/license_model.py +++ b/src/pygui/backend/license/license_model.py @@ -33,7 +33,10 @@ class LicenseModel(QObject): Licenses live in ``/licenses`` (mirrors C#'s ``DataDir\\licenses``). The 30-day replay trial marker is - ``/replay.lic``. + ``/replay.lic``. "Removed" licenses are tracked in + ``/removed_licenses.txt`` (one ``serial-level`` per line) — + removal hides a license from the app without deleting its ``.bin``, + so the vendor-issued file always stays recoverable on disk. """ licensesChanged = Signal() @@ -65,8 +68,12 @@ class LicenseModel(QObject): @Slot() def refresh(self) -> None: - """Re-scan the licenses directory.""" - self._licenses = read_license_files(self._licenses_dir) + """Re-scan the licenses directory, filtering out removed entries.""" + removed = self._read_removed() + self._licenses = [ + lic for lic in read_license_files(self._licenses_dir) + if f"{lic.serial}-{lic.level}" not in removed + ] self.licensesChanged.emit() # ── Load License ────────────────────────────────────────────────── @@ -92,13 +99,61 @@ class LicenseModel(QObject): log.warning("License file failed validation: %s", src.name) return False + # Skip the copy when src already IS the file in place — e.g. re-picking + # a previously-removed license from /licenses/ to un-hide it. + # shutil.copyfile raises SameFileError (an OSError) on src == dst. try: self._licenses_dir.mkdir(parents=True, exist_ok=True) - shutil.copyfile(src, self._licenses_dir / src.name) + dst = self._licenses_dir / src.name + if src.resolve() != dst.resolve(): + shutil.copyfile(src, dst) except OSError as exc: log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc) return False + # An explicit (re)load un-hides the license if it was previously + # removed from the app view — the user is deliberately bringing it back. + self._discard_removed(lic.serial, lic.level) + self.refresh() + return True + + # ── Remove License (app-side only — .bin stays on disk) ──────────── + @property + def _removed_marker(self) -> Path: + return self._data_dir / "removed_licenses.txt" + + def _read_removed(self) -> set[str]: + try: + return set(self._removed_marker.read_text().splitlines()) + except OSError: + return set() + + def _discard_removed(self, serial: str, level: str) -> None: + entry = f"{serial}-{level}" + removed = self._read_removed() + if entry not in removed: + return + removed.discard(entry) + try: + self._removed_marker.write_text("\n".join(sorted(removed)) + "\n" if removed else "") + except OSError as exc: + log.warning("Cannot update removed-license marker %s: %s", self._removed_marker, exc) + + @Slot(str, str, result=bool) + def removeLicense(self, serial: str, level: str) -> bool: + """Hide license (serial, level) from the app. Leaves the .bin on disk.""" + if not any(lic.serial == serial and lic.level == level for lic in self._licenses): + return False + + entry = f"{serial}-{level}" + removed = self._read_removed() + removed.add(entry) + try: + self._removed_marker.write_text("\n".join(sorted(removed)) + "\n") + except OSError as exc: + log.warning("Cannot update removed-license marker %s: %s", self._removed_marker, exc) + return False + self.refresh() return True @@ -128,9 +183,17 @@ class LicenseModel(QObject): @Slot(result=bool) def startReplayTrial(self) -> bool: - """Create the trial marker (idempotent). False on I/O failure.""" + """Create the trial marker if absent. False on I/O failure. + + Must never touch an existing marker: Path.touch(exist_ok=True) bumps + mtime even when the file is already there, which would silently push + the 30-day clock forward every time this is called on an + already-started trial — an infinite-reset hole. + """ + if self._trial_marker.exists(): + return True try: - self._trial_marker.touch(exist_ok=True) + self._trial_marker.touch() return True except OSError as exc: log.warning("Cannot create trial marker: %s", exc) diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index a44bb61..a1295ff 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -378,12 +378,7 @@ class WaferMapItem(QQuickPaintedItem): return idx return -1 - # TODO P6.1: build batch export on top of this single-frame export - # THINKING: export_image() already does the hard part (grabToImage → PNG); - # batch export is just a loop over file_browser.files that loads each CSV - # through the existing wafer-map pipeline and calls this per file into a - # chosen output dir, plus an optional summary CSV of (file, min/max/mean). - # No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1. + @Slot(str, result=bool) @Slot(str, str, result=bool) def export_image(self, file_path: str, extra: str = "") -> bool: @@ -520,13 +515,6 @@ class WaferMapItem(QQuickPaintedItem): r = max(math.hypot(s.x, s.y) for s in self._sensors) return r * 1.05 - # TODO P6.2: reuse this for the edge-to-center delta metric - # THINKING: radial_metrics.py (new) needs the same "which sensors are near - # the edge vs. near the center" bucketing this function already computes - # for ring-line drawing — do NOT re-derive ring boundaries separately, this - # already handles round vs. square wafer shapes correctly (see family_spec.py - # square-wafer work). Outermost group ≈ edge sensors, innermost ≈ center. - # See docs/pending/alpha-release-polish-plan.md §6.2. def _sensor_ring_radii_mm(self) -> list[float]: """Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary.""" if not self._sensors: diff --git a/tests/test_device_controller.py b/tests/test_device_controller.py index d01b635..08a2239 100644 --- a/tests/test_device_controller.py +++ b/tests/test_device_controller.py @@ -50,6 +50,11 @@ def test_detect_wafer_spawns_thread(controller): assert controller.operationInProgress def test_read_memory_without_detect_return_error(controller): + # License the wafer so this test exercises the "no port" branch rather + # than short-circuiting on the license guard. + controller._license_lookup = lambda s: "02" + controller._last_wafer_info = {"serialNumber": "A00001"} + emitted_result = None def on_read_result(result): nonlocal emitted_result @@ -91,6 +96,9 @@ def test_clear_session(controller): assert controller.lastWaferInfo == ["", "", 0, "", 0, 0] def test_erase_memory_spawns_thread(controller): + # License the wafer so the new license guard doesn't block the erase. + controller._license_lookup = lambda s: "02" + controller._last_wafer_info = {"serialNumber": "A00001"} with patch("threading.Thread") as mock_thread: controller.eraseMemory("COM1") mock_thread.assert_called_once() @@ -109,11 +117,32 @@ def test_erase_memory_handler(controller): assert not controller.operationInProgress def test_read_debug_spawns_thread(controller): + # License the wafer so the license guard doesn't block the debug read. + controller._license_lookup = lambda s: "02" + controller._last_wafer_info = {"serialNumber": "A00001"} with patch("threading.Thread") as mock_thread: controller.readDebug("COM1") mock_thread.assert_called_once() assert controller.operationInProgress +def test_read_debug_denied_without_license(controller): + """readDebug() returns D1 sensor data, so it obeys the same license guard.""" + controller._license_lookup = lambda s: "" + controller._last_wafer_info = {"serialNumber": "A00001"} + + emitted_result = None + def on_debug_result(result): + nonlocal emitted_result + emitted_result = result + controller.debugResult.connect(on_debug_result) + + with patch("threading.Thread") as mock_thread: + controller.readDebug("COM1") + mock_thread.assert_not_called() + + assert emitted_result == {"success": False, "error": "Wafer not licensed"} + assert not controller.operationInProgress + def test_read_debug_handler(controller): emitted_result = None def on_debug_result(result): @@ -300,3 +329,136 @@ def test_export_dir_created_under_save_data_dir(controller): assert result == str(Path(controller.saveDataDir) / "Export") assert Path(result).is_dir() + + +def test_wafer_licensed_default_no_license(controller): + """Default license_lookup (empty lambda) -> waferLicensed() is False.""" + controller._last_wafer_info = {"serialNumber": "P00001"} + assert controller.waferLicensed() is False + + +def test_wafer_licensed_with_valid_license(mock_settings): + """A license_lookup returning a level -> waferLicensed() is True.""" + import shutil + import tempfile + + temp_dir = tempfile.mkdtemp() + try: + controller = DeviceController( + mock_settings, temp_dir, license_lookup=lambda s: "02" + ) + controller._last_wafer_info = {"serialNumber": "A00001"} + assert controller.waferLicensed() is True + finally: + shutil.rmtree(temp_dir) + + +def test_read_memory_denied_without_license(controller): + """readMemoryAsync() must refuse to spawn a thread for an unlicensed wafer.""" + controller._license_lookup = lambda s: "" + controller._last_wafer_info = {"serialNumber": "A00001"} + controller._selected_port = "COM1" + + emitted_result = None + def on_read_result(result): + nonlocal emitted_result + emitted_result = result + controller.readResult.connect(on_read_result) + + with patch("threading.Thread") as mock_thread: + controller.readMemoryAsync() + mock_thread.assert_not_called() + + assert emitted_result is not None + assert "error" in emitted_result + assert not controller.operationInProgress + + +def test_erase_memory_denied_without_license(controller): + """eraseMemory() must refuse to spawn a thread for an unlicensed wafer.""" + controller._license_lookup = lambda s: "" + controller._last_wafer_info = {"serialNumber": "A00001"} + + emitted_result = None + def on_erase_result(result): + nonlocal emitted_result + emitted_result = result + controller.eraseResult.connect(on_erase_result) + + with patch("threading.Thread") as mock_thread: + controller.eraseMemory("COM1") + mock_thread.assert_not_called() + + assert emitted_result == {"success": False} + assert not controller.operationInProgress + + +def test_read_memory_allowed_with_license(controller): + """readMemoryAsync() proceeds and spawns a thread once the wafer is licensed.""" + controller._license_lookup = lambda s: "02" + controller._last_wafer_info = {"serialNumber": "A00001"} + controller._selected_port = "COM1" + + with patch("threading.Thread") as mock_thread: + controller.readMemoryAsync() + mock_thread.assert_called_once() + assert controller.operationInProgress + + +def test_erase_memory_allowed_with_license(controller): + """eraseMemory() proceeds and spawns a thread once the wafer is licensed.""" + controller._license_lookup = lambda s: "02" + controller._last_wafer_info = {"serialNumber": "A00001"} + + with patch("threading.Thread") as mock_thread: + controller.eraseMemory("COM1") + mock_thread.assert_called_once() + assert controller.operationInProgress + + +def test_wafer_runtime_exceeded_false_under_threshold(controller): + controller._last_wafer_info = {"runtime": 100} + assert controller.waferRuntimeExceeded() is False + + +def test_wafer_runtime_exceeded_true_over_threshold(controller): + controller._last_wafer_info = {"runtime": 999} + assert controller.waferRuntimeExceeded() is True + + +def test_detect_finished_warns_on_runtime_exceeded(controller): + info = WaferInfo( + family_code="P", + serial_number="P00001", + sensor_count=244, + mfg_date_hex="12345678", + sensor_assigned_hex="01", + locked_hex="00", + runtime=999, + cycle_count=10, + ) + log_messages = [] + controller.activityLogUpdated.connect(lambda msg: log_messages.append(msg)) + + controller._handle_detect_finished("COM1", info) + + assert any("exceeds recommended" in msg for msg in log_messages) + + +def test_detect_finished_no_warning_under_threshold(controller): + info = WaferInfo( + family_code="P", + serial_number="P00001", + sensor_count=244, + mfg_date_hex="12345678", + sensor_assigned_hex="01", + locked_hex="00", + runtime=100, + cycle_count=10, + ) + log_messages = [] + controller.activityLogUpdated.connect(lambda msg: log_messages.append(msg)) + + controller._handle_detect_finished("COM1", info) + + assert not any("exceeds recommended" in msg for msg in log_messages) diff --git a/tests/test_device_controller_license.py b/tests/test_device_controller_license.py deleted file mode 100644 index b94f378..0000000 --- a/tests/test_device_controller_license.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO P4.2: unit tests for the DeviceController license guard (plan §4.2, -# docs/pending/license-gating-plan.md). Two cases: -# 1. license_lookup=lambda s: "" + fake _last_wafer_info → readMemoryAsync() -# emits readResult {"error": ...} and spawns no thread. -# 2. lookup returning "02" → proceeds to _read_worker (mock DeviceService). -# THINKING: guard is a trust boundary (plan §2.1); these fail if someone -# reorders the busy/license checks or renames the "serialNumber" key. -# Depends on P1.2 + P2.1 being implemented. diff --git a/tests/test_license.py b/tests/test_license.py index 57673a6..2908cd4 100644 --- a/tests/test_license.py +++ b/tests/test_license.py @@ -135,6 +135,63 @@ def test_model_load_license_file_url(tmp_path, qapp): assert model.loadLicenseFile("file://" + src) is True +def test_model_remove_license_keeps_bin_on_disk(tmp_path, qapp): + (tmp_path / "licenses").mkdir() + write_license(tmp_path / "licenses", serial="A00001", level="01") + model = LicenseModel(str(tmp_path)) + assert len(model.licenses) == 1 + + assert model.removeLicense("A00001", "01") is True + assert model.licenses == [] + # app-side removal only — the vendor-issued .bin is never deleted + assert (tmp_path / "licenses" / "A00001-01.bin").exists() + + # sticky across a fresh scan (not just until the next refresh) + model.refresh() + assert model.licenses == [] + + # sticky across a real relaunch — brand new instance, no shared state + relaunched = LicenseModel(str(tmp_path)) + assert relaunched.licenses == [] + + +def test_model_remove_license_missing_returns_false(tmp_path, qapp): + model = LicenseModel(str(tmp_path)) + assert model.removeLicense("Z99999", "00") is False + + +def test_model_reload_after_remove_unhides(tmp_path, qapp): + src_dir = tmp_path / "dl" + src_dir.mkdir() + src = write_license(src_dir, serial="A00001", level="01") + model = LicenseModel(str(tmp_path / "appdata")) + assert model.loadLicenseFile(src) is True + assert len(model.licenses) == 1 + + model.removeLicense("A00001", "01") + assert model.licenses == [] + + # explicit reload of the same file brings it back + assert model.loadLicenseFile(src) is True + assert len(model.licenses) == 1 + + +def test_model_reload_in_place_unhides(tmp_path, qapp): + """Un-hiding by re-picking the .bin from its own /licenses/ dir — + src and dst resolve to the same file, must not error on the copy step.""" + model = LicenseModel(str(tmp_path / "appdata")) + write_license(tmp_path / "appdata" / "licenses", serial="A00001", level="01") + model.refresh() + assert len(model.licenses) == 1 + + model.removeLicense("A00001", "01") + assert model.licenses == [] + + in_place = tmp_path / "appdata" / "licenses" / "A00001-01.bin" + assert model.loadLicenseFile(str(in_place)) is True + assert len(model.licenses) == 1 + + def test_model_load_rejects_invalid(tmp_path, qapp): src_dir = tmp_path / "dl" src_dir.mkdir() @@ -167,6 +224,23 @@ def test_replay_state_none_then_temporary(tmp_path, qapp): assert model.replayTrialExpired() is False +def test_start_replay_trial_does_not_reset_existing_marker(tmp_path, qapp): + """Repeat calls must not push the clock forward — the original + touch(exist_ok=True) bumped mtime on every call, which would let anyone + re-trigger the trial start (e.g. an over-limit wafer re-detected) reset + the 30-day window indefinitely.""" + model = LicenseModel(str(tmp_path)) + assert model.startReplayTrial() is True + first_mtime = model._trial_marker.stat().st_mtime + + # Simulate the marker being old (as if most of the trial has elapsed). + old_time = first_mtime - 25 * 86400 + os.utime(model._trial_marker, (old_time, old_time)) + + assert model.startReplayTrial() is True + assert model._trial_marker.stat().st_mtime == old_time + + def test_replay_state_permanent_with_level1(tmp_path, qapp): (tmp_path / "licenses").mkdir() write_license(tmp_path / "licenses", level="01")