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
This commit is contained in:
jack
2026-07-12 20:02:25 -07:00
parent 6003bde84d
commit 67158ed7ab
15 changed files with 699 additions and 54 deletions
+9
View File
@@ -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)
}
}
}
+148
View File
@@ -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
}
}
}
}
}
+128 -10
View File
@@ -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: "<b>" + model.label + "</b>: " + model.desc
text: (index === 2 && licenseModel.licenses.length === 0)
? "<b>MAP</b>: No license loaded — load one in About to unlock"
: "<b>" + model.label + "</b>: " + 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"
@@ -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
}
}
@@ -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.
@@ -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)
}
}
@@ -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;
+1
View File
@@ -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
+11 -5
View File
@@ -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)
@@ -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())
+69 -6
View File
@@ -33,7 +33,10 @@ class LicenseModel(QObject):
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
``DataDir\\licenses``). The 30-day replay trial marker is
``<data_dir>/replay.lic``.
``<data_dir>/replay.lic``. "Removed" licenses are tracked in
``<data_dir>/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 <data_dir>/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)
@@ -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:
+162
View File
@@ -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)
-8
View File
@@ -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.
+74
View File
@@ -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 <data_dir>/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")