Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0741e96e45 | |||
| 44216e5857 | |||
| d3c5b6fa64 | |||
| 8707d467f5 | |||
| 5060df1527 | |||
| 87cfbabb4a | |||
| 3675f0722a | |||
| c4cbc02a15 | |||
| 425f3731cd | |||
| 92ff6fbd14 | |||
| c76cddc5fd | |||
| 0e6e819851 |
@@ -0,0 +1,35 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
QT_QPA_PLATFORM: offscreen
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
preflight:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Qt runtime libs
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v3
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: make lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: make typecheck
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: make test
|
||||||
+2
-4
@@ -21,7 +21,6 @@ env/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
docs
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -37,9 +36,7 @@ moc_*.cpp
|
|||||||
# Build / packaging artifacts
|
# Build / packaging artifacts
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
*.spec
|
|
||||||
*.icns
|
|
||||||
*.ico
|
|
||||||
|
|
||||||
# Runtime scratch data
|
# Runtime scratch data
|
||||||
tmp/
|
tmp/
|
||||||
@@ -58,3 +55,4 @@ AGENTS.md
|
|||||||
CONVENTIONS.md
|
CONVENTIONS.md
|
||||||
specs/
|
specs/
|
||||||
graphify-out/
|
graphify-out/
|
||||||
|
.personal-docs/
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ typecheck:
|
|||||||
run:
|
run:
|
||||||
uv run python -m pygui
|
uv run python -m pygui
|
||||||
|
|
||||||
|
package:
|
||||||
|
uv run pyinstaller packaging/isc.spec
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf .pytest_cache .ruff_cache .mypy_cache
|
rm -rf .pytest_cache .ruff_cache .mypy_cache build dist
|
||||||
find . -type d -name "__pycache__" -exec rm -rf {} +
|
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
# Run from the project root: pyinstaller packaging/isc.spec
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
app_icon = "isc.ico"
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
app_icon = "isc.icns"
|
||||||
|
else:
|
||||||
|
app_icon = None
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['../src/pygui/__main__.py'],
|
||||||
|
pathex=['src'],
|
||||||
|
binaries=[],
|
||||||
|
# Bundle the QML module so loadFromModule("ISC", "Main") resolves at runtime.
|
||||||
|
datas=[('../src/pygui/ISC', 'ISC')],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name='ISC',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=app_icon,
|
||||||
|
)
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name='ISC',
|
||||||
|
)
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
app = BUNDLE(
|
||||||
|
coll,
|
||||||
|
name='ISC.app',
|
||||||
|
icon=app_icon,
|
||||||
|
bundle_identifier=None,
|
||||||
|
)
|
||||||
@@ -92,6 +92,7 @@ dev = [
|
|||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"ruff",
|
"ruff",
|
||||||
"mypy",
|
"mypy",
|
||||||
|
"pyinstaller==6.21.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import QtQuick.Controls
|
|||||||
import QtQuick.Dialogs
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
|
||||||
Popup {
|
Popup {
|
||||||
id: root
|
id: root
|
||||||
@@ -133,13 +134,28 @@ 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 {
|
Button {
|
||||||
|
id: removeLicenseBtn
|
||||||
Layout.preferredWidth: 28
|
Layout.preferredWidth: 28
|
||||||
text: "✕"
|
implicitHeight: 28
|
||||||
font.pixelSize: Theme.fontSm
|
hoverEnabled: true
|
||||||
ToolTip.visible: hovered
|
background: Rectangle {
|
||||||
ToolTip.text: "Remove license"
|
color: removeLicenseBtn.pressed ? Theme.buttonPressed : removeLicenseBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: "✕"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
color: Theme.bodyColor
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
onClicked: licenseModel.removeLicense(modelData.serial, modelData.level)
|
onClicked: licenseModel.removeLicense(modelData.serial, modelData.level)
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: removeLicenseBtn.hovered
|
||||||
|
text: "Remove license"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Popup {
|
|||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose a folder to save data"
|
title: "Choose a folder to save data"
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
var dir = decodeURIComponent(String(selectedFolder).replace(/^file:\/\//, ""))
|
var dir = String(selectedFolder)
|
||||||
deviceController.setSaveDataDir(dir)
|
deviceController.setSaveDataDir(dir)
|
||||||
file_browser.setCurrentDirectory(dir)
|
file_browser.setCurrentDirectory(dir)
|
||||||
root.finished()
|
root.finished()
|
||||||
|
|||||||
+58
-20
@@ -33,6 +33,9 @@ Rectangle {
|
|||||||
property string dataCompareFileB: ""
|
property string dataCompareFileB: ""
|
||||||
|
|
||||||
onSelectedTabIndexChanged: {
|
onSelectedTabIndexChanged: {
|
||||||
|
// Time-based, so re-check on every switch rather than trusting a
|
||||||
|
// value computed at some earlier point in a possibly long-running session.
|
||||||
|
root.reviewBlocked = licenseModel.reviewAccessBlocked()
|
||||||
if (selectedTabIndex === 2) {
|
if (selectedTabIndex === 2) {
|
||||||
file_browser.refreshFiles();
|
file_browser.refreshFiles();
|
||||||
if (root.mapReviewFile !== "") {
|
if (root.mapReviewFile !== "") {
|
||||||
@@ -68,6 +71,9 @@ Rectangle {
|
|||||||
if (root.selectedTabIndex === 2) root.mapReviewFile = streamController.loadedFile
|
if (root.selectedTabIndex === 2) root.mapReviewFile = streamController.loadedFile
|
||||||
else if (root.selectedTabIndex === 3) root.graphReviewFile = streamController.loadedFile
|
else if (root.selectedTabIndex === 3) root.graphReviewFile = streamController.loadedFile
|
||||||
}
|
}
|
||||||
|
function onLiveError(message) {
|
||||||
|
deviceController.appendActivityLog("Live stream error — stream stopped: " + message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data tab's Run A/B picks survive it being destroyed/recreated by its
|
// Data tab's Run A/B picks survive it being destroyed/recreated by its
|
||||||
@@ -84,6 +90,24 @@ Rectangle {
|
|||||||
property bool waferDetected: false
|
property bool waferDetected: false
|
||||||
property bool waferLicensed: false
|
property bool waferLicensed: false
|
||||||
|
|
||||||
|
// Data/Graph/Map-review are gated on the 30-day trial once it's actually
|
||||||
|
// expired (ADR-0005 "Deferred" item — the trigger existed, nothing ever
|
||||||
|
// checked it). Status stays free: hardware actions are gated separately,
|
||||||
|
// per-wafer-serial, via waferLicensed above.
|
||||||
|
property bool reviewBlocked: false
|
||||||
|
onReviewBlockedChanged: {
|
||||||
|
// Property change signals only fire on an actual value change, so
|
||||||
|
// this logs once per none/temporary → expired transition, not on
|
||||||
|
// every recompute in onSelectedTabIndexChanged/onLicensesChanged.
|
||||||
|
if (reviewBlocked) {
|
||||||
|
deviceController.appendActivityLog(
|
||||||
|
"Review trial expired — load a wafer license to continue using Data/Map/Graph (About dialog)")
|
||||||
|
if (root.selectedTabIndex !== 0) {
|
||||||
|
root.selectedTabIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
function onDetectResult(result){
|
function onDetectResult(result){
|
||||||
@@ -100,16 +124,22 @@ Rectangle {
|
|||||||
licenseModel.startReplayTrial()
|
licenseModel.startReplayTrial()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onParsedDataReady(result) {
|
}
|
||||||
if (result.success && result.csv_path) {
|
Connections {
|
||||||
streamController.loadFile(result.csv_path)
|
// Deferred until the post-read metadata dialog (StatusTab.qml) closes —
|
||||||
// Fires while still on Status(0), before the tab switch below —
|
// jumping tabs from onParsedDataReady directly would deactivate the
|
||||||
// the generic per-tab tracker only attributes picks made while
|
// Status Loader (StackLayout.index !== selectedTabIndex) and tear down
|
||||||
// already on Map/Graph, so record this one explicitly.
|
// the dialog before the technician could see or use it.
|
||||||
root.mapReviewFile = result.csv_path
|
target: statusTabLoader.item
|
||||||
if (root.selectedTabIndex === 0 && licenseModel.licenses.length > 0) {
|
function onCsvMetadataDialogClosed(csvPath) {
|
||||||
|
if (!csvPath) return
|
||||||
|
streamController.loadFile(csvPath)
|
||||||
|
// 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 = csvPath
|
||||||
|
if (root.selectedTabIndex === 0 && licenseModel.licenses.length > 0) {
|
||||||
root.selectedTabIndex = 2
|
root.selectedTabIndex = 2
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,6 +154,7 @@ Rectangle {
|
|||||||
root.selectedTabIndex = 0
|
root.selectedTabIndex = 0
|
||||||
}
|
}
|
||||||
root.waferLicensed = root.waferDetected && deviceController.waferLicensed()
|
root.waferLicensed = root.waferDetected && deviceController.waferLicensed()
|
||||||
|
root.reviewBlocked = licenseModel.reviewAccessBlocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Connections {
|
Connections {
|
||||||
@@ -133,17 +164,13 @@ Rectangle {
|
|||||||
result !== undefined &&
|
result !== undefined &&
|
||||||
result.success === true)
|
result.success === true)
|
||||||
if (root.memoryRead) {
|
if (root.memoryRead) {
|
||||||
console.log("[P1.1] Memory read complete:", result.bytes, "bytes")
|
console.log("Memory read complete:", result.bytes, "bytes")
|
||||||
} else {
|
} else {
|
||||||
console.log("[P1.1] Read failed:", result.error || "unknown")
|
console.log("Read failed:", result.error || "unknown")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanFolderUrl(url) {
|
|
||||||
return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
function _doDetect() {
|
function _doDetect() {
|
||||||
root.memoryRead = false
|
root.memoryRead = false
|
||||||
root.waferDetected = false
|
root.waferDetected = false
|
||||||
@@ -156,7 +183,7 @@ Rectangle {
|
|||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose a folder to save Data"
|
title: "Choose a folder to save Data"
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
var dir = root.cleanFolderUrl(selectedFolder)
|
var dir = String(selectedFolder)
|
||||||
deviceController.setSaveDataDir(dir)
|
deviceController.setSaveDataDir(dir)
|
||||||
file_browser.setCurrentDirectory(dir)
|
file_browser.setCurrentDirectory(dir)
|
||||||
root._doDetect()
|
root._doDetect()
|
||||||
@@ -271,6 +298,7 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
|
root.reviewBlocked = licenseModel.reviewAccessBlocked()
|
||||||
if (isFirstLaunch) {
|
if (isFirstLaunch) {
|
||||||
firstLaunchWizard.open()
|
firstLaunchWizard.open()
|
||||||
}
|
}
|
||||||
@@ -342,10 +370,13 @@ Rectangle {
|
|||||||
id: pillBtn
|
id: pillBtn
|
||||||
checked: root.selectedTabIndex === index
|
checked: root.selectedTabIndex === index
|
||||||
// MAP (index 2) is locked until a valid license .bin is installed.
|
// MAP (index 2) is locked until a valid license .bin is installed.
|
||||||
|
// DATA/MAP/GRAPH (index !== 0) also lock once the 30-day review
|
||||||
|
// trial has actually expired with no permanent license (ADR-0005).
|
||||||
// Stays enabled (not `enabled: false`) so hover still fires and the
|
// Stays enabled (not `enabled: false`) so hover still fires and the
|
||||||
// tooltip can explain why — a disabled Item receives no mouse/hover
|
// tooltip can explain why — a disabled Item receives no mouse/hover
|
||||||
// events at all, which would silently kill the tooltip below.
|
// events at all, which would silently kill the tooltip below.
|
||||||
property bool locked: index === 2 && licenseModel.licenses.length === 0
|
property bool locked: (index === 2 && licenseModel.licenses.length === 0)
|
||||||
|
|| (index !== 0 && root.reviewBlocked)
|
||||||
flat: true
|
flat: true
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
@@ -411,9 +442,15 @@ Rectangle {
|
|||||||
|
|
||||||
AppToolTip {
|
AppToolTip {
|
||||||
visible: pillBtn.hovered
|
visible: pillBtn.hovered
|
||||||
text: (index === 2 && licenseModel.licenses.length === 0)
|
// Trial-expired is checked first: it's the broader reason and
|
||||||
? "<b>MAP</b>: No license loaded — load one in About to unlock"
|
// applies identically to Data/Map/Graph, so when it's true it
|
||||||
: "<b>" + model.label + "</b>: " + model.desc
|
// wins even for MAP — otherwise MAP's narrower "no license at
|
||||||
|
// all" case (trial not yet expired, still just unlicensed) shows.
|
||||||
|
text: (index !== 0 && root.reviewBlocked)
|
||||||
|
? "<b>" + model.label + "</b>: Review trial expired — load a license in About to unlock"
|
||||||
|
: (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: if (!pillBtn.locked) root.selectedTabIndex = index
|
onClicked: if (!pillBtn.locked) root.selectedTabIndex = index
|
||||||
@@ -729,6 +766,7 @@ Rectangle {
|
|||||||
currentIndex: root.selectedTabIndex
|
currentIndex: root.selectedTabIndex
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
|
id: statusTabLoader
|
||||||
source: "Tabs/StatusTab.qml"
|
source: "Tabs/StatusTab.qml"
|
||||||
active: StackLayout.index === root.selectedTabIndex
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ ColumnLayout {
|
|||||||
anchors.margins: Theme.panelPadding
|
anchors.margins: Theme.panelPadding
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
|
// Fired once the post-read metadata dialog is dismissed (Save or Cancel),
|
||||||
|
// so HomePage can defer its auto-jump-to-Map until after the technician
|
||||||
|
// has had a chance to edit metadata — jumping tabs immediately would
|
||||||
|
// deactivate this Loader's item and kill the dialog mid-display.
|
||||||
|
signal csvMetadataDialogClosed(string csvPath)
|
||||||
|
|
||||||
// ── Reusable Inline Components ──
|
// ── Reusable Inline Components ──
|
||||||
component StatCard : Rectangle {
|
component StatCard : Rectangle {
|
||||||
property alias value: valueText.text
|
property alias value: valueText.text
|
||||||
@@ -107,9 +113,6 @@ ColumnLayout {
|
|||||||
readonly property bool isBusy: deviceController.connectionStatus.endsWith("...")
|
readonly property bool isBusy: deviceController.connectionStatus.endsWith("...")
|
||||||
readonly property string portName: deviceController.selectedPort || "—"
|
readonly property string portName: deviceController.selectedPort || "—"
|
||||||
|
|
||||||
function cleanFolderUrl(url) {
|
|
||||||
return decodeURIComponent(String(url).replace(/^file:\/\//, ""));
|
|
||||||
}
|
|
||||||
function currentFamilyCode() {
|
function currentFamilyCode() {
|
||||||
var info = deviceController.lastWaferInfo;
|
var info = deviceController.lastWaferInfo;
|
||||||
return info && info.length > 0 ? (info[0] || "") : "";
|
return info && info.length > 0 ? (info[0] || "") : "";
|
||||||
@@ -122,7 +125,7 @@ ColumnLayout {
|
|||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose a folder to save."
|
title: "Choose a folder to save."
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
var dir = root.cleanFolderUrl(selectedFolder);
|
var dir = String(selectedFolder);
|
||||||
deviceController.setSaveDataDir(dir);
|
deviceController.setSaveDataDir(dir);
|
||||||
file_browser.setCurrentDirectory(dir);
|
file_browser.setCurrentDirectory(dir);
|
||||||
root.parseAndSavePendingRead();
|
root.parseAndSavePendingRead();
|
||||||
@@ -135,7 +138,7 @@ ColumnLayout {
|
|||||||
id: dirOnlyDialog
|
id: dirOnlyDialog
|
||||||
title: "Choose a folder to save Data"
|
title: "Choose a folder to save Data"
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
var dir = root.cleanFolderUrl(selectedFolder);
|
var dir = String(selectedFolder);
|
||||||
deviceController.setSaveDataDir(dir);
|
deviceController.setSaveDataDir(dir);
|
||||||
file_browser.setCurrentDirectory(dir);
|
file_browser.setCurrentDirectory(dir);
|
||||||
}
|
}
|
||||||
@@ -147,31 +150,127 @@ ColumnLayout {
|
|||||||
id: editCsvDialog
|
id: editCsvDialog
|
||||||
parent: Overlay.overlay
|
parent: Overlay.overlay
|
||||||
modal: true
|
modal: true
|
||||||
title: "Edit CSV Metadata"
|
|
||||||
width: 460
|
width: 460
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
standardButtons: Dialog.Save | Dialog.Cancel
|
standardButtons: Dialog.NoButton
|
||||||
|
padding: 20
|
||||||
onAccepted: file_browser.saveMetadata(root.csvPath, metaWafer.text, metaDate.text, metaChamber.text, metaNotes.text, false, "")
|
onAccepted: file_browser.saveMetadata(root.csvPath, metaWafer.text, metaDate.text, metaChamber.text, metaNotes.text, false, "")
|
||||||
|
onClosed: root.csvMetadataDialogClosed(root.csvPath)
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusLg
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
}
|
||||||
|
|
||||||
|
component MetaField: TextField {
|
||||||
|
id: metaField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: metaField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: metaField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
|
// ── header ───────────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
text: "EDIT CSV METADATA"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: root.csvPath.split("/").pop()
|
text: root.csvPath.split("/").pop()
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontXs
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
Layout.bottomMargin: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.bottomMargin: 8
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Label { text: "Wafer"; color: Theme.bodyColor; font.pixelSize: Theme.fontXs }
|
||||||
|
MetaField { id: metaWafer }
|
||||||
|
Label { text: "Date"; color: Theme.bodyColor; font.pixelSize: Theme.fontXs; Layout.topMargin: 6 }
|
||||||
|
MetaField { id: metaDate }
|
||||||
|
Label { text: "Chamber"; color: Theme.bodyColor; font.pixelSize: Theme.fontXs; Layout.topMargin: 6 }
|
||||||
|
MetaField { id: metaChamber }
|
||||||
|
Label { text: "Notes"; color: Theme.bodyColor; font.pixelSize: Theme.fontXs; Layout.topMargin: 6 }
|
||||||
|
MetaField { id: metaNotes }
|
||||||
|
|
||||||
|
// ── buttons ───────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 14
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: cancelCsvBtn
|
||||||
|
text: "Cancel"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: cancelCsvBtn.down ? Theme.buttonNeutralPressed : cancelCsvBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: cancelCsvBtn.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
onClicked: editCsvDialog.reject()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
id: saveCsvBtn
|
||||||
|
text: "Save"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: saveCsvBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.primaryAccent
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: saveCsvBtn.text
|
||||||
|
color: Theme.tone100
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
onClicked: editCsvDialog.accept()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Label { text: "Wafer" }
|
|
||||||
TextField { id: metaWafer; Layout.fillWidth: true }
|
|
||||||
Label { text: "Date" }
|
|
||||||
TextField { id: metaDate; Layout.fillWidth: true }
|
|
||||||
Label { text: "Chamber" }
|
|
||||||
TextField { id: metaChamber; Layout.fillWidth: true }
|
|
||||||
Label { text: "Notes" }
|
|
||||||
TextField { id: metaNotes; Layout.fillWidth: true }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,74 +313,74 @@ ColumnLayout {
|
|||||||
|
|
||||||
Item { Layout.fillHeight: true }
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
RowLayout {
|
// Badge cluster centers on the full card width; port name overlays
|
||||||
|
// the right edge independently so a long port string can't drag
|
||||||
|
// the centered badge off-center (was sharing width via RowLayout).
|
||||||
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 8
|
Layout.preferredHeight: 22
|
||||||
|
|
||||||
Item {
|
Row {
|
||||||
Layout.fillWidth: true
|
anchors.centerIn: parent
|
||||||
implicitHeight: 22
|
spacing: 5
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: 5
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: 4
|
|
||||||
Rectangle {
|
|
||||||
width: 4; height: 4; radius: 2
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
|
|
||||||
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
|
|
||||||
|
|
||||||
SequentialAnimation on opacity {
|
|
||||||
running: root.isConnected || root.isBusy
|
|
||||||
loops: Animation.Infinite
|
|
||||||
NumberAnimation { to: 0.3; duration: 1400 }
|
|
||||||
NumberAnimation { to: 1.0; duration: 1400 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: 4
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
width: 4; height: 4; radius: 2
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: statusPillText.implicitWidth + 16
|
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
|
||||||
height: 20
|
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
|
||||||
radius: 10
|
|
||||||
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
|
|
||||||
|
|
||||||
Text {
|
SequentialAnimation on opacity {
|
||||||
id: statusPillText
|
running: root.isConnected || root.isBusy
|
||||||
anchors.centerIn: parent
|
loops: Animation.Infinite
|
||||||
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
|
NumberAnimation { to: 0.3; duration: 1400 }
|
||||||
color: Theme.statusBadgeText
|
NumberAnimation { to: 1.0; duration: 1400 }
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.weight: Font.Bold
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.letterSpacing: 0.6
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Repeater {
|
Rectangle {
|
||||||
model: 4
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
Rectangle {
|
width: statusPillText.implicitWidth + 16
|
||||||
width: 4; height: 4; radius: 2
|
height: 20
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
radius: 10
|
||||||
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
|
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
|
||||||
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
|
|
||||||
|
|
||||||
SequentialAnimation on opacity {
|
Text {
|
||||||
running: root.isConnected || root.isBusy
|
id: statusPillText
|
||||||
loops: Animation.Infinite
|
anchors.centerIn: parent
|
||||||
NumberAnimation { to: 0.3; duration: 1400 }
|
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
|
||||||
NumberAnimation { to: 1.0; duration: 1400 }
|
color: Theme.statusBadgeText
|
||||||
}
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: 4
|
||||||
|
Rectangle {
|
||||||
|
width: 4; height: 4; radius: 2
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
|
||||||
|
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: root.isConnected || root.isBusy
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.3; duration: 1400 }
|
||||||
|
NumberAnimation { to: 1.0; duration: 1400 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: root.portName
|
text: root.portName
|
||||||
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
||||||
font.pixelSize: Theme.fontMd
|
font.pixelSize: Theme.fontMd
|
||||||
@@ -612,7 +711,7 @@ ColumnLayout {
|
|||||||
root.dataCols = 0;
|
root.dataCols = 0;
|
||||||
root.csvPath = "";
|
root.csvPath = "";
|
||||||
if (result && result.success === true)
|
if (result && result.success === true)
|
||||||
saveDirDialog.open();
|
root.parseAndSavePendingRead();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ PanelBox {
|
|||||||
margin: 1.0
|
margin: 1.0
|
||||||
blend: card.blendAmount / 100
|
blend: card.blendAmount / 100
|
||||||
showLabels: true
|
showLabels: true
|
||||||
|
showExtremes: false
|
||||||
ringColor: Theme.waferRingColor
|
ringColor: Theme.waferRingColor
|
||||||
axisColor: Theme.waferAxisColor
|
axisColor: Theme.waferAxisColor
|
||||||
lowColor: Theme.sensorLow
|
lowColor: Theme.sensorLow
|
||||||
|
|||||||
@@ -260,14 +260,14 @@ ColumnLayout {
|
|||||||
id: importFileDialog
|
id: importFileDialog
|
||||||
title: "Select Data File To Import"
|
title: "Select Data File To Import"
|
||||||
nameFilters: ["CSV files (*.csv)"]
|
nameFilters: ["CSV files (*.csv)"]
|
||||||
onAccepted: file_browser.importZWaferCsv(String(selectedFile).replace(/^file:\/\//, ""))
|
onAccepted: file_browser.importZWaferCsv(String(selectedFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
FolderDialog {
|
FolderDialog {
|
||||||
id: batchExportDirDialog
|
id: batchExportDirDialog
|
||||||
title: "Export Wafer Maps To…"
|
title: "Export Wafer Maps To…"
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
var dir = String(selectedFolder).replace(/^file:\/\//, "");
|
var dir = String(selectedFolder);
|
||||||
var paths = file_browser.files.map(function(row) { return row.fileName; });
|
var paths = file_browser.files.map(function(row) { return row.fileName; });
|
||||||
batchExportController.start(paths, dir);
|
batchExportController.start(paths, dir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,8 +110,13 @@ Rectangle {
|
|||||||
|
|
||||||
TabButton {
|
TabButton {
|
||||||
id: liveTab
|
id: liveTab
|
||||||
|
// Only X-family wafer firmware supports live streaming
|
||||||
|
// (C# parity, Form1.cs btnConnect_Click) — other families
|
||||||
|
// have no live-stream protocol to speak.
|
||||||
|
readonly property string detectedFamily: (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) ? deviceController.lastWaferInfo[0] : ""
|
||||||
|
readonly property bool familySupportsStream: detectedFamily === "X"
|
||||||
text: "Live"
|
text: "Live"
|
||||||
enabled: deviceController.connectionStatus === "Connected"
|
enabled: deviceController.connectionStatus === "Connected" && familySupportsStream
|
||||||
opacity: enabled ? 1.0 : 0.4
|
opacity: enabled ? 1.0 : 0.4
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 24
|
implicitHeight: 24
|
||||||
@@ -120,7 +125,9 @@ Rectangle {
|
|||||||
|
|
||||||
AppToolTip {
|
AppToolTip {
|
||||||
visible: disabledHover.containsMouse && !liveTab.enabled
|
visible: disabledHover.containsMouse && !liveTab.enabled
|
||||||
text: "Connect a wafer to enable Live mode"
|
text: deviceController.connectionStatus !== "Connected"
|
||||||
|
? "Connect a wafer to enable Live mode"
|
||||||
|
: "Live mode is only supported on X-family wafers (detected " + liveTab.detectedFamily + ")"
|
||||||
delay: 300
|
delay: 300
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,12 +366,14 @@ Rectangle {
|
|||||||
text: streamController.mode === "live" ? "STOP" : "START"
|
text: streamController.mode === "live" ? "STOP" : "START"
|
||||||
enabled: streamController.mode === "live"
|
enabled: streamController.mode === "live"
|
||||||
? true
|
? true
|
||||||
: deviceController.connectionStatus === "Connected"
|
: (deviceController.connectionStatus === "Connected" && liveTab.familySupportsStream)
|
||||||
opacity: enabled ? 1.0 : 0.4
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
|
||||||
AppToolTip {
|
AppToolTip {
|
||||||
visible: primaryBtn.hovered && !primaryBtn.enabled
|
visible: primaryBtn.hovered && !primaryBtn.enabled
|
||||||
text: "Connect a wafer to enable Live mode"
|
text: deviceController.connectionStatus !== "Connected"
|
||||||
|
? "Connect a wafer to enable Live mode"
|
||||||
|
: "Live mode is only supported on X-family wafers (detected " + liveTab.detectedFamily + ")"
|
||||||
delay: 300
|
delay: 300
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import threading
|
|||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
|
|
||||||
from pygui.backend.utils import slot_error_boundary
|
from pygui.backend.utils import slot_error_boundary, to_local_path
|
||||||
from pygui.backend.wafer.batch_export import BatchExportResult, export_batch
|
from pygui.backend.wafer.batch_export import BatchExportResult, export_batch
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -33,7 +33,9 @@ class BatchExportController(QObject):
|
|||||||
self._busy = True
|
self._busy = True
|
||||||
self.busyChanged.emit()
|
self.busyChanged.emit()
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._worker, args=(list(file_paths), output_dir), daemon=True
|
target=self._worker,
|
||||||
|
args=(list(file_paths), str(to_local_path(output_dir))),
|
||||||
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
def _worker(self, file_paths: list[str], output_dir: str) -> None:
|
def _worker(self, file_paths: list[str], output_dir: str) -> None:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
|||||||
|
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from pygui.backend.models.data_model import TemperatureTableModel
|
from pygui.backend.models.data_model import TemperatureTableModel
|
||||||
from pygui.backend.utils import slot_error_boundary
|
from pygui.backend.utils import slot_error_boundary, to_local_path
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
from pygui.serialcomm.data_parser import (
|
from pygui.serialcomm.data_parser import (
|
||||||
convert_to_debug_temperatures,
|
convert_to_debug_temperatures,
|
||||||
@@ -82,7 +82,8 @@ class DeviceController(QObject):
|
|||||||
self._operation_in_progress = False
|
self._operation_in_progress = False
|
||||||
self._activity_log: list[str] = []
|
self._activity_log: list[str] = []
|
||||||
self._raw_bytes: Optional[bytes] = None
|
self._raw_bytes: Optional[bytes] = None
|
||||||
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
|
saved_dir = getattr(settings, 'save_data_dir', "")
|
||||||
|
self._save_data_dir: str = str(to_local_path(saved_dir)) if saved_dir else str(Path(self._data_dir) / "csv")
|
||||||
self._last_wafer_info: dict[str, Any] = {}
|
self._last_wafer_info: dict[str, Any] = {}
|
||||||
self._wafer_detected: bool = False
|
self._wafer_detected: bool = False
|
||||||
self._last_csv_path: str = ""
|
self._last_csv_path: str = ""
|
||||||
@@ -141,8 +142,8 @@ class DeviceController(QObject):
|
|||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def setSaveDataDir(self, path: str) -> None:
|
def setSaveDataDir(self, path: str) -> None:
|
||||||
"""Set the directory for saving parsed CSV data files."""
|
"""Set the directory for saving parsed CSV data files."""
|
||||||
self._save_data_dir = path
|
self._save_data_dir = str(to_local_path(path))
|
||||||
self._append_log(f"Save data dir set to: {path}")
|
self._append_log(f"Save data dir set to: {self._save_data_dir}")
|
||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot(result=str)
|
@Slot(result=str)
|
||||||
@@ -303,6 +304,13 @@ class DeviceController(QObject):
|
|||||||
self._activity_log = self._activity_log[-200:]
|
self._activity_log = self._activity_log[-200:]
|
||||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def appendActivityLog(self, message: str) -> None:
|
||||||
|
"""Public entry point for QML-side conditions (e.g. license/trial
|
||||||
|
state) to log into the same Activity Log as backend-triggered lines."""
|
||||||
|
self._append_log(message)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def clearActivityLog(self) -> None:
|
def clearActivityLog(self) -> None:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from pygui.backend.models.sensor_editor import SensorEditor
|
|||||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||||
from pygui.backend.utils import slot_error_boundary
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||||
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
|
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
@@ -43,6 +44,7 @@ class SessionController(QObject):
|
|||||||
loadFileError = Signal(str)
|
loadFileError = Signal(str)
|
||||||
clusterAveragingEnabledChanged = Signal()
|
clusterAveragingEnabledChanged = Signal()
|
||||||
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||||
|
liveError = Signal(str) # fatal live-stream error message, for Activity Log
|
||||||
# trend: per-frame avg for live graph
|
# trend: per-frame avg for live graph
|
||||||
trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame
|
trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame
|
||||||
trendReset = Signal() # live trend buffer cleared (new stream started)
|
trendReset = Signal() # live trend buffer cleared (new stream started)
|
||||||
@@ -53,7 +55,7 @@ class SessionController(QObject):
|
|||||||
segmentExported = Signal(dict) # dict with success, path | error
|
segmentExported = Signal(dict) # dict with success, path | error
|
||||||
# private: marshal a worker-thread frame onto the main thread
|
# private: marshal a worker-thread frame onto the main thread
|
||||||
_liveFrame = Signal(object) # Frame
|
_liveFrame = Signal(object) # Frame
|
||||||
_liveError = Signal() # worker-thread error ping
|
_liveError = Signal(str) # worker-thread error ping, carries exception text
|
||||||
|
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -685,6 +687,16 @@ class SessionController(QObject):
|
|||||||
log.warning("startStream: StreamReader is already running.")
|
log.warning("startStream: StreamReader is already running.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# C# parity (Form1.cs btnConnect_Click: "currently only X wafers
|
||||||
|
# support streaming"). Other families' firmware has no live-stream
|
||||||
|
# protocol — a D2 send on family A produced a burst of unrelated
|
||||||
|
# ASCII output for ~10s then went quiet, not real sensor telemetry.
|
||||||
|
if family_code and family_code != "X":
|
||||||
|
message = f"Live stream is only supported on X-family wafers (detected {family_code})"
|
||||||
|
log.warning(message)
|
||||||
|
self.liveError.emit(message)
|
||||||
|
return
|
||||||
|
|
||||||
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||||||
from pygui.serialcomm.serial_port import SerialPort # transport open
|
from pygui.serialcomm.serial_port import SerialPort # transport open
|
||||||
|
|
||||||
@@ -700,9 +712,16 @@ class SessionController(QObject):
|
|||||||
if not self._active_clusters:
|
if not self._active_clusters:
|
||||||
self._active_clusters = group_sensors_by_radius(self._sensors)
|
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||||||
|
|
||||||
# The new binary protocol sends payload of (sensorCount * 2) bytes
|
# The binary protocol's payload carries a fixed-size channel array
|
||||||
# Each sensor is a big-endian 16-bit value (Sign-Magnitude).
|
# (sensorCount * 2 bytes, big-endian Sign-Magnitude each) that's wider
|
||||||
expected_sensors = 80 if (family_code or "") == "X" else 244
|
# than any one wafer family's real sensor count — trailing channels
|
||||||
|
# are unpopulated hardware slots, not real readings. Bound decoding to
|
||||||
|
# the just-loaded layout's actual sensor count so those float/noise
|
||||||
|
# channels never reach values (and therefore never reach MIN/MAX/AVG
|
||||||
|
# in compute_stats, which has no sensor-count knowledge of its own).
|
||||||
|
# Fall back to the family YAML lookup only if no layout loaded yet
|
||||||
|
# (e.g. startStream called with an unknown family_code).
|
||||||
|
expected_sensors = len(self._sensors) or sensor_count_for(family_code or "")
|
||||||
|
|
||||||
def parse_binary_frame(payload: bytes, seq: int) -> Frame:
|
def parse_binary_frame(payload: bytes, seq: int) -> Frame:
|
||||||
values = []
|
values = []
|
||||||
@@ -733,6 +752,13 @@ class SessionController(QObject):
|
|||||||
self.liveStatsChanged.emit()
|
self.liveStatsChanged.emit()
|
||||||
|
|
||||||
transport = SerialPort.open_port(port, timeout=1)
|
transport = SerialPort.open_port(port, timeout=1)
|
||||||
|
# Discard anything left over from a prior session (e.g. the device
|
||||||
|
# kept transmitting after our last D2S landed) — stale bytes here
|
||||||
|
# misalign the very first packet, corrupting every reading in it.
|
||||||
|
try:
|
||||||
|
transport.reset_input_buffer()
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Could not reset input buffer before start: %s", exc)
|
||||||
# Send 'D2' command padded to 512 bytes to start the stream
|
# Send 'D2' command padded to 512 bytes to start the stream
|
||||||
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
|
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
|
||||||
transport.write(cmd)
|
transport.write(cmd)
|
||||||
@@ -744,8 +770,11 @@ class SessionController(QObject):
|
|||||||
log.error("Live stream error: %s", exc)
|
log.error("Live stream error: %s", exc)
|
||||||
ref = weak_self()
|
ref = weak_self()
|
||||||
if ref is not None:
|
if ref is not None:
|
||||||
ref._liveError.emit()
|
# Marshal onto the main thread via the queued signal — this
|
||||||
ref.stopStream()
|
# callback runs on StreamReader's own worker thread, and
|
||||||
|
# stopStream() joins that same thread, so calling it directly
|
||||||
|
# here would deadlock/raise ("cannot join current thread").
|
||||||
|
ref._liveError.emit(str(exc))
|
||||||
|
|
||||||
def on_frame(frame: Frame):
|
def on_frame(frame: Frame):
|
||||||
ref = weak_self()
|
ref = weak_self()
|
||||||
@@ -772,14 +801,24 @@ class SessionController(QObject):
|
|||||||
self.liveStatsChanged.emit()
|
self.liveStatsChanged.emit()
|
||||||
if self._reader:
|
if self._reader:
|
||||||
transport = self._reader._transport
|
transport = self._reader._transport
|
||||||
|
reader_thread = self._reader._thread
|
||||||
|
# If the worker thread already exited on its own (e.g. a fatal
|
||||||
|
# stream error), it already closed the transport in its finally
|
||||||
|
# block — writing here races that close and raises EBADF. Nothing
|
||||||
|
# is listening for D2S anyway once the reader has given up.
|
||||||
|
reader_still_running = reader_thread is not None and reader_thread.is_alive()
|
||||||
|
|
||||||
# Send 'D2S' command padded to 512 bytes to stop the stream
|
# Send 'D2S' command padded to 512 bytes to stop the stream
|
||||||
# Send BEFORE stopping the reader which will close the port on thread exit
|
# Send BEFORE stopping the reader which will close the port on thread exit
|
||||||
if transport and transport.is_open:
|
if reader_still_running and transport and transport.is_open:
|
||||||
try:
|
try:
|
||||||
cmd = b"D2S" + (b"F" * 509)
|
cmd = b"D2S" + (b"F" * 509)
|
||||||
transport.write(cmd)
|
transport.write(cmd)
|
||||||
transport.flush()
|
transport.flush()
|
||||||
|
# Device may keep transmitting a few more bytes after D2S
|
||||||
|
# lands — discard them so they don't survive into the
|
||||||
|
# next Live session and misalign its first packet.
|
||||||
|
transport.reset_input_buffer()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Error sending stop command: %s", exc)
|
log.error("Error sending stop command: %s", exc)
|
||||||
|
|
||||||
@@ -815,10 +854,15 @@ class SessionController(QObject):
|
|||||||
self.frameUpdated.emit()
|
self.frameUpdated.emit()
|
||||||
self.stateChanged.emit()
|
self.stateChanged.emit()
|
||||||
|
|
||||||
def _on_live_error(self) -> None:
|
def _on_live_error(self, message: str) -> None:
|
||||||
"""Main-thread callback for worker-thread stream errors."""
|
"""Main-thread callback for worker-thread stream errors. StreamReader
|
||||||
|
only calls its on_error for resync exhaustion or an unhandled parse
|
||||||
|
exception — both unrecoverable for the current stream — so this ends
|
||||||
|
the session and surfaces the reason (Activity Log wiring in QML)."""
|
||||||
self._error_count += 1
|
self._error_count += 1
|
||||||
self.liveStatsChanged.emit()
|
self.liveStatsChanged.emit()
|
||||||
|
self.liveError.emit(message)
|
||||||
|
self.stopStream()
|
||||||
|
|
||||||
# ---- recording ----
|
# ---- recording ----
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import QFileDialog, QMessageBox
|
|||||||
|
|
||||||
from pygui.backend.data.constants import default_data_dir
|
from pygui.backend.data.constants import default_data_dir
|
||||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
||||||
|
from pygui.backend.utils import to_local_path
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ class FileBrowser(QObject):
|
|||||||
Used to keep this directory in sync with DeviceController.saveDataDir
|
Used to keep this directory in sync with DeviceController.saveDataDir
|
||||||
when the user picks a save folder elsewhere in the app.
|
when the user picks a save folder elsewhere in the app.
|
||||||
"""
|
"""
|
||||||
self._set_current_directory(Path(path))
|
self._set_current_directory(to_local_path(path))
|
||||||
self._refresh_files(show_empty_message=False)
|
self._refresh_files(show_empty_message=False)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
@@ -124,7 +125,8 @@ class FileBrowser(QObject):
|
|||||||
date still gets a deterministic (if degenerate) filename, so the
|
date still gets a deterministic (if degenerate) filename, so the
|
||||||
duplicate-import guard below still fires on a second attempt.
|
duplicate-import guard below still fires on a second attempt.
|
||||||
"""
|
"""
|
||||||
source_path = Path(file_path)
|
source_path = to_local_path(file_path)
|
||||||
|
file_path = str(source_path)
|
||||||
parser_data, _ = self._parser.parse(file_path)
|
parser_data, _ = self._parser.parse(file_path)
|
||||||
if parser_data is None:
|
if parser_data is None:
|
||||||
self.importFinished.emit("", f"Failed to parse CSV:\n{source_path.name}")
|
self.importFinished.emit("", f"Failed to parse CSV:\n{source_path.name}")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import logging
|
|||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import unquote, urlparse
|
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, Signal, Slot
|
from PySide6.QtCore import Property, QObject, Signal, Slot
|
||||||
|
|
||||||
@@ -14,19 +13,13 @@ from pygui.backend.license.license_manager import (
|
|||||||
parse_license_blob,
|
parse_license_blob,
|
||||||
read_license_files,
|
read_license_files,
|
||||||
)
|
)
|
||||||
|
from pygui.backend.utils import to_local_path as _to_local_path
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
TRIAL_DAYS = 30
|
TRIAL_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
def _to_local_path(path_or_url: str) -> Path:
|
|
||||||
"""Accept both plain paths and file:// URLs (QML FileDialog gives URLs)."""
|
|
||||||
if path_or_url.startswith("file:"):
|
|
||||||
return Path(unquote(urlparse(path_or_url).path))
|
|
||||||
return Path(path_or_url)
|
|
||||||
|
|
||||||
|
|
||||||
# ===== License Model =====
|
# ===== License Model =====
|
||||||
class LicenseModel(QObject):
|
class LicenseModel(QObject):
|
||||||
"""Context property backing the About dialog license grid.
|
"""Context property backing the About dialog license grid.
|
||||||
@@ -207,3 +200,12 @@ class LicenseModel(QObject):
|
|||||||
return True
|
return True
|
||||||
age_days = (time.time() - marker.stat().st_mtime) / 86400.0
|
age_days = (time.time() - marker.stat().st_mtime) / 86400.0
|
||||||
return age_days > TRIAL_DAYS
|
return age_days > TRIAL_DAYS
|
||||||
|
|
||||||
|
@Slot(result=bool)
|
||||||
|
def reviewAccessBlocked(self) -> bool:
|
||||||
|
"""True only once a started trial has actually expired (ADR-0005).
|
||||||
|
|
||||||
|
A "none" state (trial never started) is not a block — nothing to
|
||||||
|
enforce yet. "permanent" (a real license) never blocks.
|
||||||
|
"""
|
||||||
|
return self.replayLicenseState() == "temporary" and self.replayTrialExpired()
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
|
||||||
|
_MANGLED_DRIVE_RE = re.compile(r"^/([A-Za-z]:)")
|
||||||
|
|
||||||
|
|
||||||
|
def to_local_path(path_or_url: str) -> Path:
|
||||||
|
"""Accept plain paths, file:// URLs, and already-mangled "/C:/..." strings
|
||||||
|
(persisted by old buggy code before it stripped file:// by hand).
|
||||||
|
|
||||||
|
Uses QUrl.toLocalFile() rather than a manual string strip — stripping
|
||||||
|
"file://" by hand leaves a leading slash before a Windows drive letter
|
||||||
|
(e.g. "/C:/Users/...", not a valid absolute path), which QUrl's
|
||||||
|
platform-aware conversion handles correctly.
|
||||||
|
"""
|
||||||
|
if path_or_url.startswith("file:"):
|
||||||
|
return Path(QUrl(path_or_url).toLocalFile())
|
||||||
|
# ponytail: repair values saved by the old regex-stripping QML code
|
||||||
|
return Path(_MANGLED_DRIVE_RE.sub(r"\1", path_or_url))
|
||||||
|
|
||||||
|
|
||||||
def slot_error_boundary(func):
|
def slot_error_boundary(func):
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
|
def ring_boundaries(sensors: list[Sensor]) -> list[float]:
|
||||||
|
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
||||||
|
if not sensors:
|
||||||
|
r = 150.0
|
||||||
|
return [r * f for f in (0.25, 0.50, 0.75, 1.0)]
|
||||||
|
# Cluster radii that are within 2 mm of each other into one ring; skip center point.
|
||||||
|
radii = sorted(r for r in {math.hypot(s.x, s.y) for s in sensors} if r > 1.0)
|
||||||
|
groups: list[float] = []
|
||||||
|
for r in radii:
|
||||||
|
if not groups or r - groups[-1] > 2.0:
|
||||||
|
groups.append(r)
|
||||||
|
else:
|
||||||
|
groups[-1] = (groups[-1] + r) / 2 # merge close values
|
||||||
|
# Always include the outer boundary ring so the wafer circle is drawn.
|
||||||
|
outer = max(math.hypot(s.x, s.y) for s in sensors) * 1.05
|
||||||
|
if not groups or outer - groups[-1] > 2.0:
|
||||||
|
groups.append(outer)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def bucket_by_ring(sensors: list[Sensor], values: list[float]) -> dict[int, list[float]]:
|
||||||
|
"""Group each sensor's value under the index of its nearest ring boundary."""
|
||||||
|
boundaries = ring_boundaries(sensors)
|
||||||
|
result: dict[int, list[float]] = {i: [] for i in range(len(boundaries))}
|
||||||
|
for i, s in enumerate(sensors):
|
||||||
|
if i >= len(values):
|
||||||
|
continue
|
||||||
|
r = math.hypot(s.x, s.y)
|
||||||
|
idx = min(range(len(boundaries)), key=lambda j: abs(boundaries[j] - r))
|
||||||
|
result[idx].append(values[i])
|
||||||
|
return result
|
||||||
@@ -28,6 +28,7 @@ from PySide6.QtQml import QmlElement
|
|||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
from pygui.backend.models.frame_stats import compute_stats
|
from pygui.backend.models.frame_stats import compute_stats
|
||||||
|
from pygui.backend.visualization.radial_metrics import ring_boundaries
|
||||||
from pygui.backend.visualization.rbf_heatmap import (
|
from pygui.backend.visualization.rbf_heatmap import (
|
||||||
ellipse_alpha,
|
ellipse_alpha,
|
||||||
interpolate_field,
|
interpolate_field,
|
||||||
@@ -517,22 +518,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
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:
|
return ring_boundaries(self._sensors)
|
||||||
r = self._wafer_radius_mm()
|
|
||||||
return [r * f for f in (0.25, 0.50, 0.75, 1.0)]
|
|
||||||
# Cluster radii that are within 2 mm of each other into one ring; skip center point.
|
|
||||||
radii = sorted(r for r in {math.hypot(s.x, s.y) for s in self._sensors} if r > 1.0)
|
|
||||||
groups: list[float] = []
|
|
||||||
for r in radii:
|
|
||||||
if not groups or r - groups[-1] > 2.0:
|
|
||||||
groups.append(r)
|
|
||||||
else:
|
|
||||||
groups[-1] = (groups[-1] + r) / 2 # merge close values
|
|
||||||
# Always include the outer boundary ring so the wafer circle is drawn.
|
|
||||||
outer = self._wafer_radius_mm()
|
|
||||||
if not groups or outer - groups[-1] > 2.0:
|
|
||||||
groups.append(outer)
|
|
||||||
return groups
|
|
||||||
|
|
||||||
def _scale(self, ds: int, r_mm: float) -> float:
|
def _scale(self, ds: int, r_mm: float) -> float:
|
||||||
"""Pixels per mm. The wafer radius maps to ds//2 - 24 px."""
|
"""Pixels per mm. The wafer radius maps to ds//2 - 24 px."""
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""Single source of truth for the wafer sensor-count partition."""
|
"""Single source of truth for the wafer sensor-count partition."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||||||
|
|
||||||
|
|
||||||
def sensor_count_for(family_code: str) -> int:
|
def sensor_count_for(family_code: str) -> int:
|
||||||
"""Return the number of valid sensor readings for a wafer family code.
|
"""Real sensor count for a wafer family code, from its YAML layout.
|
||||||
|
|
||||||
Returns 80 for the "X" family. Returns 244 for every other family
|
0 for an unknown/empty family code."""
|
||||||
code."""
|
try:
|
||||||
return 80 if family_code == "X" else 244
|
return len(load_layout_for_wafer_id(family_code))
|
||||||
|
except KeyError:
|
||||||
|
return 0
|
||||||
|
|||||||
@@ -20,17 +20,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def csv_column_count(family_code: str) -> int:
|
def csv_column_count(family_code: str) -> int:
|
||||||
"""Return the number of columns to display for a family code."""
|
"""Return the number of columns to display for a family code."""
|
||||||
mapping = {
|
return sensor_count_for(family_code)
|
||||||
"A": 48,
|
|
||||||
"E": 48,
|
|
||||||
"P": 48,
|
|
||||||
"B": 29,
|
|
||||||
"C": 29,
|
|
||||||
"D": 29,
|
|
||||||
"F": 22,
|
|
||||||
"X": 80,
|
|
||||||
}
|
|
||||||
return mapping.get(family_code, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _hex_to_binary(hex_str: str) -> list[int]:
|
def _hex_to_binary(hex_str: str) -> list[int]:
|
||||||
@@ -160,19 +150,19 @@ def parse_binary_data(data_bytes: bytes, family_code: str) -> Optional[list[list
|
|||||||
if family_code == "X":
|
if family_code == "X":
|
||||||
return _parse_x_binary(data_bytes)
|
return _parse_x_binary(data_bytes)
|
||||||
else:
|
else:
|
||||||
return _parse_p_binary(data_bytes)
|
return _parse_p_binary(data_bytes, family_code)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Binary parse failed: %s", exc)
|
log.error("Binary parse failed: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_p_binary(data_bytes: bytes) -> list[list[str]]:
|
def _parse_p_binary(data_bytes: bytes, family_code: str = "P") -> list[list[str]]:
|
||||||
"""Parse P-family (and A/B/C/D/E/F) binary data.
|
"""Parse P-family (and A/B/C/D/E/F) binary data.
|
||||||
|
|
||||||
Each block of 256 readings has 244 valid + 12 overhead.
|
Each block of 256 readings has 244 valid + 12 overhead.
|
||||||
Valid readings are chunked into rows of the family's sensor count (244).
|
Valid readings are chunked into rows of the family's sensor count (244).
|
||||||
"""
|
"""
|
||||||
sensor_count = sensor_count_for("P")
|
sensor_count = sensor_count_for(family_code or "P")
|
||||||
readings: list[str] = []
|
readings: list[str] = []
|
||||||
|
|
||||||
# Read 2 bytes at a time (UInt16 little-endian)
|
# Read 2 bytes at a time (UInt16 little-endian)
|
||||||
@@ -190,7 +180,7 @@ def _parse_p_binary(data_bytes: bytes) -> list[list[str]]:
|
|||||||
result.append(readings[idx : idx + sensor_count])
|
result.append(readings[idx : idx + sensor_count])
|
||||||
idx += sensor_count
|
idx += sensor_count
|
||||||
|
|
||||||
log.info("Parsed P-family: %d rows × %d cols", len(result), sensor_count)
|
log.info("Parsed %s-family: %d rows × %d cols", family_code or "P", len(result), sensor_count)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,33 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
ParseFrame = Callable[[bytes, int], Frame]
|
ParseFrame = Callable[[bytes, int], Frame]
|
||||||
|
|
||||||
# Max consecutive resync attempts before giving up on a corrupt stream.
|
# Max garbage bytes discarded (no sync marker found) before giving up on a
|
||||||
_MAX_RESYNC_ATTEMPTS = 50
|
# corrupt stream. Counted in BYTES, not read attempts — attempts scale with
|
||||||
|
# data rate (a slow trickle could take minutes to trip an attempt-count cap),
|
||||||
|
# bytes don't. Real frames are <=500B, so a healthy stream never comes close;
|
||||||
|
# 16KB is ~32 frames of slack, ~1.4s of pure garbage at 115200 baud. Empty
|
||||||
|
# reads (idle port) never count — C#'s findMessageStart waits forever on quiet.
|
||||||
|
_MAX_RESYNC_BYTES = 16384
|
||||||
|
|
||||||
|
# Largest believable payload_len. Real frames are ≤ 488 bytes (244 words);
|
||||||
|
# anything bigger means we latched onto a false 0xAA 0x88 inside data and
|
||||||
|
# read garbage as a length — without this cap, _accumulate would swallow up
|
||||||
|
# to 64KB of good frames waiting for a packet that doesn't exist.
|
||||||
|
# ponytail: loose cap; exact per-family length check if false frames slip CRC.
|
||||||
|
_MAX_PAYLOAD_LEN = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def _crc16(data: bytes) -> int:
|
||||||
|
"""CRC16/MODBUS (poly 0xA001, init 0xFFFF) — mirrors C# CalculateCRC16."""
|
||||||
|
crc = 0xFFFF
|
||||||
|
for byte in data:
|
||||||
|
crc ^= byte
|
||||||
|
for _ in range(8):
|
||||||
|
if crc & 1:
|
||||||
|
crc = (crc >> 1) ^ 0xA001
|
||||||
|
else:
|
||||||
|
crc >>= 1
|
||||||
|
return crc
|
||||||
|
|
||||||
class StreamReader:
|
class StreamReader:
|
||||||
|
|
||||||
@@ -63,43 +88,43 @@ class StreamReader:
|
|||||||
|
|
||||||
def _run(self) -> None:
|
def _run(self) -> None:
|
||||||
try:
|
try:
|
||||||
# Peek at the first few bytes to determine protocol.
|
# Protocol is decided by the first byte: 0xAA is the binary sync
|
||||||
peek_buf = bytearray()
|
# marker's lead byte and can never appear in the ASCII hex-dump
|
||||||
while not self._stop.is_set() and len(peek_buf) < 32:
|
# stream (0-9/A-F only) real hardware has been observed sending
|
||||||
b = self._transport.read(1)
|
# — see docs/adr/0007. Anything else is ASCII.
|
||||||
if not b:
|
first = b""
|
||||||
continue
|
while not first and not self._stop.is_set():
|
||||||
peek_buf.extend(b)
|
first = self._transport.read(1)
|
||||||
if b[0] == 10: # newline
|
if self._stop.is_set() or not first:
|
||||||
break
|
|
||||||
|
|
||||||
if self._stop.is_set() or not peek_buf:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
raw = bytes(peek_buf)
|
if first[0] == 0xAA:
|
||||||
|
second = b""
|
||||||
# Binary sync bytes are the strongest signal.
|
while not second and not self._stop.is_set():
|
||||||
if raw.startswith(b"\xaa\x88"):
|
second = self._transport.read(1)
|
||||||
self._run_binary(raw)
|
if self._stop.is_set():
|
||||||
|
return
|
||||||
|
if second and second[0] == 0x88:
|
||||||
|
self._run_binary(bytearray(first + second))
|
||||||
|
return
|
||||||
|
# Lone 0xAA, not a real marker — feed both bytes into the
|
||||||
|
# ASCII path instead of discarding them.
|
||||||
|
self._run_ascii(bytes(first) + bytes(second))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Text-lines (CSV) requires BOTH a newline AND commas in the
|
self._run_ascii(bytes(first))
|
||||||
# pre-newline content. This avoids misclassifying a binary
|
|
||||||
# payload that happens to contain 0x2C as text.
|
|
||||||
has_newline = b"\n" in raw or b"\r" in raw
|
|
||||||
line_before_nl = raw.split(b"\n")[0].split(b"\r")[0]
|
|
||||||
if has_newline and b"," in line_before_nl:
|
|
||||||
self._run_text_lines(raw)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Default: ASCII hex dump stream.
|
|
||||||
self._run_ascii(raw)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.exception("StreamReader thread encountered error: %s", exc)
|
if self._stop.is_set():
|
||||||
try:
|
# stop() closes the transport to unblock a pending read —
|
||||||
self._on_error(exc)
|
# that self-inflicted close raises here too (e.g. EBADF).
|
||||||
except Exception:
|
# Not a real stream fault, so don't report it as one.
|
||||||
pass
|
log.debug("StreamReader thread exiting after stop(): %s", exc)
|
||||||
|
else:
|
||||||
|
log.exception("StreamReader thread encountered error: %s", exc)
|
||||||
|
try:
|
||||||
|
self._on_error(exc)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
self._transport.close()
|
self._transport.close()
|
||||||
@@ -109,7 +134,8 @@ class StreamReader:
|
|||||||
def _run_binary(self, initial_bytes: bytes | bytearray) -> None:
|
def _run_binary(self, initial_bytes: bytes | bytearray) -> None:
|
||||||
log.info("StreamReader: starting binary stream parsing")
|
log.info("StreamReader: starting binary stream parsing")
|
||||||
buf = bytearray(initial_bytes)
|
buf = bytearray(initial_bytes)
|
||||||
resync_attempts = 0
|
resync_bytes = 0
|
||||||
|
logged_head = False
|
||||||
|
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
# Find sync marker in buffer.
|
# Find sync marker in buffer.
|
||||||
@@ -122,7 +148,11 @@ class StreamReader:
|
|||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
if synced:
|
if synced:
|
||||||
resync_attempts = 0
|
if not logged_head:
|
||||||
|
log.debug("StreamReader: first bytes on wire: %s",
|
||||||
|
bytes(buf[:64]).hex())
|
||||||
|
logged_head = True
|
||||||
|
resync_bytes = 0
|
||||||
buf = buf[idx:]
|
buf = buf[idx:]
|
||||||
# Accumulate header (5 bytes after sync).
|
# Accumulate header (5 bytes after sync).
|
||||||
while len(buf) < 7 and not self._stop.is_set():
|
while len(buf) < 7 and not self._stop.is_set():
|
||||||
@@ -133,6 +163,12 @@ class StreamReader:
|
|||||||
return
|
return
|
||||||
|
|
||||||
payload_len = int.from_bytes(buf[5:7], byteorder='little')
|
payload_len = int.from_bytes(buf[5:7], byteorder='little')
|
||||||
|
if payload_len > _MAX_PAYLOAD_LEN:
|
||||||
|
# False sync marker inside data — the "length" is garbage.
|
||||||
|
# Skip past the marker and rescan what we already have.
|
||||||
|
self.resync_count += 1
|
||||||
|
buf = buf[2:]
|
||||||
|
continue
|
||||||
total_packet_len = 2 + 5 + payload_len + 2
|
total_packet_len = 2 + 5 + payload_len + 2
|
||||||
|
|
||||||
# Accumulate the rest of the packet in one buffered read.
|
# Accumulate the rest of the packet in one buffered read.
|
||||||
@@ -144,6 +180,22 @@ class StreamReader:
|
|||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# CRC16 over sync+header+payload, trailing 2 bytes LE —
|
||||||
|
# mirrors C# VerifyData. A failed CRC is a corrupt or
|
||||||
|
# misframed packet: drop it, rescan, keep streaming.
|
||||||
|
received_crc = int.from_bytes(
|
||||||
|
buf[7 + payload_len:9 + payload_len], byteorder='little'
|
||||||
|
)
|
||||||
|
if _crc16(bytes(buf[:7 + payload_len])) != received_crc:
|
||||||
|
self.error_count += 1
|
||||||
|
if self.error_count <= 10:
|
||||||
|
log.warning(
|
||||||
|
"CRC mismatch on binary packet (seq bytes %s) — dropped",
|
||||||
|
bytes(buf[3:5]).hex(),
|
||||||
|
)
|
||||||
|
buf = buf[2:]
|
||||||
|
continue
|
||||||
|
|
||||||
header = buf[2:7]
|
header = buf[2:7]
|
||||||
seq = int.from_bytes(header[1:3], byteorder='little')
|
seq = int.from_bytes(header[1:3], byteorder='little')
|
||||||
payload = buf[7:7 + payload_len]
|
payload = buf[7:7 + payload_len]
|
||||||
@@ -160,27 +212,46 @@ class StreamReader:
|
|||||||
buf = buf[total_packet_len:]
|
buf = buf[total_packet_len:]
|
||||||
else:
|
else:
|
||||||
# Resync: keep only trailing 0xAA if present, then read more.
|
# Resync: keep only trailing 0xAA if present, then read more.
|
||||||
resync_attempts += 1
|
|
||||||
self.resync_count += 1
|
|
||||||
if resync_attempts > _MAX_RESYNC_ATTEMPTS:
|
|
||||||
log.error(
|
|
||||||
"StreamReader: giving up after %d resync attempts "
|
|
||||||
"(no 0xAA 0x88 sync marker found in stream)",
|
|
||||||
resync_attempts,
|
|
||||||
)
|
|
||||||
self._on_error(
|
|
||||||
TimeoutError("Binary stream resync failed: no sync marker")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if len(buf) > 0 and buf[-1] == 0xAA:
|
if len(buf) > 0 and buf[-1] == 0xAA:
|
||||||
buf = bytearray([0xAA])
|
buf = bytearray([0xAA])
|
||||||
else:
|
else:
|
||||||
buf.clear()
|
buf.clear()
|
||||||
|
|
||||||
chunk = self._read_chunk()
|
chunk = self._read_chunk()
|
||||||
|
if not chunk:
|
||||||
|
# Quiet port — waiting for data is not a resync failure.
|
||||||
|
# The device pauses between scans; C#'s findMessageStart
|
||||||
|
# waits forever. Only actual garbage counts toward the cap.
|
||||||
|
continue
|
||||||
|
if not logged_head:
|
||||||
|
log.debug("StreamReader: first bytes on wire: %s",
|
||||||
|
bytes((buf + chunk)[:64]).hex())
|
||||||
|
logged_head = True
|
||||||
|
self.resync_count += 1
|
||||||
|
resync_bytes += len(chunk)
|
||||||
|
if resync_bytes > _MAX_RESYNC_BYTES:
|
||||||
|
# buf was already cleared/reset at the top of this
|
||||||
|
# iteration — the bytes worth dumping are in buf+chunk
|
||||||
|
# (the carryover byte, if any, plus what was just read).
|
||||||
|
# Raw wire content is DEBUG-only — never in the ERROR
|
||||||
|
# line the terminal/Activity Log surface to the user.
|
||||||
|
log.debug("StreamReader: discarded bytes before giving up: %s",
|
||||||
|
bytes((buf + chunk)[:64]).hex())
|
||||||
|
log.error(
|
||||||
|
"StreamReader: giving up after %d garbage bytes "
|
||||||
|
"(no 0xAA 0x88 sync marker found in stream)",
|
||||||
|
resync_bytes,
|
||||||
|
)
|
||||||
|
self._on_error(
|
||||||
|
TimeoutError("Binary stream resync failed: no sync marker")
|
||||||
|
)
|
||||||
|
return
|
||||||
buf.extend(chunk)
|
buf.extend(chunk)
|
||||||
|
|
||||||
def _run_ascii(self, initial_bytes: bytes) -> None:
|
def _run_ascii(self, initial_bytes: bytes) -> None:
|
||||||
|
"""Parse the ASCII hex-dump live stream: fixed-width blocks of
|
||||||
|
4-char hex words, one word per channel slot (up to 256 slots; only
|
||||||
|
the family's real sensor_count are populated, see docs/adr/0007)."""
|
||||||
log.info("StreamReader: starting ASCII hex dump stream parsing")
|
log.info("StreamReader: starting ASCII hex dump stream parsing")
|
||||||
|
|
||||||
valid_words = sensor_count_for(self._family_code)
|
valid_words = sensor_count_for(self._family_code)
|
||||||
@@ -189,12 +260,9 @@ class StreamReader:
|
|||||||
chars_needed = block_words * word_char_len # total hex chars per block
|
chars_needed = block_words * word_char_len # total hex chars per block
|
||||||
|
|
||||||
hex_chars = bytearray()
|
hex_chars = bytearray()
|
||||||
if initial_bytes:
|
for b in initial_bytes:
|
||||||
for b in initial_bytes:
|
if chr(b) in "0123456789ABCDEFabcdef":
|
||||||
if chr(b) in "0123456789ABCDEFabcdef":
|
hex_chars.append(b)
|
||||||
hex_chars.append(b)
|
|
||||||
elif b == 0xAA:
|
|
||||||
hex_chars.append(b)
|
|
||||||
|
|
||||||
seq = 0
|
seq = 0
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
@@ -202,24 +270,15 @@ class StreamReader:
|
|||||||
while len(hex_chars) < chars_needed and not self._stop.is_set():
|
while len(hex_chars) < chars_needed and not self._stop.is_set():
|
||||||
chunk = self._read_chunk(min_bytes=chars_needed - len(hex_chars))
|
chunk = self._read_chunk(min_bytes=chars_needed - len(hex_chars))
|
||||||
for byte in chunk:
|
for byte in chunk:
|
||||||
if byte == 0xAA:
|
|
||||||
# Sync marker appeared – hand off to binary parser.
|
|
||||||
hex_chars.append(byte)
|
|
||||||
break
|
|
||||||
if chr(byte) in "0123456789ABCDEFabcdef":
|
if chr(byte) in "0123456789ABCDEFabcdef":
|
||||||
hex_chars.append(byte)
|
hex_chars.append(byte)
|
||||||
else:
|
|
||||||
continue
|
|
||||||
break # broke out of inner loop due to 0xAA
|
|
||||||
|
|
||||||
# Check for early termination (0xAA sync marker anywhere in buffer).
|
if self._stop.is_set():
|
||||||
aa_idx = hex_chars.find(bytes([0xAA]))
|
|
||||||
if aa_idx >= 0:
|
|
||||||
log.info("StreamReader: Detected 0xAA at position %d, switching to binary mode.", aa_idx)
|
|
||||||
self._run_binary(hex_chars[aa_idx:])
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse words from the accumulated hex characters.
|
# Parse one block's worth of words (buffered reads may overshoot
|
||||||
|
# chars_needed — cap here so the remainder rolls into the next
|
||||||
|
# iteration instead of being folded into this frame and lost).
|
||||||
word_buffer = []
|
word_buffer = []
|
||||||
for i in range(0, min(len(hex_chars), chars_needed), word_char_len):
|
for i in range(0, min(len(hex_chars), chars_needed), word_char_len):
|
||||||
word_str = hex_chars[i:i + word_char_len]
|
word_str = hex_chars[i:i + word_char_len]
|
||||||
@@ -228,7 +287,6 @@ class StreamReader:
|
|||||||
word_buffer.append(bytes(word_str))
|
word_buffer.append(bytes(word_str))
|
||||||
|
|
||||||
if not word_buffer:
|
if not word_buffer:
|
||||||
time.sleep(0.01)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
hex_chars = hex_chars[len(word_buffer) * word_char_len:]
|
hex_chars = hex_chars[len(word_buffer) * word_char_len:]
|
||||||
@@ -238,7 +296,7 @@ class StreamReader:
|
|||||||
for hex_val in valid_hex_words:
|
for hex_val in valid_hex_words:
|
||||||
try:
|
try:
|
||||||
swapped = hex_val[2:4] + hex_val[0:2]
|
swapped = hex_val[2:4] + hex_val[0:2]
|
||||||
t = _convert_hex_to_temp(swapped, self._family_code) # type: ignore[arg-type]
|
t = _convert_hex_to_temp(swapped.decode(), self._family_code)
|
||||||
values.append(t)
|
values.append(t)
|
||||||
except Exception:
|
except Exception:
|
||||||
values.append(0.0)
|
values.append(0.0)
|
||||||
@@ -248,34 +306,29 @@ class StreamReader:
|
|||||||
self._on_frame(frame)
|
self._on_frame(frame)
|
||||||
seq += 1
|
seq += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Error emitting ASCII frame: %s", exc)
|
self.error_count += 1
|
||||||
|
if self.error_count <= 10:
|
||||||
def _run_text_lines(self, initial_bytes: bytes) -> None:
|
log.warning("Error emitting ASCII frame: %s", exc)
|
||||||
log.info("StreamReader: starting line-by-line ASCII text parsing")
|
self._on_error(exc)
|
||||||
buf = bytearray(initial_bytes)
|
|
||||||
seq = 0
|
|
||||||
|
|
||||||
while not self._stop.is_set():
|
|
||||||
# Drain complete lines from the buffer.
|
|
||||||
while b"\n" in buf:
|
|
||||||
line_bytes, _, buf = buf.partition(b"\n")
|
|
||||||
line = line_bytes.decode('utf-8', errors='ignore').strip()
|
|
||||||
if line:
|
|
||||||
try:
|
|
||||||
frame = self._parse(line, seq) # type: ignore[arg-type]
|
|
||||||
if frame:
|
|
||||||
self._on_frame(frame)
|
|
||||||
seq += 1
|
|
||||||
except Exception as exc:
|
|
||||||
self.error_count += 1
|
|
||||||
self._on_error(exc)
|
|
||||||
|
|
||||||
# Read more data using buffered reads.
|
|
||||||
chunk = self._read_chunk()
|
|
||||||
buf.extend(chunk)
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
|
# Close the port out from under any blocked transport.read() call —
|
||||||
|
# relying on _stop alone means a thread stuck in a longer-than-
|
||||||
|
# expected read (or one the OS driver is slow to unblock) keeps the
|
||||||
|
# port open, and a subsequent startStream() then opens a *second*
|
||||||
|
# handle to the same port, racing the still-live old reader for
|
||||||
|
# bytes (garbled/duplicated frames on the next Live session).
|
||||||
|
try:
|
||||||
|
self._transport.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if self._thread is not None and self._thread.is_alive():
|
if self._thread is not None and self._thread.is_alive():
|
||||||
self._thread.join(timeout = 2.0)
|
self._thread.join(timeout = 2.0)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
log.error(
|
||||||
|
"StreamReader: worker thread still alive %.1fs after "
|
||||||
|
"stop() — port may not be released for the next session",
|
||||||
|
2.0,
|
||||||
|
)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
|||||||
@@ -42,8 +42,13 @@ class TestCsvColumnCount:
|
|||||||
def test_x_family(self):
|
def test_x_family(self):
|
||||||
assert csv_column_count("X") == 80
|
assert csv_column_count("X") == 80
|
||||||
|
|
||||||
|
def test_z_family(self):
|
||||||
|
# Z has a real YAML layout (zwafer.yaml) — not "unknown" like the
|
||||||
|
# old hardcoded table implied by omitting it.
|
||||||
|
assert csv_column_count("Z") == 65
|
||||||
|
|
||||||
def test_unknown_family_returns_zero(self):
|
def test_unknown_family_returns_zero(self):
|
||||||
assert csv_column_count("Z") == 0
|
assert csv_column_count("Q") == 0
|
||||||
|
|
||||||
def test_empty_string_returns_zero(self):
|
def test_empty_string_returns_zero(self):
|
||||||
assert csv_column_count("") == 0
|
assert csv_column_count("") == 0
|
||||||
@@ -134,11 +139,11 @@ class TestParseBinaryData:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
|
||||||
def test_p_family_single_block_row_has_244_sensors(self):
|
def test_p_family_single_block_row_has_48_sensors(self):
|
||||||
data = _make_p_block(1)
|
data = _make_p_block(1)
|
||||||
result = parse_binary_data(data, "P")
|
result = parse_binary_data(data, "P")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result[0]) == 244
|
assert len(result[0]) == 48
|
||||||
|
|
||||||
def test_p_family_two_blocks_returns_two_rows(self):
|
def test_p_family_two_blocks_returns_two_rows(self):
|
||||||
data = _make_p_block(2)
|
data = _make_p_block(2)
|
||||||
@@ -170,7 +175,7 @@ class TestParseBinaryData:
|
|||||||
data = _make_p_block(1)
|
data = _make_p_block(1)
|
||||||
result = parse_binary_data(data, "A")
|
result = parse_binary_data(data, "A")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result[0]) == 244
|
assert len(result[0]) == 48
|
||||||
|
|
||||||
def test_empty_bytes_returns_empty_list(self):
|
def test_empty_bytes_returns_empty_list(self):
|
||||||
result = parse_binary_data(b"", "P")
|
result = parse_binary_data(b"", "P")
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ def test_clear_activity_log(controller):
|
|||||||
controller.clearActivityLog()
|
controller.clearActivityLog()
|
||||||
assert len(controller._activity_log) == 0
|
assert len(controller._activity_log) == 0
|
||||||
|
|
||||||
|
def test_append_activity_log_appends_to_activity_log(controller):
|
||||||
|
controller.appendActivityLog("Review trial expired")
|
||||||
|
assert "Review trial expired" in controller.activityLog
|
||||||
|
|
||||||
def test_clear_session(controller):
|
def test_clear_session(controller):
|
||||||
controller._connection_status = "Connected"
|
controller._connection_status = "Connected"
|
||||||
controller._selected_port = "COM1"
|
controller._selected_port = "COM1"
|
||||||
@@ -236,12 +240,12 @@ def test_parse_and_save_data_and_get_chart_data(controller):
|
|||||||
assert emitted_result is not None
|
assert emitted_result is not None
|
||||||
assert emitted_result.get("success") is True
|
assert emitted_result.get("success") is True
|
||||||
assert controller.dataRowCount == 1
|
assert controller.dataRowCount == 1
|
||||||
assert controller.dataColCount == 244
|
assert controller.dataColCount == 48
|
||||||
|
|
||||||
chart_res = controller.getChartData()
|
chart_res = controller.getChartData()
|
||||||
assert chart_res.get("success") is True
|
assert chart_res.get("success") is True
|
||||||
assert "Sensor1" in chart_res.get("sensor_names")
|
assert "Sensor1" in chart_res.get("sensor_names")
|
||||||
assert len(chart_res.get("series")) == 244
|
assert len(chart_res.get("series")) == 48
|
||||||
|
|
||||||
def test_logging_handler(controller):
|
def test_logging_handler(controller):
|
||||||
logger = logging.getLogger("test_logger")
|
logger = logging.getLogger("test_logger")
|
||||||
|
|||||||
+23
-10
@@ -1,4 +1,8 @@
|
|||||||
"""Tests for the shared wafer family sensor-count lookup."""
|
"""Tests for the shared wafer family sensor-count lookup.
|
||||||
|
|
||||||
|
Real counts, one per YAML layout file — this is the ground truth every
|
||||||
|
caller (streaming decode, NVRAM parsing, CSV column count) must agree on.
|
||||||
|
"""
|
||||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||||
|
|
||||||
|
|
||||||
@@ -6,18 +10,27 @@ def test_x_family_returns_80():
|
|||||||
assert sensor_count_for("X") == 80
|
assert sensor_count_for("X") == 80
|
||||||
|
|
||||||
|
|
||||||
def test_p_family_returns_244():
|
def test_aep_family_returns_48():
|
||||||
assert sensor_count_for("P") == 244
|
for code in ("A", "E", "P"):
|
||||||
|
assert sensor_count_for(code) == 48
|
||||||
|
|
||||||
|
|
||||||
def test_other_known_families_return_244():
|
def test_bcd_family_returns_29():
|
||||||
for code in ("A", "B", "C", "D", "E", "F"):
|
for code in ("B", "C", "D"):
|
||||||
assert sensor_count_for(code) == 244
|
assert sensor_count_for(code) == 29
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_family_returns_244():
|
def test_f_family_returns_22():
|
||||||
assert sensor_count_for("Z") == 244
|
assert sensor_count_for("F") == 22
|
||||||
|
|
||||||
|
|
||||||
def test_empty_string_returns_244():
|
def test_z_family_returns_65():
|
||||||
assert sensor_count_for("") == 244
|
assert sensor_count_for("Z") == 65
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_family_returns_zero():
|
||||||
|
assert sensor_count_for("Q") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_string_returns_zero():
|
||||||
|
assert sensor_count_for("") == 0
|
||||||
|
|||||||
@@ -267,3 +267,33 @@ def test_replay_trial_expiry(tmp_path, qapp):
|
|||||||
def test_replay_trial_expired_without_marker(tmp_path, qapp):
|
def test_replay_trial_expired_without_marker(tmp_path, qapp):
|
||||||
model = LicenseModel(str(tmp_path))
|
model = LicenseModel(str(tmp_path))
|
||||||
assert model.replayTrialExpired() is True
|
assert model.replayTrialExpired() is True
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Review-access gate (ADR-0005 "Deferred" item) =====
|
||||||
|
def test_review_access_not_blocked_before_trial_starts(tmp_path, qapp):
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
assert model.reviewAccessBlocked() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_access_not_blocked_during_fresh_trial(tmp_path, qapp):
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
model.startReplayTrial()
|
||||||
|
assert model.reviewAccessBlocked() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_access_blocked_once_trial_expired(tmp_path, qapp):
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
model.startReplayTrial()
|
||||||
|
old = time.time() - 31 * 86400
|
||||||
|
os.utime(model._trial_marker, (old, old))
|
||||||
|
assert model.reviewAccessBlocked() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_access_not_blocked_with_permanent_license_even_if_trial_expired(tmp_path, qapp):
|
||||||
|
(tmp_path / "licenses").mkdir()
|
||||||
|
write_license(tmp_path / "licenses", level="01")
|
||||||
|
model = LicenseModel(str(tmp_path))
|
||||||
|
model.startReplayTrial()
|
||||||
|
old = time.time() - 31 * 86400
|
||||||
|
os.utime(model._trial_marker, (old, old))
|
||||||
|
assert model.reviewAccessBlocked() is False
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pygui.backend.visualization.radial_metrics import bucket_by_ring, ring_boundaries
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_boundaries_empty_sensors_returns_default_quartiles():
|
||||||
|
assert ring_boundaries([]) == [37.5, 75.0, 112.5, 150.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_boundaries_single_ring_plus_outer_boundary():
|
||||||
|
sensors = [
|
||||||
|
Sensor(label="1", x=100.0, y=0.0),
|
||||||
|
Sensor(label="2", x=0.0, y=100.0),
|
||||||
|
Sensor(label="3", x=-100.0, y=0.0),
|
||||||
|
Sensor(label="4", x=0.0, y=-100.0),
|
||||||
|
]
|
||||||
|
assert ring_boundaries(sensors) == [100.0, 105.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_boundaries_excludes_center_point():
|
||||||
|
sensors = [
|
||||||
|
Sensor(label="center", x=0.0, y=0.0),
|
||||||
|
Sensor(label="1", x=50.0, y=0.0),
|
||||||
|
Sensor(label="2", x=0.0, y=50.0),
|
||||||
|
Sensor(label="3", x=-50.0, y=0.0),
|
||||||
|
Sensor(label="4", x=0.0, y=-50.0),
|
||||||
|
]
|
||||||
|
assert ring_boundaries(sensors) == [50.0, 52.5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_boundaries_merges_radii_within_2mm_and_averages():
|
||||||
|
sensors = [
|
||||||
|
Sensor(label="1", x=30.0, y=0.0),
|
||||||
|
Sensor(label="2", x=0.0, y=31.5),
|
||||||
|
Sensor(label="3", x=80.0, y=0.0),
|
||||||
|
]
|
||||||
|
assert ring_boundaries(sensors) == [30.75, 80.0, 84.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_boundaries_square_family_grid_buckets_by_distance_not_axis():
|
||||||
|
# A square-family sensor grid (per wafer_layouts.py, e.g. the 80-sensor
|
||||||
|
# square layout): axis points and corner points sit at different radial
|
||||||
|
# distances from center despite forming a visually square pattern, so
|
||||||
|
# ring clustering must key on hypot() distance, not grid position.
|
||||||
|
sensors = [
|
||||||
|
Sensor(label="axis1", x=40.0, y=0.0),
|
||||||
|
Sensor(label="axis2", x=-40.0, y=0.0),
|
||||||
|
Sensor(label="axis3", x=0.0, y=40.0),
|
||||||
|
Sensor(label="axis4", x=0.0, y=-40.0),
|
||||||
|
Sensor(label="corner1", x=40.0, y=40.0),
|
||||||
|
Sensor(label="corner2", x=-40.0, y=-40.0),
|
||||||
|
]
|
||||||
|
axis_r = 40.0
|
||||||
|
corner_r = math.hypot(40.0, 40.0)
|
||||||
|
outer = corner_r * 1.05
|
||||||
|
assert ring_boundaries(sensors) == pytest.approx([axis_r, corner_r, outer])
|
||||||
|
|
||||||
|
|
||||||
|
def test_bucket_by_ring_groups_single_ring_sensors_together():
|
||||||
|
sensors = [
|
||||||
|
Sensor(label="1", x=100.0, y=0.0),
|
||||||
|
Sensor(label="2", x=0.0, y=100.0),
|
||||||
|
Sensor(label="3", x=-100.0, y=0.0),
|
||||||
|
Sensor(label="4", x=0.0, y=-100.0),
|
||||||
|
]
|
||||||
|
values = [10.0, 20.0, 30.0, 40.0]
|
||||||
|
assert bucket_by_ring(sensors, values) == {0: [10.0, 20.0, 30.0, 40.0], 1: []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bucket_by_ring_assigns_each_sensor_to_its_nearest_boundary():
|
||||||
|
sensors = [
|
||||||
|
Sensor(label="1", x=30.0, y=0.0),
|
||||||
|
Sensor(label="2", x=0.0, y=31.5),
|
||||||
|
Sensor(label="3", x=80.0, y=0.0),
|
||||||
|
]
|
||||||
|
values = [1.0, 2.0, 3.0]
|
||||||
|
assert bucket_by_ring(sensors, values) == {0: [1.0, 2.0], 1: [3.0], 2: []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bucket_by_ring_ignores_sensors_beyond_a_short_values_list():
|
||||||
|
sensors = [
|
||||||
|
Sensor(label="1", x=100.0, y=0.0),
|
||||||
|
Sensor(label="2", x=0.0, y=100.0),
|
||||||
|
]
|
||||||
|
values = [10.0] # frame shorter than sensor count
|
||||||
|
assert bucket_by_ring(sensors, values) == {0: [10.0], 1: []}
|
||||||
@@ -96,6 +96,102 @@ def test_leaving_live_mode_stops_stream(controller):
|
|||||||
assert not controller._repaint_timer.isActive()
|
assert not controller._repaint_timer.isActive()
|
||||||
|
|
||||||
|
|
||||||
|
def test_binary_frame_decode_bounded_to_loaded_layout(controller, monkeypatch):
|
||||||
|
"""Regression for phantom sensor #239 polluting MIN/MAX/AVG: the wire
|
||||||
|
payload carries a fixed-width channel array (up to 244 slots) wider than
|
||||||
|
any single wafer family's real sensor count. Family "X" (xwafer.yaml,
|
||||||
|
the only family live streaming is supported on) has 80 real sensors —
|
||||||
|
everything past that in the payload is an unpopulated hardware slot and
|
||||||
|
must never reach Frame.values."""
|
||||||
|
fake_transport = MagicMock()
|
||||||
|
fake_transport.read.return_value = b""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"pygui.serialcomm.serial_port.SerialPort.open_port",
|
||||||
|
lambda *a, **k: fake_transport,
|
||||||
|
)
|
||||||
|
|
||||||
|
controller.startStream("COM_FAKE", "X")
|
||||||
|
try:
|
||||||
|
assert len(controller._sensors) == 80 # sanity: xwafer.yaml loaded
|
||||||
|
parse = controller._reader._parse
|
||||||
|
payload = bytes(244 * 2) # full-width wire payload, well beyond the real 80
|
||||||
|
frame = parse(payload, seq=1)
|
||||||
|
assert len(frame.values) == 80
|
||||||
|
finally:
|
||||||
|
controller.stopStream()
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_error_surfaces_message_and_stops_stream(controller):
|
||||||
|
"""A fatal StreamReader error must reach QML with its message (for the
|
||||||
|
Activity Log) and must tear the stream down cleanly."""
|
||||||
|
controller.setMode("live")
|
||||||
|
controller._reader = MagicMock()
|
||||||
|
|
||||||
|
error_seen = MagicMock()
|
||||||
|
controller.liveError.connect(error_seen)
|
||||||
|
|
||||||
|
controller._on_live_error("Binary stream resync failed: no sync marker")
|
||||||
|
|
||||||
|
error_seen.assert_called_once_with("Binary stream resync failed: no sync marker")
|
||||||
|
assert controller._reader is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_stream_refuses_non_x_family(controller, monkeypatch):
|
||||||
|
"""C# parity: only X-family wafer firmware supports live streaming.
|
||||||
|
Family A produced ~10s of unrelated ASCII output, not real telemetry —
|
||||||
|
startStream must refuse before ever opening the port."""
|
||||||
|
open_port = MagicMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"pygui.serialcomm.serial_port.SerialPort.open_port", open_port
|
||||||
|
)
|
||||||
|
error_seen = MagicMock()
|
||||||
|
controller.liveError.connect(error_seen)
|
||||||
|
|
||||||
|
controller.startStream("COM_FAKE", "A")
|
||||||
|
|
||||||
|
open_port.assert_not_called()
|
||||||
|
error_seen.assert_called_once()
|
||||||
|
assert "X-family" in error_seen.call_args[0][0]
|
||||||
|
assert controller._reader is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_stream_allows_x_family(controller, monkeypatch):
|
||||||
|
fake_transport = MagicMock()
|
||||||
|
fake_transport.read.return_value = b""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"pygui.serialcomm.serial_port.SerialPort.open_port",
|
||||||
|
lambda *a, **k: fake_transport,
|
||||||
|
)
|
||||||
|
error_seen = MagicMock()
|
||||||
|
controller.liveError.connect(error_seen)
|
||||||
|
|
||||||
|
controller.startStream("COM_FAKE", "X")
|
||||||
|
try:
|
||||||
|
error_seen.assert_not_called()
|
||||||
|
assert controller._reader is not None
|
||||||
|
finally:
|
||||||
|
controller.stopStream()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_stream_skips_d2s_write_when_reader_already_exited(controller):
|
||||||
|
"""Reader's own worker thread can exit and close the transport (e.g.
|
||||||
|
after a resync give-up) before stopStream() runs on the main thread.
|
||||||
|
Writing D2S to that already-closed transport is a guaranteed EBADF —
|
||||||
|
skip it instead of racing the close."""
|
||||||
|
controller.setMode("live")
|
||||||
|
fake_reader = MagicMock()
|
||||||
|
fake_reader._thread = MagicMock()
|
||||||
|
fake_reader._thread.is_alive.return_value = False
|
||||||
|
fake_transport = MagicMock()
|
||||||
|
fake_transport.is_open = True
|
||||||
|
fake_reader._transport = fake_transport
|
||||||
|
controller._reader = fake_reader
|
||||||
|
|
||||||
|
controller.stopStream()
|
||||||
|
|
||||||
|
fake_transport.write.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_compare_files_integration(controller):
|
def test_compare_files_integration(controller):
|
||||||
# Mock the comparisonResult signal
|
# Mock the comparisonResult signal
|
||||||
mock_slot = MagicMock()
|
mock_slot = MagicMock()
|
||||||
|
|||||||
+178
-24
@@ -1,7 +1,7 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
from pygui.serialcomm.stream_reader import StreamReader
|
from pygui.serialcomm.stream_reader import StreamReader, _crc16
|
||||||
|
|
||||||
|
|
||||||
class FakeTransport:
|
class FakeTransport:
|
||||||
@@ -17,33 +17,187 @@ class FakeTransport:
|
|||||||
res = self._data[self._pos:self._pos+n]
|
res = self._data[self._pos:self._pos+n]
|
||||||
self._pos += n
|
self._pos += n
|
||||||
return res
|
return res
|
||||||
def readline(self):
|
|
||||||
time.sleep(0.001)
|
|
||||||
if self._pos >= len(self._data):
|
|
||||||
return b""
|
|
||||||
idx = self._data.find(b"\n", self._pos)
|
|
||||||
if idx == -1:
|
|
||||||
res = self._data[self._pos:]
|
|
||||||
self._pos = len(self._data)
|
|
||||||
return res
|
|
||||||
res = self._data[self._pos:idx+1]
|
|
||||||
self._pos = idx + 1
|
|
||||||
return res
|
|
||||||
def close(self): self.closed = True
|
def close(self): self.closed = True
|
||||||
|
|
||||||
def parse_line(raw: str, seq: int) -> Frame:
|
|
||||||
parts = [float(x) for x in raw.split(",")]
|
|
||||||
return Frame(seq = seq, time = parts[0], values = parts[1:])
|
|
||||||
|
|
||||||
def test_reads_frames_and_counts_errors():
|
def make_packet(payload: bytes, seq: int = 1) -> bytes:
|
||||||
got, errors = [], []
|
"""Build a wire packet exactly as the device does: AA 88, msg type,
|
||||||
transport = FakeTransport([b"0.0,149,148\n", b"garbage\n", b"0.5,150,149\n"])
|
seq (LE), payload len (LE), payload, CRC16/MODBUS (LE) over the rest."""
|
||||||
r = StreamReader(transport, parse_line,
|
body = (b"\xaa\x88\x01" + seq.to_bytes(2, "little")
|
||||||
on_frame = got.append, on_error = lambda e: errors.append(e))
|
+ len(payload).to_bytes(2, "little") + payload)
|
||||||
|
return body + _crc16(body).to_bytes(2, "little")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_payload(payload: bytes, seq: int) -> Frame:
|
||||||
|
return Frame(seq=seq, time=0.0, values=[float(b) for b in payload])
|
||||||
|
|
||||||
|
|
||||||
|
def test_crc16_known_vector():
|
||||||
|
# CRC16/MODBUS check value for "123456789" is 0x4B37.
|
||||||
|
assert _crc16(b"123456789") == 0x4B37
|
||||||
|
|
||||||
|
|
||||||
|
def test_binary_stream_parses_crc_valid_packets():
|
||||||
|
got = []
|
||||||
|
data = make_packet(b"\x01\x02", seq=1) + make_packet(b"\x03\x04", seq=2)
|
||||||
|
transport = FakeTransport([data])
|
||||||
|
r = StreamReader(transport, parse_payload,
|
||||||
|
on_frame=got.append, on_error=lambda e: None)
|
||||||
r.start()
|
r.start()
|
||||||
time.sleep(0.1)
|
time.sleep(0.2)
|
||||||
r.stop()
|
r.stop()
|
||||||
assert [f.values for f in got] == [[149.0, 148.0], [150.0, 149.0]]
|
assert [f.seq for f in got] == [1, 2]
|
||||||
assert r.error_count == 1
|
assert r.error_count == 0
|
||||||
assert transport.closed
|
assert transport.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_crc_packet_dropped_and_stream_continues():
|
||||||
|
got, errors = [], []
|
||||||
|
bad = bytearray(make_packet(b"\x09\x09", seq=1))
|
||||||
|
bad[-1] ^= 0xFF # break the CRC
|
||||||
|
data = bytes(bad) + make_packet(b"\x01\x02", seq=2)
|
||||||
|
r = StreamReader(FakeTransport([data]), parse_payload,
|
||||||
|
on_frame=got.append, on_error=errors.append)
|
||||||
|
r.start()
|
||||||
|
time.sleep(0.2)
|
||||||
|
r.stop()
|
||||||
|
assert [f.seq for f in got] == [2]
|
||||||
|
assert r.error_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_bogus_giant_payload_len_does_not_stall_the_stream():
|
||||||
|
"""A false 0xAA 0x88 inside data yields a garbage payload_len (up to
|
||||||
|
64KB). Without a sanity cap the reader waits forever accumulating a
|
||||||
|
packet that doesn't exist — the 'frames stop, no error' symptom."""
|
||||||
|
got = []
|
||||||
|
# False sync + header claiming a 0xFFFF-byte payload, then a real packet.
|
||||||
|
bogus = b"\xaa\x88\x01\x00\x00\xff\xff"
|
||||||
|
data = bogus + make_packet(b"\x01\x02", seq=7)
|
||||||
|
r = StreamReader(FakeTransport([data]), parse_payload,
|
||||||
|
on_frame=got.append, on_error=lambda e: None)
|
||||||
|
r.start()
|
||||||
|
time.sleep(0.2)
|
||||||
|
r.stop()
|
||||||
|
assert [f.seq for f in got] == [7]
|
||||||
|
|
||||||
|
|
||||||
|
def test_quiet_port_between_scans_is_not_a_resync_failure():
|
||||||
|
"""Device pauses between measurement scans. Empty reads must not count
|
||||||
|
toward the resync give-up cap (C#'s findMessageStart waits forever) —
|
||||||
|
the old code died with TimeoutError after ~50 empty reads."""
|
||||||
|
errors = []
|
||||||
|
got = []
|
||||||
|
r = StreamReader(FakeTransport([make_packet(b"\x01\x02", seq=1)]),
|
||||||
|
parse_payload, on_frame=got.append, on_error=errors.append)
|
||||||
|
r.start()
|
||||||
|
time.sleep(0.5) # long idle stretch after the single packet drains
|
||||||
|
r.stop()
|
||||||
|
assert [f.seq for f in got] == [1]
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_resync_giveup_debug_log_has_bytes_error_log_does_not(caplog):
|
||||||
|
"""Regression: the give-up log used to dump `buf` after it had already
|
||||||
|
been cleared for the new read, so the hex-diagnostic came back empty —
|
||||||
|
useless for diagnosing what was actually on the wire. Fix: real bytes
|
||||||
|
go to DEBUG only; the ERROR line the terminal/Activity Log show stays
|
||||||
|
free of raw internal buffer content."""
|
||||||
|
import logging
|
||||||
|
errors = []
|
||||||
|
# 0xAA/0x88 excluded so this exercises _run_binary's resync path even
|
||||||
|
# though it's called directly (bypassing the ASCII/binary dispatch,
|
||||||
|
# which is exercised separately — see test_ascii_hex_dump_stream_*).
|
||||||
|
garbage = b"\x42" * 20000
|
||||||
|
r = StreamReader(FakeTransport([garbage]), parse_payload,
|
||||||
|
on_frame=lambda f: None, on_error=errors.append)
|
||||||
|
with caplog.at_level(logging.DEBUG, logger="pygui.serialcomm.stream_reader"):
|
||||||
|
r._run_binary(bytearray())
|
||||||
|
assert len(errors) == 1
|
||||||
|
error_lines = [r.message for r in caplog.records if r.levelname == "ERROR"]
|
||||||
|
debug_lines = [r.message for r in caplog.records if r.levelname == "DEBUG"]
|
||||||
|
assert any("giving up" in m for m in error_lines)
|
||||||
|
assert not any("42424242" in m for m in error_lines), (
|
||||||
|
"raw wire bytes must not appear in the ERROR line users see"
|
||||||
|
)
|
||||||
|
assert any("42424242" in m for m in debug_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resync_gives_up_after_16kb_of_garbage():
|
||||||
|
"""Give-up cap counts discarded BYTES, not read attempts, so it fires
|
||||||
|
at a fixed data volume regardless of transfer rate."""
|
||||||
|
errors = []
|
||||||
|
got = []
|
||||||
|
garbage = b"\x01" * 20000 # never contains 0xAA 0x88; well past the cap
|
||||||
|
r = StreamReader(FakeTransport([garbage]), parse_payload,
|
||||||
|
on_frame=got.append, on_error=errors.append)
|
||||||
|
r._run_binary(bytearray()) # deterministic give-up — no thread needed
|
||||||
|
assert got == []
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "resync" in str(errors[0]).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ascii_hex_dump_stream_decodes_real_wafer_capture():
|
||||||
|
"""Regression using an exact byte capture from real hardware (family A):
|
||||||
|
the wire is plain ASCII hex text, 4 chars/word, no 0xAA 0x88 framing at
|
||||||
|
all — see docs/adr/0007. _run() must route to the ASCII path and the
|
||||||
|
words must decode to plausible room-temperature values, not stall."""
|
||||||
|
got = []
|
||||||
|
words = ["280B", "1C0B", "220B", "1B0B", "200B", "1F0B", "190B", "1D0B",
|
||||||
|
"1B0B", "240B", "180B", "210B", "2B0B", "260B", "290B", "2B0B"]
|
||||||
|
# aepwafer.yaml's family "A" has 48 real sensors; pad to one full block
|
||||||
|
# (256 words) the way the real device does — extra slots are unpopulated.
|
||||||
|
block = "".join(words) + "0000" * (256 - len(words))
|
||||||
|
r = StreamReader(FakeTransport([block.encode()]), parse_payload,
|
||||||
|
on_frame=got.append, on_error=lambda e: None,
|
||||||
|
family_code="A")
|
||||||
|
r.start()
|
||||||
|
time.sleep(0.2)
|
||||||
|
r.stop()
|
||||||
|
assert len(got) == 1
|
||||||
|
assert len(got[0].values) == 48 # sensor_count_for("A")
|
||||||
|
# First word "280B" byte-swaps to "0B28" -> ~22.31C via the AEP formula.
|
||||||
|
assert 22.0 < got[0].values[0] < 22.5
|
||||||
|
|
||||||
|
|
||||||
|
class WedgedTransport:
|
||||||
|
"""Simulates a worker thread stuck in a blocking read() that ignores
|
||||||
|
the stop event — read() never returns on its own. stop() must close
|
||||||
|
the port to unblock it rather than relying on the read loop to notice."""
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def read(self, n=1):
|
||||||
|
while not self.closed:
|
||||||
|
time.sleep(0.001)
|
||||||
|
raise OSError("port closed")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_closes_transport_even_if_worker_thread_is_wedged():
|
||||||
|
r = StreamReader(WedgedTransport(), parse_payload,
|
||||||
|
on_frame=lambda f: None, on_error=lambda e: None)
|
||||||
|
r.start()
|
||||||
|
time.sleep(0.05) # let the thread enter the blocking read
|
||||||
|
r.stop()
|
||||||
|
assert r._transport.closed, (
|
||||||
|
"stop() must close the transport itself — otherwise a wedged "
|
||||||
|
"worker thread keeps the port open and a subsequent startStream() "
|
||||||
|
"opens a second competing handle to the same port"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_does_not_report_the_close_it_caused_as_an_error():
|
||||||
|
"""stop()'s own transport.close() unblocks the worker thread's read()
|
||||||
|
by making it raise — that's expected teardown, not a stream fault, and
|
||||||
|
must not reach on_error (Activity Log would otherwise show a fake
|
||||||
|
"Live stream error" on every ordinary Stop)."""
|
||||||
|
errors = []
|
||||||
|
r = StreamReader(WedgedTransport(), parse_payload,
|
||||||
|
on_frame=lambda f: None, on_error=errors.append)
|
||||||
|
r.start()
|
||||||
|
time.sleep(0.05)
|
||||||
|
r.stop()
|
||||||
|
time.sleep(0.05) # let the worker thread's except-handler run
|
||||||
|
assert errors == []
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ supported-markers = [
|
|||||||
"sys_platform == 'linux'",
|
"sys_platform == 'linux'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "altgraph"
|
||||||
|
version = "0.17.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ast-serialize"
|
name = "ast-serialize"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -586,6 +595,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "macholib"
|
||||||
|
version = "1.16.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "altgraph" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matplotlib"
|
name = "matplotlib"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -867,6 +888,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pefile"
|
||||||
|
version = "2024.8.26"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.2.0"
|
version = "12.2.0"
|
||||||
@@ -1006,6 +1036,7 @@ dev = [
|
|||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
|
{ name = "pyinstaller" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
@@ -1029,10 +1060,51 @@ provides-extras = ["dev"]
|
|||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
|
{ name = "pyinstaller", specifier = "==6.21.0" },
|
||||||
{ name = "pytest", specifier = ">=9.0.3" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyinstaller"
|
||||||
|
version = "6.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "altgraph" },
|
||||||
|
{ name = "macholib", marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pefile", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "pyinstaller-hooks-contrib" },
|
||||||
|
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "setuptools" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyinstaller-hooks-contrib"
|
||||||
|
version = "2026.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyparsing"
|
name = "pyparsing"
|
||||||
version = "3.3.2"
|
version = "3.3.2"
|
||||||
@@ -1139,6 +1211,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pywin32-ctypes"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
@@ -1290,6 +1371,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
|
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "setuptools"
|
||||||
|
version = "83.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shiboken6"
|
name = "shiboken6"
|
||||||
version = "6.11.1"
|
version = "6.11.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user