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:
@@ -111,6 +111,7 @@ Popup {
|
|||||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
|
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: "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 }
|
Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
||||||
|
Item { Layout.preferredWidth: 28 }
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -132,6 +133,14 @@ Popup {
|
|||||||
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 }
|
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.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 }
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -19,20 +19,70 @@ Rectangle {
|
|||||||
|
|
||||||
// ===== View State =====
|
// ===== View State =====
|
||||||
property int selectedTabIndex: 0
|
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: {
|
onSelectedTabIndexChanged: {
|
||||||
if (selectedTabIndex === 2) {
|
if (selectedTabIndex === 2) {
|
||||||
file_browser.refreshFiles();
|
file_browser.refreshFiles();
|
||||||
streamController.unloadFile()
|
if (root.mapReviewFile !== "") {
|
||||||
} else if (streamController.mode === "live") {
|
if (streamController.loadedFile !== root.mapReviewFile)
|
||||||
// Leaving the map tab mid-stream: stop and blank the live map.
|
streamController.loadFile(root.mapReviewFile)
|
||||||
streamController.setMode("review")
|
} 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 memoryRead: false
|
||||||
property bool waferDetected: false
|
property bool waferDetected: false
|
||||||
|
property bool waferLicensed: false
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
@@ -41,16 +91,41 @@ Rectangle {
|
|||||||
// as an opaque wrapper whose fields read as undefined, so only
|
// as an opaque wrapper whose fields read as undefined, so only
|
||||||
// test truthiness (None on failure, dict on success).
|
// test truthiness (None on failure, dict on success).
|
||||||
root.waferDetected = !!result
|
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) {
|
function onParsedDataReady(result) {
|
||||||
if (result.success && result.csv_path) {
|
if (result.success && result.csv_path) {
|
||||||
streamController.loadFile(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
|
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 {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
function onReadResult(result) {
|
function onReadResult(result) {
|
||||||
@@ -181,6 +256,26 @@ Rectangle {
|
|||||||
id: aboutDialog
|
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 =====
|
// ===== Main Layout: Rail + Workspace =====
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -246,6 +341,11 @@ Rectangle {
|
|||||||
delegate: Button {
|
delegate: Button {
|
||||||
id: pillBtn
|
id: pillBtn
|
||||||
checked: root.selectedTabIndex === index
|
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
|
flat: true
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
@@ -276,7 +376,7 @@ Rectangle {
|
|||||||
source: model.icon
|
source: model.icon
|
||||||
sourceSize.width: 16; sourceSize.height: 16
|
sourceSize.width: 16; sourceSize.height: 16
|
||||||
color: (pillBtn.checked || pillBtn.hovered) ? Theme.headingColor : Theme.sideMutedText
|
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 {
|
Behavior on color {
|
||||||
ColorAnimation { duration: Theme.durationFast }
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
}
|
}
|
||||||
@@ -295,7 +395,7 @@ Rectangle {
|
|||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.letterSpacing: 1.0
|
font.letterSpacing: 1.0
|
||||||
opacity: pillBtn.checked ? 1.0 : 0.0
|
opacity: !pillBtn.locked ? (pillBtn.checked ? 1.0 : 0.0) : 0.25
|
||||||
clip: true
|
clip: true
|
||||||
Behavior on width {
|
Behavior on width {
|
||||||
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||||
@@ -311,10 +411,12 @@ Rectangle {
|
|||||||
|
|
||||||
AppToolTip {
|
AppToolTip {
|
||||||
visible: pillBtn.hovered
|
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 {
|
SourcePanel {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 14
|
anchors.margins: 14
|
||||||
|
selectedTabIndex: root.selectedTabIndex
|
||||||
|
dataTab: dataTabLoader.item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,10 +736,24 @@ Rectangle {
|
|||||||
id: dataTabLoader
|
id: dataTabLoader
|
||||||
source: "Tabs/DataTab.qml"
|
source: "Tabs/DataTab.qml"
|
||||||
active: StackLayout.index === root.selectedTabIndex
|
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 {
|
Loader {
|
||||||
source: "Tabs/WaferMapTab.qml"
|
source: "Tabs/WaferMapTab.qml"
|
||||||
active: StackLayout.index === root.selectedTabIndex
|
active: StackLayout.index === root.selectedTabIndex && licenseModel.licenses.length > 0
|
||||||
}
|
}
|
||||||
Loader {
|
Loader {
|
||||||
source: "Tabs/GraphTab.qml"
|
source: "Tabs/GraphTab.qml"
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ Rectangle {
|
|||||||
// Set true to show the button as "active" (e.g. a one-shot toggle
|
// Set true to show the button as "active" (e.g. a one-shot toggle
|
||||||
// that's mid-operation), independent of hover/press state.
|
// that's mid-operation), independent of hover/press state.
|
||||||
property bool toggled: false
|
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
|
property color _textColor: !root.enabled ? Theme.sideMutedText
|
||||||
: root.destructive ? Theme.statusErrorColor
|
: root.destructive ? Theme.statusErrorColor
|
||||||
: Theme.headingColor
|
: Theme.headingColor
|
||||||
@@ -23,7 +26,7 @@ Rectangle {
|
|||||||
|
|
||||||
implicitHeight: Theme.sideButtonHeight
|
implicitHeight: Theme.sideButtonHeight
|
||||||
radius: 8
|
radius: 8
|
||||||
color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
|
color: root.toggled || (root.enabled && (mouseArea.containsMouse || mouseArea.pressed))
|
||||||
? Theme.sideActiveBackground : "transparent"
|
? Theme.sideActiveBackground : "transparent"
|
||||||
border.width: 0
|
border.width: 0
|
||||||
|
|
||||||
@@ -64,10 +67,17 @@ Rectangle {
|
|||||||
MouseArea {
|
MouseArea {
|
||||||
id: mouseArea
|
id: mouseArea
|
||||||
anchors.fill: parent
|
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
|
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (root.enabled) root.clicked()
|
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 exportSummary: ""
|
||||||
property string importSummary: ""
|
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 {
|
Connections {
|
||||||
target: batchExportController
|
target: batchExportController
|
||||||
function onExportFinished(summary) {
|
function onExportFinished(summary) {
|
||||||
@@ -352,11 +357,7 @@ ColumnLayout {
|
|||||||
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property var dataTab: {
|
readonly property var dataTab: root.selectedTabIndex === 1 ? root.dataTab : null
|
||||||
try {
|
|
||||||
return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
|
|
||||||
} catch (e) { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run A's effective file: the master file when "Compare vs Master
|
// Run A's effective file: the master file when "Compare vs Master
|
||||||
// File" is toggled on, otherwise the manually selected file.
|
// File" is toggled on, otherwise the manually selected file.
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ ColumnLayout {
|
|||||||
label: "READ MEMORY"
|
label: "READ MEMORY"
|
||||||
iconSource: "../icons/read.svg"
|
iconSource: "../icons/read.svg"
|
||||||
Layout.fillWidth: true
|
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()
|
onClicked: deviceController.readMemoryAsync()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,9 @@ ColumnLayout {
|
|||||||
iconSource: "../icons/erase.svg"
|
iconSource: "../icons/erase.svg"
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
destructive: 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(
|
onClicked: deviceController.eraseMemory(
|
||||||
deviceController.selectedPort)
|
deviceController.selectedPort)
|
||||||
}
|
}
|
||||||
@@ -65,7 +69,9 @@ ColumnLayout {
|
|||||||
// One-shot toggle: "on" while the debug read is in flight,
|
// One-shot toggle: "on" while the debug read is in flight,
|
||||||
// resets automatically once deviceController flips status.
|
// resets automatically once deviceController flips status.
|
||||||
toggled: deviceController.connectionStatus === "Reading debug..."
|
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)
|
onClicked: deviceController.readDebug(deviceController.selectedPort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,22 @@ Rectangle {
|
|||||||
|
|
||||||
TabBar {
|
TabBar {
|
||||||
id: modeBar
|
id: modeBar
|
||||||
|
// Guards the programmatic sync below from re-triggering
|
||||||
|
// onCurrentIndexChanged's setMode()/startStream() side effects.
|
||||||
|
property bool _syncing: false
|
||||||
currentIndex: 0
|
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
|
spacing: 2
|
||||||
padding: 2
|
padding: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -152,6 +167,7 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onCurrentIndexChanged: {
|
onCurrentIndexChanged: {
|
||||||
|
if (_syncing) return;
|
||||||
if (currentIndex === 1) {
|
if (currentIndex === 1) {
|
||||||
if (deviceController.connectionStatus !== "Connected") {
|
if (deviceController.connectionStatus !== "Connected") {
|
||||||
currentIndex = 0;
|
currentIndex = 0;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ module ISC
|
|||||||
Main 1.0 Main.qml
|
Main 1.0 Main.qml
|
||||||
HomePage 1.0 HomePage.qml
|
HomePage 1.0 HomePage.qml
|
||||||
AboutDialog 1.0 AboutDialog.qml
|
AboutDialog 1.0 AboutDialog.qml
|
||||||
|
FirstLaunchWizard 1.0 FirstLaunchWizard.qml
|
||||||
|
|
||||||
# ===== Singleton Theme =====
|
# ===== Singleton Theme =====
|
||||||
singleton Theme 1.0 Theme.qml
|
singleton Theme 1.0 Theme.qml
|
||||||
|
|||||||
+11
-5
@@ -49,6 +49,9 @@ def main() -> int:
|
|||||||
# Settings tab is visible to the device layer immediately — no reload,
|
# Settings tab is visible to the device layer immediately — no reload,
|
||||||
# no second copy to go stale.
|
# no second copy to go stale.
|
||||||
data_dir = str(default_data_dir())
|
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)
|
raw_settings = LocalSettings.read_settings(data_dir)
|
||||||
|
|
||||||
settings_model = LocalSettingsModel(raw_settings, data_dir)
|
settings_model = LocalSettingsModel(raw_settings, data_dir)
|
||||||
@@ -56,6 +59,7 @@ def main() -> int:
|
|||||||
settings_model.loadSettings()
|
settings_model.loadSettings()
|
||||||
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
||||||
engine.rootContext().setContextProperty("file_browser", select_file_dialog_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
|
# App version from package metadata so the About dialog can't drift
|
||||||
# from pyproject.toml.
|
# from pyproject.toml.
|
||||||
@@ -65,17 +69,19 @@ def main() -> int:
|
|||||||
app_version = "dev"
|
app_version = "dev"
|
||||||
engine.rootContext().setContextProperty("appVersion", app_version)
|
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 (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)
|
engine.rootContext().setContextProperty("deviceController", device_controller)
|
||||||
|
|
||||||
# Source panel browses the same folder recordings/reads are saved to.
|
# Source panel browses the same folder recordings/reads are saved to.
|
||||||
select_file_dialog_model.setCurrentDirectory(str(device_controller.saveDataDir))
|
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) =====
|
# ===== Session Controller (live/review wafer dashboard) =====
|
||||||
stream_controller = SessionController()
|
stream_controller = SessionController()
|
||||||
engine.rootContext().setContextProperty("streamController", stream_controller)
|
engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
|
|
||||||
@@ -29,6 +29,12 @@ from pygui.serialcomm.serial_port import WaferInfo
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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):
|
class DeviceController(QObject):
|
||||||
"""Controls serial communication with the temperature-sensing wafer.
|
"""Controls serial communication with the temperature-sensing wafer.
|
||||||
@@ -57,10 +63,17 @@ class DeviceController(QObject):
|
|||||||
_eraseFinished = Signal(bool) # (success)
|
_eraseFinished = Signal(bool) # (success)
|
||||||
_debugFinished = Signal(dict) # (result_dict)
|
_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)
|
super().__init__(parent)
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._data_dir = data_dir
|
self._data_dir = data_dir
|
||||||
|
self._license_lookup = license_lookup
|
||||||
from pygui.serialcomm.device_service import DeviceService
|
from pygui.serialcomm.device_service import DeviceService
|
||||||
self._service = DeviceService(settings)
|
self._service = DeviceService(settings)
|
||||||
|
|
||||||
@@ -266,6 +279,21 @@ class DeviceController(QObject):
|
|||||||
"series": series_data,
|
"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:
|
def _append_log(self, message: str) -> None:
|
||||||
"""Append a message to the activity log and emit updated log."""
|
"""Append a message to the activity log and emit updated log."""
|
||||||
ts = datetime.now().strftime("%H:%M:%S")
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
@@ -383,6 +411,12 @@ class DeviceController(QObject):
|
|||||||
self._last_wafer_info = wafer_dict
|
self._last_wafer_info = wafer_dict
|
||||||
self._set_wafer_detected(True)
|
self._set_wafer_detected(True)
|
||||||
self._set_connection_status("Connected")
|
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.detectResult.emit(wafer_dict)
|
||||||
self.selectedPortChanged.emit()
|
self.selectedPortChanged.emit()
|
||||||
self._save_status()
|
self._save_status()
|
||||||
@@ -410,6 +444,11 @@ class DeviceController(QObject):
|
|||||||
self._append_log("Already busy — ignoring read request")
|
self._append_log("Already busy — ignoring read request")
|
||||||
return
|
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:
|
if not self._selected_port:
|
||||||
self._append_log("No wafer detected — run Detect Wafer first")
|
self._append_log("No wafer detected — run Detect Wafer first")
|
||||||
self.readResult.emit({"error": "No port — 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")
|
self._append_log("Already busy — ignoring erase request")
|
||||||
return
|
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_operation_progress(True)
|
||||||
self._set_connection_status("Erasing...")
|
self._set_connection_status("Erasing...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
@@ -510,6 +554,13 @@ class DeviceController(QObject):
|
|||||||
if self._operation_in_progress:
|
if self._operation_in_progress:
|
||||||
self._append_log("Already busy — ignoring debug read request")
|
self._append_log("Already busy — ignoring debug read request")
|
||||||
return
|
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_operation_progress(True)
|
||||||
self._set_connection_status("Reading debug...")
|
self._set_connection_status("Reading debug...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ class LicenseModel(QObject):
|
|||||||
|
|
||||||
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
|
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
|
||||||
``DataDir\\licenses``). The 30-day replay trial marker is
|
``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()
|
licensesChanged = Signal()
|
||||||
@@ -65,8 +68,12 @@ class LicenseModel(QObject):
|
|||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
"""Re-scan the licenses directory."""
|
"""Re-scan the licenses directory, filtering out removed entries."""
|
||||||
self._licenses = read_license_files(self._licenses_dir)
|
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()
|
self.licensesChanged.emit()
|
||||||
|
|
||||||
# ── Load License ──────────────────────────────────────────────────
|
# ── Load License ──────────────────────────────────────────────────
|
||||||
@@ -92,13 +99,61 @@ class LicenseModel(QObject):
|
|||||||
log.warning("License file failed validation: %s", src.name)
|
log.warning("License file failed validation: %s", src.name)
|
||||||
return False
|
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:
|
try:
|
||||||
self._licenses_dir.mkdir(parents=True, exist_ok=True)
|
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:
|
except OSError as exc:
|
||||||
log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
|
log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
|
||||||
return False
|
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()
|
self.refresh()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -128,9 +183,17 @@ class LicenseModel(QObject):
|
|||||||
|
|
||||||
@Slot(result=bool)
|
@Slot(result=bool)
|
||||||
def startReplayTrial(self) -> 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:
|
try:
|
||||||
self._trial_marker.touch(exist_ok=True)
|
self._trial_marker.touch()
|
||||||
return True
|
return True
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
log.warning("Cannot create trial marker: %s", exc)
|
log.warning("Cannot create trial marker: %s", exc)
|
||||||
|
|||||||
@@ -378,12 +378,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
return idx
|
return idx
|
||||||
return -1
|
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, result=bool)
|
||||||
@Slot(str, str, result=bool)
|
@Slot(str, str, result=bool)
|
||||||
def export_image(self, file_path: str, extra: str = "") -> 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)
|
r = max(math.hypot(s.x, s.y) for s in self._sensors)
|
||||||
return r * 1.05
|
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]:
|
def _sensor_ring_radii_mm(self) -> list[float]:
|
||||||
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
||||||
if not self._sensors:
|
if not self._sensors:
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ def test_detect_wafer_spawns_thread(controller):
|
|||||||
assert controller.operationInProgress
|
assert controller.operationInProgress
|
||||||
|
|
||||||
def test_read_memory_without_detect_return_error(controller):
|
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
|
emitted_result = None
|
||||||
def on_read_result(result):
|
def on_read_result(result):
|
||||||
nonlocal emitted_result
|
nonlocal emitted_result
|
||||||
@@ -91,6 +96,9 @@ def test_clear_session(controller):
|
|||||||
assert controller.lastWaferInfo == ["", "", 0, "", 0, 0]
|
assert controller.lastWaferInfo == ["", "", 0, "", 0, 0]
|
||||||
|
|
||||||
def test_erase_memory_spawns_thread(controller):
|
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:
|
with patch("threading.Thread") as mock_thread:
|
||||||
controller.eraseMemory("COM1")
|
controller.eraseMemory("COM1")
|
||||||
mock_thread.assert_called_once()
|
mock_thread.assert_called_once()
|
||||||
@@ -109,11 +117,32 @@ def test_erase_memory_handler(controller):
|
|||||||
assert not controller.operationInProgress
|
assert not controller.operationInProgress
|
||||||
|
|
||||||
def test_read_debug_spawns_thread(controller):
|
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:
|
with patch("threading.Thread") as mock_thread:
|
||||||
controller.readDebug("COM1")
|
controller.readDebug("COM1")
|
||||||
mock_thread.assert_called_once()
|
mock_thread.assert_called_once()
|
||||||
assert controller.operationInProgress
|
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):
|
def test_read_debug_handler(controller):
|
||||||
emitted_result = None
|
emitted_result = None
|
||||||
def on_debug_result(result):
|
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 result == str(Path(controller.saveDataDir) / "Export")
|
||||||
assert Path(result).is_dir()
|
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)
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -135,6 +135,63 @@ def test_model_load_license_file_url(tmp_path, qapp):
|
|||||||
assert model.loadLicenseFile("file://" + src) is True
|
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):
|
def test_model_load_rejects_invalid(tmp_path, qapp):
|
||||||
src_dir = tmp_path / "dl"
|
src_dir = tmp_path / "dl"
|
||||||
src_dir.mkdir()
|
src_dir.mkdir()
|
||||||
@@ -167,6 +224,23 @@ def test_replay_state_none_then_temporary(tmp_path, qapp):
|
|||||||
assert model.replayTrialExpired() is False
|
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):
|
def test_replay_state_permanent_with_level1(tmp_path, qapp):
|
||||||
(tmp_path / "licenses").mkdir()
|
(tmp_path / "licenses").mkdir()
|
||||||
write_license(tmp_path / "licenses", level="01")
|
write_license(tmp_path / "licenses", level="01")
|
||||||
|
|||||||
Reference in New Issue
Block a user