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