12 Commits

Author SHA1 Message Date
jack 0741e96e45 fix(paths): centralize file:// URL handling to fix broken save-dir paths
CI / preflight (push) Waiting to run
2026-07-13 15:45:22 -07:00
jack 44216e5857 fix(license): use QUrl.toLocalFile; track docs folder
CI / preflight (push) Waiting to run
- QUrl.toLocalFile() prevents leading slash on Windows drive letters (e.g.
  "/C:/Users/...") that was causing Path operations to fail
- remove docs/ from .gitignore so document revisions are tracked in Git
2026-07-13 15:12:02 -07:00
jack d3c5b6fa64 feat(packaging)
CI / preflight (push) Waiting to run
2026-07-13 13:51:18 -07:00
jack 8707d467f5 style(ui): minor styling adjustments for AboutDialog and OverlapMapCard
CI / preflight (push) Waiting to run
2026-07-13 13:41:13 -07:00
jack 5060df1527 build(packaging): add PyInstaller packaging 2026-07-13 13:40:19 -07:00
jack 87cfbabb4a fix(serialcomm): support dynamic family sensor count in binary parser
Previously, parse_binary_data() hardcoded P-family's sensor count (244)
for all non-X wafers, causing misalignment when parsing A/B/C/D/E/F
wafers. Now passes family_code to _parse_p_binary().
2026-07-13 13:38:45 -07:00
jack 3675f0722a ci: add Gitea Actions preflight workflow
CI / preflight (push) Waiting to run
2026-07-13 12:33:51 -07:00
jack c4cbc02a15 fix(stream_reader): add ASCII hex path + binary resync guard
Family-A wafers never send 0xAA 0x88 framing — _run() now routes
to _run_ascii() on first byte dispatch instead of treating the whole
stream as corrupt binary.

Binary path gets a 16 KB give-up cap (bytes, not attempts) so a
permanently-corrupt port can't stall the reader indefinitely. Raw
wire bytes go to DEBUG only; the ERROR line that surfaces in the
Activity Log is kept free of internal buffer content.

- add family_code param to StreamReader for ASCII decode routing
- add _run_binary give-up cap + DEBUG-only hex diagnostic
- fix stop(): close transport on wedged thread (binary + ASCII paths)
- refactor test_stream_reader: consolidate ASCII regression + resync
  give-up + DEBUG-vs-ERROR log split tests; drop redundant parse_line
- extend test_session_controller: live-stream gate for non-X family
- StreamControlPanel.qml: thread-safe stop on tab leave
2026-07-13 12:26:33 -07:00
jack 425f3731cd fix(stream): payload-length cap, quiet-port resync, main-thread error teardown 2026-07-13 11:54:11 -07:00
jack 92ff6fbd14 fix(wafer): derive family sensor counts from YAML layouts, bound frame decode to layout 2026-07-13 11:52:56 -07:00
jack c76cddc5fd feat(license): runtime trial gating UI + appendActivityLog entry point 2026-07-13 11:52:06 -07:00
jack 0e6e819851 feat(visualization): extract ring_boundaries to radial_metrics
- add radial_metrics: ring_boundaries + bucket_by_ring
- replace WaferMapItem inline logic with ring_boundaries()
- fix outer boundary: sensor max-hypot*1.05 (was wafer_radius_mm)
- add LicenseModel.reviewAccessBlocked() (ADR-0005 deferred)
- add appendActivityLog test
- add test_radial_metrics.py (89 lines, full edge-case coverage)
- update README: licmgr --level flag + date format
2026-07-13 10:48:49 -07:00
33 changed files with 1193 additions and 299 deletions
+35
View File
@@ -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
View File
@@ -21,7 +21,6 @@ env/
*.swp
*.swo
*~
docs
# OS
.DS_Store
@@ -37,9 +36,7 @@ moc_*.cpp
# Build / packaging artifacts
build/
dist/
*.spec
*.icns
*.ico
# Runtime scratch data
tmp/
@@ -58,3 +55,4 @@ AGENTS.md
CONVENTIONS.md
specs/
graphify-out/
.personal-docs/
+4 -1
View File
@@ -18,6 +18,9 @@ typecheck:
run:
uv run python -m pygui
package:
uv run pyinstaller packaging/isc.spec
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 {} +
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+62
View File
@@ -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,
)
+1
View File
@@ -92,6 +92,7 @@ dev = [
"pytest>=9.0.3",
"ruff",
"mypy",
"pyinstaller==6.21.0",
]
[tool.uv]
+21 -5
View File
@@ -3,6 +3,7 @@ import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts
import ISC
import ISC.Tabs.components
Popup {
id: root
@@ -133,13 +134,28 @@ Popup {
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 }
Label { text: modelData.level; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 80 }
Label { text: modelData.licDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.fillWidth: true }
ToolButton {
Button {
id: removeLicenseBtn
Layout.preferredWidth: 28
text: "✕"
font.pixelSize: Theme.fontSm
ToolTip.visible: hovered
ToolTip.text: "Remove license"
implicitHeight: 28
hoverEnabled: true
background: Rectangle {
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)
AppToolTip {
visible: removeLicenseBtn.hovered
text: "Remove license"
}
}
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ Popup {
id: saveDirDialog
title: "Choose a folder to save data"
onAccepted: {
var dir = decodeURIComponent(String(selectedFolder).replace(/^file:\/\//, ""))
var dir = String(selectedFolder)
deviceController.setSaveDataDir(dir)
file_browser.setCurrentDirectory(dir)
root.finished()
+58 -20
View File
@@ -33,6 +33,9 @@ Rectangle {
property string dataCompareFileB: ""
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) {
file_browser.refreshFiles();
if (root.mapReviewFile !== "") {
@@ -68,6 +71,9 @@ Rectangle {
if (root.selectedTabIndex === 2) root.mapReviewFile = 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
@@ -84,6 +90,24 @@ Rectangle {
property bool waferDetected: 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 {
target: deviceController
function onDetectResult(result){
@@ -100,16 +124,22 @@ Rectangle {
licenseModel.startReplayTrial()
}
}
function onParsedDataReady(result) {
if (result.success && result.csv_path) {
streamController.loadFile(result.csv_path)
// Fires while still on Status(0), before the tab switch below —
// the generic per-tab tracker only attributes picks made while
// already on Map/Graph, so record this one explicitly.
root.mapReviewFile = result.csv_path
if (root.selectedTabIndex === 0 && licenseModel.licenses.length > 0) {
}
Connections {
// Deferred until the post-read metadata dialog (StatusTab.qml) closes —
// jumping tabs from onParsedDataReady directly would deactivate the
// Status Loader (StackLayout.index !== selectedTabIndex) and tear down
// the dialog before the technician could see or use it.
target: statusTabLoader.item
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
}
}
}
}
@@ -124,6 +154,7 @@ Rectangle {
root.selectedTabIndex = 0
}
root.waferLicensed = root.waferDetected && deviceController.waferLicensed()
root.reviewBlocked = licenseModel.reviewAccessBlocked()
}
}
Connections {
@@ -133,17 +164,13 @@ Rectangle {
result !== undefined &&
result.success === true)
if (root.memoryRead) {
console.log("[P1.1] Memory read complete:", result.bytes, "bytes")
console.log("Memory read complete:", result.bytes, "bytes")
} 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() {
root.memoryRead = false
root.waferDetected = false
@@ -156,7 +183,7 @@ Rectangle {
id: saveDirDialog
title: "Choose a folder to save Data"
onAccepted: {
var dir = root.cleanFolderUrl(selectedFolder)
var dir = String(selectedFolder)
deviceController.setSaveDataDir(dir)
file_browser.setCurrentDirectory(dir)
root._doDetect()
@@ -271,6 +298,7 @@ Rectangle {
}
}
Component.onCompleted: {
root.reviewBlocked = licenseModel.reviewAccessBlocked()
if (isFirstLaunch) {
firstLaunchWizard.open()
}
@@ -342,10 +370,13 @@ Rectangle {
id: pillBtn
checked: root.selectedTabIndex === index
// 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
// tooltip can explain why — a disabled Item receives no mouse/hover
// events at all, which would silently kill the tooltip below.
property bool locked: index === 2 && licenseModel.licenses.length === 0
property bool locked: (index === 2 && licenseModel.licenses.length === 0)
|| (index !== 0 && root.reviewBlocked)
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
@@ -411,9 +442,15 @@ Rectangle {
AppToolTip {
visible: pillBtn.hovered
text: (index === 2 && licenseModel.licenses.length === 0)
? "<b>MAP</b>: No license loaded — load one in About to unlock"
: "<b>" + model.label + "</b>: " + model.desc
// Trial-expired is checked first: it's the broader reason and
// applies identically to Data/Map/Graph, so when it's true it
// 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
@@ -729,6 +766,7 @@ Rectangle {
currentIndex: root.selectedTabIndex
Loader {
id: statusTabLoader
source: "Tabs/StatusTab.qml"
active: StackLayout.index === root.selectedTabIndex
}
+167 -68
View File
@@ -15,6 +15,12 @@ ColumnLayout {
anchors.margins: Theme.panelPadding
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 ──
component StatCard : Rectangle {
property alias value: valueText.text
@@ -107,9 +113,6 @@ ColumnLayout {
readonly property bool isBusy: deviceController.connectionStatus.endsWith("...")
readonly property string portName: deviceController.selectedPort || "—"
function cleanFolderUrl(url) {
return decodeURIComponent(String(url).replace(/^file:\/\//, ""));
}
function currentFamilyCode() {
var info = deviceController.lastWaferInfo;
return info && info.length > 0 ? (info[0] || "") : "";
@@ -122,7 +125,7 @@ ColumnLayout {
id: saveDirDialog
title: "Choose a folder to save."
onAccepted: {
var dir = root.cleanFolderUrl(selectedFolder);
var dir = String(selectedFolder);
deviceController.setSaveDataDir(dir);
file_browser.setCurrentDirectory(dir);
root.parseAndSavePendingRead();
@@ -135,7 +138,7 @@ ColumnLayout {
id: dirOnlyDialog
title: "Choose a folder to save Data"
onAccepted: {
var dir = root.cleanFolderUrl(selectedFolder);
var dir = String(selectedFolder);
deviceController.setSaveDataDir(dir);
file_browser.setCurrentDirectory(dir);
}
@@ -147,31 +150,127 @@ ColumnLayout {
id: editCsvDialog
parent: Overlay.overlay
modal: true
title: "Edit CSV Metadata"
width: 460
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, "")
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 {
anchors.fill: parent
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: root.csvPath.split("/").pop()
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
elide: Text.ElideMiddle
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 }
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
spacing: 8
Layout.preferredHeight: 22
Item {
Layout.fillWidth: true
implicitHeight: 22
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 }
}
}
}
Row {
anchors.centerIn: parent
spacing: 5
Repeater {
model: 4
Rectangle {
width: 4; height: 4; radius: 2
anchors.verticalCenter: parent.verticalCenter
width: statusPillText.implicitWidth + 16
height: 20
radius: 10
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
Text {
id: statusPillText
anchors.centerIn: parent
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
color: Theme.statusBadgeText
font.pixelSize: Theme.fontXs
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
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 {
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
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: statusPillText.implicitWidth + 16
height: 20
radius: 10
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
SequentialAnimation on opacity {
running: root.isConnected || root.isBusy
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 1400 }
NumberAnimation { to: 1.0; duration: 1400 }
}
Text {
id: statusPillText
anchors.centerIn: parent
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
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 {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.portName
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
font.pixelSize: Theme.fontMd
@@ -612,7 +711,7 @@ ColumnLayout {
root.dataCols = 0;
root.csvPath = "";
if (result && result.success === true)
saveDirDialog.open();
root.parseAndSavePendingRead();
}
}
@@ -53,6 +53,7 @@ PanelBox {
margin: 1.0
blend: card.blendAmount / 100
showLabels: true
showExtremes: false
ringColor: Theme.waferRingColor
axisColor: Theme.waferAxisColor
lowColor: Theme.sensorLow
@@ -260,14 +260,14 @@ ColumnLayout {
id: importFileDialog
title: "Select Data File To Import"
nameFilters: ["CSV files (*.csv)"]
onAccepted: file_browser.importZWaferCsv(String(selectedFile).replace(/^file:\/\//, ""))
onAccepted: file_browser.importZWaferCsv(String(selectedFile))
}
FolderDialog {
id: batchExportDirDialog
title: "Export Wafer Maps To…"
onAccepted: {
var dir = String(selectedFolder).replace(/^file:\/\//, "");
var dir = String(selectedFolder);
var paths = file_browser.files.map(function(row) { return row.fileName; });
batchExportController.start(paths, dir);
}
@@ -110,8 +110,13 @@ Rectangle {
TabButton {
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"
enabled: deviceController.connectionStatus === "Connected"
enabled: deviceController.connectionStatus === "Connected" && familySupportsStream
opacity: enabled ? 1.0 : 0.4
Layout.fillWidth: true
implicitHeight: 24
@@ -120,7 +125,9 @@ Rectangle {
AppToolTip {
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
}
@@ -359,12 +366,14 @@ Rectangle {
text: streamController.mode === "live" ? "STOP" : "START"
enabled: streamController.mode === "live"
? true
: deviceController.connectionStatus === "Connected"
: (deviceController.connectionStatus === "Connected" && liveTab.familySupportsStream)
opacity: enabled ? 1.0 : 0.4
AppToolTip {
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
}
@@ -5,7 +5,7 @@ import threading
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
log = logging.getLogger(__name__)
@@ -33,7 +33,9 @@ class BatchExportController(QObject):
self._busy = True
self.busyChanged.emit()
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()
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.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.serialcomm.data_parser import (
convert_to_debug_temperatures,
@@ -82,7 +82,8 @@ class DeviceController(QObject):
self._operation_in_progress = False
self._activity_log: list[str] = []
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._wafer_detected: bool = False
self._last_csv_path: str = ""
@@ -141,8 +142,8 @@ class DeviceController(QObject):
@slot_error_boundary
def setSaveDataDir(self, path: str) -> None:
"""Set the directory for saving parsed CSV data files."""
self._save_data_dir = path
self._append_log(f"Save data dir set to: {path}")
self._save_data_dir = str(to_local_path(path))
self._append_log(f"Save data dir set to: {self._save_data_dir}")
self._save_status()
@Slot(result=str)
@@ -303,6 +304,13 @@ class DeviceController(QObject):
self._activity_log = self._activity_log[-200:]
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_error_boundary
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.threshold_classifier import ThresholdConfig
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.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -43,6 +44,7 @@ class SessionController(QObject):
loadFileError = Signal(str)
clusterAveragingEnabledChanged = Signal()
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
trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame
trendReset = Signal() # live trend buffer cleared (new stream started)
@@ -53,7 +55,7 @@ class SessionController(QObject):
segmentExported = Signal(dict) # dict with success, path | error
# private: marshal a worker-thread frame onto the main thread
_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:
super().__init__(parent)
@@ -685,6 +687,16 @@ class SessionController(QObject):
log.warning("startStream: StreamReader is already running.")
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.serialcomm.serial_port import SerialPort # transport open
@@ -700,9 +712,16 @@ class SessionController(QObject):
if not self._active_clusters:
self._active_clusters = group_sensors_by_radius(self._sensors)
# The new binary protocol sends payload of (sensorCount * 2) bytes
# Each sensor is a big-endian 16-bit value (Sign-Magnitude).
expected_sensors = 80 if (family_code or "") == "X" else 244
# The binary protocol's payload carries a fixed-size channel array
# (sensorCount * 2 bytes, big-endian Sign-Magnitude each) that's wider
# 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:
values = []
@@ -733,6 +752,13 @@ class SessionController(QObject):
self.liveStatsChanged.emit()
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
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
transport.write(cmd)
@@ -744,8 +770,11 @@ class SessionController(QObject):
log.error("Live stream error: %s", exc)
ref = weak_self()
if ref is not None:
ref._liveError.emit()
ref.stopStream()
# Marshal onto the main thread via the queued signal — this
# 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):
ref = weak_self()
@@ -772,14 +801,24 @@ class SessionController(QObject):
self.liveStatsChanged.emit()
if self._reader:
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 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:
cmd = b"D2S" + (b"F" * 509)
transport.write(cmd)
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:
log.error("Error sending stop command: %s", exc)
@@ -815,10 +854,15 @@ class SessionController(QObject):
self.frameUpdated.emit()
self.stateChanged.emit()
def _on_live_error(self) -> None:
"""Main-thread callback for worker-thread stream errors."""
def _on_live_error(self, message: str) -> None:
"""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.liveStatsChanged.emit()
self.liveError.emit(message)
self.stopStream()
# ---- recording ----
@Slot(str, str)
+4 -2
View File
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import QFileDialog, QMessageBox
from pygui.backend.data.constants import default_data_dir
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
@@ -45,7 +46,7 @@ class FileBrowser(QObject):
Used to keep this directory in sync with DeviceController.saveDataDir
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)
@Slot()
@@ -124,7 +125,8 @@ class FileBrowser(QObject):
date still gets a deterministic (if degenerate) filename, so the
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)
if parser_data is None:
self.importFinished.emit("", f"Failed to parse CSV:\n{source_path.name}")
+10 -8
View File
@@ -4,7 +4,6 @@ import logging
import shutil
import time
from pathlib import Path
from urllib.parse import unquote, urlparse
from PySide6.QtCore import Property, QObject, Signal, Slot
@@ -14,19 +13,13 @@ from pygui.backend.license.license_manager import (
parse_license_blob,
read_license_files,
)
from pygui.backend.utils import to_local_path as _to_local_path
log = logging.getLogger(__name__)
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 =====
class LicenseModel(QObject):
"""Context property backing the About dialog license grid.
@@ -207,3 +200,12 @@ class LicenseModel(QObject):
return True
age_days = (time.time() - marker.stat().st_mtime) / 86400.0
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()
+21
View File
@@ -1,5 +1,26 @@
import logging
import re
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):
@@ -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 pygui.backend.models.frame_stats import compute_stats
from pygui.backend.visualization.radial_metrics import ring_boundaries
from pygui.backend.visualization.rbf_heatmap import (
ellipse_alpha,
interpolate_field,
@@ -517,22 +518,7 @@ class WaferMapItem(QQuickPaintedItem):
def _sensor_ring_radii_mm(self) -> list[float]:
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
if not 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
return ring_boundaries(self._sensors)
def _scale(self, ds: int, r_mm: float) -> float:
"""Pixels per mm. The wafer radius maps to ds//2 - 24 px."""
+9 -4
View File
@@ -1,9 +1,14 @@
"""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:
"""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
code."""
return 80 if family_code == "X" else 244
0 for an unknown/empty family code."""
try:
return len(load_layout_for_wafer_id(family_code))
except KeyError:
return 0
+5 -15
View File
@@ -20,17 +20,7 @@ log = logging.getLogger(__name__)
def csv_column_count(family_code: str) -> int:
"""Return the number of columns to display for a family code."""
mapping = {
"A": 48,
"E": 48,
"P": 48,
"B": 29,
"C": 29,
"D": 29,
"F": 22,
"X": 80,
}
return mapping.get(family_code, 0)
return sensor_count_for(family_code)
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":
return _parse_x_binary(data_bytes)
else:
return _parse_p_binary(data_bytes)
return _parse_p_binary(data_bytes, family_code)
except Exception as exc:
log.error("Binary parse failed: %s", exc)
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.
Each block of 256 readings has 244 valid + 12 overhead.
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] = []
# 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])
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
+147 -94
View File
@@ -14,8 +14,33 @@ log = logging.getLogger(__name__)
ParseFrame = Callable[[bytes, int], Frame]
# Max consecutive resync attempts before giving up on a corrupt stream.
_MAX_RESYNC_ATTEMPTS = 50
# Max garbage bytes discarded (no sync marker found) before giving up on a
# 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:
@@ -63,43 +88,43 @@ class StreamReader:
def _run(self) -> None:
try:
# Peek at the first few bytes to determine protocol.
peek_buf = bytearray()
while not self._stop.is_set() and len(peek_buf) < 32:
b = self._transport.read(1)
if not b:
continue
peek_buf.extend(b)
if b[0] == 10: # newline
break
if self._stop.is_set() or not peek_buf:
# Protocol is decided by the first byte: 0xAA is the binary sync
# marker's lead byte and can never appear in the ASCII hex-dump
# stream (0-9/A-F only) real hardware has been observed sending
# — see docs/adr/0007. Anything else is ASCII.
first = b""
while not first and not self._stop.is_set():
first = self._transport.read(1)
if self._stop.is_set() or not first:
return
raw = bytes(peek_buf)
# Binary sync bytes are the strongest signal.
if raw.startswith(b"\xaa\x88"):
self._run_binary(raw)
if first[0] == 0xAA:
second = b""
while not second and not self._stop.is_set():
second = self._transport.read(1)
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
# Text-lines (CSV) requires BOTH a newline AND commas in the
# 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)
self._run_ascii(bytes(first))
except Exception as exc:
log.exception("StreamReader thread encountered error: %s", exc)
try:
self._on_error(exc)
except Exception:
pass
if self._stop.is_set():
# stop() closes the transport to unblock a pending read —
# that self-inflicted close raises here too (e.g. EBADF).
# Not a real stream fault, so don't report it as one.
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:
try:
self._transport.close()
@@ -109,7 +134,8 @@ class StreamReader:
def _run_binary(self, initial_bytes: bytes | bytearray) -> None:
log.info("StreamReader: starting binary stream parsing")
buf = bytearray(initial_bytes)
resync_attempts = 0
resync_bytes = 0
logged_head = False
while not self._stop.is_set():
# Find sync marker in buffer.
@@ -122,7 +148,11 @@ class StreamReader:
idx += 1
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:]
# Accumulate header (5 bytes after sync).
while len(buf) < 7 and not self._stop.is_set():
@@ -133,6 +163,12 @@ class StreamReader:
return
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
# Accumulate the rest of the packet in one buffered read.
@@ -144,6 +180,22 @@ class StreamReader:
if self._stop.is_set():
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]
seq = int.from_bytes(header[1:3], byteorder='little')
payload = buf[7:7 + payload_len]
@@ -160,27 +212,46 @@ class StreamReader:
buf = buf[total_packet_len:]
else:
# 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:
buf = bytearray([0xAA])
else:
buf.clear()
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)
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")
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
hex_chars = bytearray()
if initial_bytes:
for b in initial_bytes:
if chr(b) in "0123456789ABCDEFabcdef":
hex_chars.append(b)
elif b == 0xAA:
hex_chars.append(b)
for b in initial_bytes:
if chr(b) in "0123456789ABCDEFabcdef":
hex_chars.append(b)
seq = 0
while not self._stop.is_set():
@@ -202,24 +270,15 @@ class StreamReader:
while len(hex_chars) < chars_needed and not self._stop.is_set():
chunk = self._read_chunk(min_bytes=chars_needed - len(hex_chars))
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":
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).
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:])
if self._stop.is_set():
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 = []
for i in range(0, min(len(hex_chars), chars_needed), word_char_len):
word_str = hex_chars[i:i + word_char_len]
@@ -228,7 +287,6 @@ class StreamReader:
word_buffer.append(bytes(word_str))
if not word_buffer:
time.sleep(0.01)
continue
hex_chars = hex_chars[len(word_buffer) * word_char_len:]
@@ -238,7 +296,7 @@ class StreamReader:
for hex_val in valid_hex_words:
try:
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)
except Exception:
values.append(0.0)
@@ -248,34 +306,29 @@ class StreamReader:
self._on_frame(frame)
seq += 1
except Exception as exc:
log.error("Error emitting ASCII frame: %s", exc)
def _run_text_lines(self, initial_bytes: bytes) -> None:
log.info("StreamReader: starting line-by-line ASCII text parsing")
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)
self.error_count += 1
if self.error_count <= 10:
log.warning("Error emitting ASCII frame: %s", exc)
self._on_error(exc)
def stop(self) -> None:
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():
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
+9 -4
View File
@@ -42,8 +42,13 @@ class TestCsvColumnCount:
def test_x_family(self):
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):
assert csv_column_count("Z") == 0
assert csv_column_count("Q") == 0
def test_empty_string_returns_zero(self):
assert csv_column_count("") == 0
@@ -134,11 +139,11 @@ class TestParseBinaryData:
assert result is not None
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)
result = parse_binary_data(data, "P")
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):
data = _make_p_block(2)
@@ -170,7 +175,7 @@ class TestParseBinaryData:
data = _make_p_block(1)
result = parse_binary_data(data, "A")
assert result is not None
assert len(result[0]) == 244
assert len(result[0]) == 48
def test_empty_bytes_returns_empty_list(self):
result = parse_binary_data(b"", "P")
+6 -2
View File
@@ -85,6 +85,10 @@ def test_clear_activity_log(controller):
controller.clearActivityLog()
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):
controller._connection_status = "Connected"
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.get("success") is True
assert controller.dataRowCount == 1
assert controller.dataColCount == 244
assert controller.dataColCount == 48
chart_res = controller.getChartData()
assert chart_res.get("success") is True
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):
logger = logging.getLogger("test_logger")
+23 -10
View File
@@ -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
@@ -6,18 +10,27 @@ def test_x_family_returns_80():
assert sensor_count_for("X") == 80
def test_p_family_returns_244():
assert sensor_count_for("P") == 244
def test_aep_family_returns_48():
for code in ("A", "E", "P"):
assert sensor_count_for(code) == 48
def test_other_known_families_return_244():
for code in ("A", "B", "C", "D", "E", "F"):
assert sensor_count_for(code) == 244
def test_bcd_family_returns_29():
for code in ("B", "C", "D"):
assert sensor_count_for(code) == 29
def test_unknown_family_returns_244():
assert sensor_count_for("Z") == 244
def test_f_family_returns_22():
assert sensor_count_for("F") == 22
def test_empty_string_returns_244():
assert sensor_count_for("") == 244
def test_z_family_returns_65():
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
+30
View File
@@ -267,3 +267,33 @@ def test_replay_trial_expiry(tmp_path, qapp):
def test_replay_trial_expired_without_marker(tmp_path, qapp):
model = LicenseModel(str(tmp_path))
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
+89
View File
@@ -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
View File
@@ -96,6 +96,102 @@ def test_leaving_live_mode_stops_stream(controller):
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):
# Mock the comparisonResult signal
mock_slot = MagicMock()
+178 -24
View File
@@ -1,7 +1,7 @@
import time
from pygui.backend.models.frame import Frame
from pygui.serialcomm.stream_reader import StreamReader
from pygui.serialcomm.stream_reader import StreamReader, _crc16
class FakeTransport:
@@ -17,33 +17,187 @@ class FakeTransport:
res = self._data[self._pos:self._pos+n]
self._pos += n
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 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():
got, errors = [], []
transport = FakeTransport([b"0.0,149,148\n", b"garbage\n", b"0.5,150,149\n"])
r = StreamReader(transport, parse_line,
on_frame = got.append, on_error = lambda e: errors.append(e))
def make_packet(payload: bytes, seq: int = 1) -> bytes:
"""Build a wire packet exactly as the device does: AA 88, msg type,
seq (LE), payload len (LE), payload, CRC16/MODBUS (LE) over the rest."""
body = (b"\xaa\x88\x01" + seq.to_bytes(2, "little")
+ 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()
time.sleep(0.1)
time.sleep(0.2)
r.stop()
assert [f.values for f in got] == [[149.0, 148.0], [150.0, 149.0]]
assert r.error_count == 1
assert [f.seq for f in got] == [1, 2]
assert r.error_count == 0
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 == []
Generated
+90
View File
@@ -18,6 +18,15 @@ supported-markers = [
"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]]
name = "ast-serialize"
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" },
]
[[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]]
name = "matplotlib"
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" },
]
[[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]]
name = "pillow"
version = "12.2.0"
@@ -1006,6 +1036,7 @@ dev = [
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "pyinstaller" },
{ name = "pytest" },
{ name = "ruff" },
]
@@ -1029,10 +1060,51 @@ provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [
{ name = "mypy" },
{ name = "pyinstaller", specifier = "==6.21.0" },
{ name = "pytest", specifier = ">=9.0.3" },
{ 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]]
name = "pyparsing"
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" },
]
[[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]]
name = "pyyaml"
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" },
]
[[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]]
name = "shiboken6"
version = "6.11.1"