Compare commits

..

5 Commits

28 changed files with 2840 additions and 1321 deletions
+1
View File
@@ -18,6 +18,7 @@ dependencies = [
"scipy>=1.17.1",
"pyyaml>=6.0.3",
"dtw-python",
"cryptography>=41.0",
]
[project.optional-dependencies]
+95 -36
View File
@@ -1,25 +1,20 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts
import ISC
// TODO P3.1 + P3.2: license grid, "Load License" picker, version binding
// THINKING: C# parity requires the dgvLicense grid (Wafer SN, Mfg Date,
// License Level, License Date) fed from AES-128-CBC .bin files under
// <app_data>/licenses/ — CryptoHelper (backend/crypto/crypto_helper.py)
// already has the primitives; the missing piece is the license file model
// (P3.1 backend). "Version 0.1.0" below is hardcoded and will drift from
// pyproject.toml — expose importlib.metadata.version("pygui") via a context
// property instead (P3.2). See docs/pending/alpha-release-polish-plan.md §3.
Popup {
id: root
modal: true
dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: 420
height: 340
width: 480
height: 440
anchors.centerIn: Overlay.overlay
onOpened: licenseModel.refresh()
background: Rectangle {
radius: Theme.radiusMd
color: Theme.cardBackground
@@ -27,6 +22,13 @@ Popup {
border.width: 1
}
FileDialog {
id: licenseFileDialog
title: "Load License"
nameFilters: ["License files (*.bin)"]
onAccepted: loadFailedLabel.visible = !licenseModel.loadLicenseFile(selectedFile.toString())
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 24
@@ -51,7 +53,7 @@ Popup {
}
Label {
text: "Version 0.1.0"
text: "Version " + appVersion
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
}
@@ -75,7 +77,8 @@ Popup {
// ── License grid ──
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 100
Layout.fillHeight: true
Layout.minimumHeight: 120
radius: Theme.radiusSm
color: Theme.panelBackground
border.color: Theme.cardBorder
@@ -86,11 +89,24 @@ Popup {
anchors.margins: 10
spacing: 2
Label {
text: "LICENSE"
color: Theme.panelTitleText
font.pixelSize: Theme.fontXs
font.letterSpacing: 0.5
RowLayout {
Layout.fillWidth: true
Label {
text: "LICENSE"
color: Theme.panelTitleText
font.pixelSize: Theme.fontXs
font.letterSpacing: 0.5
Layout.fillWidth: true
}
Label {
id: loadFailedLabel
visible: false
text: "Invalid license file"
color: Theme.statusWarningColor
font.pixelSize: Theme.fontXs
}
}
// Grid header
@@ -98,8 +114,9 @@ Popup {
Layout.fillWidth: true
spacing: 4
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 }
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 60 }
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 90 }
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 50 }
Label { text: "License Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
}
Rectangle {
@@ -108,7 +125,24 @@ Popup {
color: Theme.softBorder
}
ListView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
visible: licenseModel.licenses.length > 0
model: licenseModel.licenses
delegate: RowLayout {
width: ListView.view.width
spacing: 4
Label { text: modelData.serial; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 100 }
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 90 }
Label { text: modelData.level; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 50 }
Label { text: modelData.licDate; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.fillWidth: true }
}
}
Label {
visible: licenseModel.licenses.length === 0
text: "No license loaded"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
@@ -120,25 +154,50 @@ Popup {
}
}
// ── Close ──
Button {
text: "Close"
Layout.alignment: Qt.AlignRight
Layout.preferredWidth: 100
Layout.preferredHeight: 32
onClicked: root.close()
// ── Buttons ──
RowLayout {
Layout.fillWidth: true
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.fieldBorder
border.width: 1
Button {
text: "Load License"
Layout.preferredWidth: 120
Layout.preferredHeight: 32
onClicked: licenseFileDialog.open()
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.fieldBorder
border.width: 1
}
contentItem: Label {
text: parent.text
color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
contentItem: Label {
text: parent.text
color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
Item { Layout.fillWidth: true }
Button {
text: "Close"
Layout.preferredWidth: 100
Layout.preferredHeight: 32
onClicked: root.close()
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.fieldBorder
border.width: 1
}
contentItem: Label {
text: parent.text
color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
+428 -97
View File
@@ -28,15 +28,28 @@ Rectangle {
property bool memoryRead: false
property bool waferDetected: false
// TODO P2.2: add `property bool waferLicensed: false`; set it in
// onDetectResult below via deviceController.waferLicensed() (slot from
// P1.2 — the detectResult payload is opaque in QML, can't read serial
// from it). Then READ/ERASE buttons get
// `enabled: root.waferDetected && root.waferLicensed`.
// See docs/pending/license-gating-plan.md §2.2.
Connections {
target: deviceController
function onDetectResult(result){
root.waferDetected = (result != null && result != undefined)
// detectResult payload is Signal(object): a dict may cross into QML
// as an opaque wrapper whose fields read as undefined, so only
// test truthiness (None on failure, dict on success).
root.waferDetected = !!result
}
function onParsedDataReady(result) {
if (result.success && result.csv_path) {
streamController.loadFile(result.csv_path)
// TODO P3.1: only auto-jump to Map when unlocked:
// `&& licenseModel.licenses.length > 0` — otherwise a
// successful read walks straight into the locked tab.
// See plan §3.1.
if (root.selectedTabIndex === 0) {
root.selectedTabIndex = 2
}
@@ -63,6 +76,7 @@ Rectangle {
function _doDetect() {
root.memoryRead = false
root.waferDetected = false
streamController.setMode("review")
streamController.stopStream()
deviceController.detectWafer()
@@ -181,8 +195,6 @@ Rectangle {
Layout.preferredWidth: Theme.sideRailWidth
Layout.fillHeight: true
color: Theme.sideRailBackground
border.color: Theme.sideBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
@@ -215,6 +227,15 @@ Rectangle {
model: pillModel
delegate: Button {
id: pillBtn
// TODO P3.1: lock MAP pill (index 2) when no license:
// `enabled: index !== 2 || licenseModel.licenses.length > 0`
// and dim icon/label when disabled. licensesChanged
// notify already fires on Load License → unlocks live,
// no restart. Rule is "any valid license" (not
// per-wafer): Map also replays CSVs with no wafer
// attached. Also gate the Map Loader (see TODO at the
// StackLayout below) and the auto-switch at
// onParsedDataReady. See plan §3.1.
checked: root.selectedTabIndex === index
flat: true
Layout.fillWidth: true
@@ -290,28 +311,313 @@ Rectangle {
}
}
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
// ── BOX 1.5: Run Comparison (Data tab only) ────────────────
Rectangle {
id: runComparisonBox
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 120
Layout.preferredHeight: runCompCol.implicitHeight + 28
visible: root.selectedTabIndex === 1
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
readonly property var dataTab: dataTabLoader.item
component RunSlot: Rectangle {
id: slotBox
property string title
property string filePath: ""
property color accent: Theme.primaryAccent
property bool active: false
property bool locked: false
property string placeholderText: "Click to select"
signal cleared()
signal armed()
Layout.fillWidth: true
implicitHeight: 58
radius: Theme.radiusSm
color: Theme.fieldBackground
opacity: slotBox.locked ? 0.65 : 1.0
border.width: slotBox.active ? 2 : 1
border.color: slotBox.active ? accent : Theme.sideBorder
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
MouseArea {
anchors.fill: parent
enabled: !slotBox.locked
cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: slotBox.armed()
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
Rectangle {
width: 8; height: 8; radius: 4
color: slotBox.accent
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
text: slotBox.title
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.0
color: slotBox.active ? slotBox.accent : Theme.sideMutedText
}
Label {
text: slotBox.filePath !== ""
? slotBox.filePath.split("/").pop()
: (slotBox.locked ? slotBox.placeholderText : (slotBox.active ? "<- Select file below..." : "Click to select"))
font.pixelSize: Theme.fontSm
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
Button {
visible: slotBox.filePath !== "" && !slotBox.locked
flat: true
implicitWidth: 24; implicitHeight: 24
onClicked: slotBox.cleared()
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.flatButtonHover : "transparent"
}
contentItem: IconImage {
source: "Tabs/icons/x.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
anchors.centerIn: parent
}
}
}
}
component PanelCheckBox: CheckBox {
id: toggle
indicator: Rectangle {
implicitWidth: 18
implicitHeight: 18
x: toggle.leftPadding
y: parent.height / 2 - height / 2
radius: Theme.radiusXs
color: toggle.checked ? Theme.primaryAccent : "transparent"
border.width: Theme.borderThin
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
IconImage {
anchors.centerIn: parent
source: "Tabs/icons/check.svg"
width: 12; height: 12
sourceSize.width: 12
sourceSize.height: 12
color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: Theme.durationFast } }
}
}
contentItem: Text {
text: toggle.text
color: Theme.headingColor
font.family: Theme.uiFontFamily
verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
}
}
ColumnLayout {
id: runCompCol
anchors.fill: parent
anchors.margins: 14
spacing: 8
spacing: 10
StackLayout {
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
spacing: 8
Label {
text: "RUN COMPARISON (DTW)"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.fillWidth: true
elide: Text.ElideRight
}
Button {
id: resetRunBtn
flat: true
implicitWidth: 32
implicitHeight: 32
visible: true
onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison()
background: Rectangle {
radius: Theme.radiusSm
color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent"
}
contentItem: IconImage {
source: "Tabs/icons/rotate-ccw.svg"
width: 18; height: 18
sourceSize.width: 18
sourceSize.height: 18
color: Theme.bodyColor
anchors.centerIn: parent
}
ToolTip.visible: resetRunBtn.hovered
ToolTip.text: "Reset comparison"
}
}
PanelCheckBox {
id: useMasterCheck
Layout.fillWidth: true
text: "Compare vs Master File"
checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
onToggled: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.useMasterFile = checked
if (checked) runComparisonBox.dataTab.activeBox = 2
}
}
RunSlot {
readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)"
accent: Theme.primaryAccent
locked: useMaster
placeholderText: useMaster
? (runComparisonBox.dataTab.runBWaferType !== ""
? "No master file for type " + runComparisonBox.dataTab.runBWaferType
: "Select Run B to resolve master")
: "Click to select"
active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false
filePath: runComparisonBox.dataTab
? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA)
: ""
onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1
onCleared: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.compareFileA = ""
runComparisonBox.dataTab.activeBox = 1
runComparisonBox.dataTab.clearResults()
}
}
RunSlot {
title: "SESSION (RUN B)"
accent: Theme.themeSkill
active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false
filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : ""
onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2
onCleared: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.compareFileB = ""
runComparisonBox.dataTab.activeBox = 2
runComparisonBox.dataTab.clearResults()
}
}
Button {
id: compareRunBtn
Layout.fillWidth: true
Layout.preferredHeight: 32
readonly property var dt: runComparisonBox.dataTab
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
readonly property string gateError: !!dt ? dt.familyGateError : ""
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
ToolTip.visible: (sameFile || masterMissing || gateError !== "") && hovered
ToolTip.text: masterMissing
? "No master file registered for this wafer type (Settings → Master Files)"
: gateError !== "" ? gateError
: "Choose two different runs to compare"
onClicked: {
dt.comparing = true
dt.clearResults()
streamController.compareFiles(fileA, dt.compareFileB)
}
background: Rectangle {
radius: Theme.radiusSm
color: compareRunBtn.enabled
? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
: Theme.buttonNeutralBackground
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
}
contentItem: Row {
spacing: 8
anchors.centerIn: parent
Label {
anchors.verticalCenter: parent.verticalCenter
text: !compareRunBtn.dt ? "Select both runs"
: compareRunBtn.dt.comparing
? "Comparing…"
: compareRunBtn.enabled
? "Run DTW Comparison"
: (compareRunBtn.masterMissing ? "No master for this type"
: compareRunBtn.sameFile ? "Choose different runs"
: compareRunBtn.gateError !== "" ? "Wafer family mismatch"
: "Select both runs")
color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
}
BusyIndicator {
running: !!compareRunBtn.dt && compareRunBtn.dt.comparing
visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing
width: 14; height: 14
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 120
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
// ── Status tab: Hardware Actions + Log Actions ──────────
ColumnLayout {
spacing: Theme.sideRailSpacing
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: hwActionsCol.implicitHeight + 28
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
// ── Status tab: Hardware Actions ────────────────
ColumnLayout {
id: hwActionsCol
anchors.fill: parent
anchors.margins: 14
spacing: 6
Label {
@@ -319,7 +625,6 @@ Rectangle {
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
topPadding: 6
font.letterSpacing: 1.5
font.bold: true
}
@@ -349,46 +654,140 @@ Rectangle {
onClicked: deviceController.eraseMemory(
deviceController.selectedPort)
}
Item { Layout.fillHeight: true }
}
}
// ── Map tab: Source file browser ──────────────
SourcePanel {
Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: logActionsCol.implicitHeight + 28
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
ColumnLayout {
id: logActionsCol
anchors.fill: parent
anchors.margins: 14
spacing: 6
Label {
text: "LOG ACTIONS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
}
RailActionButton {
label: "REFRESH SESSION"
iconSource: "../icons/refresh.svg"
Layout.fillWidth: true
onClicked: deviceController.clearSession()
}
RailActionButton {
label: "CLEAR LOG"
iconSource: "../icons/x.svg"
Layout.fillWidth: true
onClicked: deviceController.clearActivityLog()
}
}
}
Item { Layout.fillHeight: true }
}
// ── Map tab: Source file browser ──────────────
Rectangle {
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
SourcePanel {
anchors.fill: parent
anchors.margins: 14
}
}
}
// ── BOX 3: Hardware Status Footer ──────────────────────────
// Not relevant on the Data tab — Run Comparison takes its place above.
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterConnection
visible: root.selectedTabIndex !== 1
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
clip: true
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 6
// Row 1: Title + status badge (unchanged)
// Row 1: Title
Label {
text: "CONNECTION FLOW"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.fillWidth: true
elide: Text.ElideRight
}
// Row 2: Port / descriptive text
Label {
Layout.fillWidth: true
text: {
if (deviceController.connectionStatus === "Connected")
return deviceController.selectedPort || "Connected"
if (deviceController.selectedPort)
return deviceController.selectedPort
return "No port selected"
}
color: Theme.bodyColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
elide: Text.ElideRight
}
Item { Layout.fillHeight: true }
// Row 3: Mode chip left, status badge bottom-right
RowLayout {
spacing: 8
spacing: 6
Layout.fillWidth: true
Label {
text: "CONNECTION FLOW"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.2
font.bold: true
Rectangle {
width: modeChipLabel.implicitWidth + 12
height: 18
radius: 4
color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
Label {
id: modeChipLabel
anchors.centerIn: parent
text: streamController.mode
? streamController.mode.toUpperCase() : "REVIEW"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.8
}
}
Item { Layout.fillWidth: true }
Rectangle {
height: 18
width: badgeLabel.implicitWidth + 14
@@ -405,78 +804,10 @@ Rectangle {
anchors.centerIn: parent
text: deviceController.connectionStatus || "Disconnected"
color: Theme.statusBadgeText
font.pixelSize: Theme.font2xs
font.pixelSize: Theme.fontXs
font.bold: true
}
}
Item { Layout.fillWidth: true }
}
// Row 2: Status dot + descriptive text (replaces dotted trail)
Row {
spacing: 8
Layout.fillWidth: true
Rectangle {
id: statusDot
anchors.verticalCenter: parent.verticalCenter
width: 8
height: 8
radius: 4
color: deviceController.connectionStatus === "Connected"
? Theme.statusSuccessColor : Theme.statusErrorColor
SequentialAnimation on opacity {
running: deviceController.connectionStatus === "Connected"
loops: Animation.Infinite
NumberAnimation { to: 0.4; duration: 1200 }
NumberAnimation { to: 1.0; duration: 1200 }
}
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: {
if (deviceController.connectionStatus === "Connected")
return deviceController.selectedPort || "Connected"
if (deviceController.selectedPort)
return deviceController.selectedPort
return "No port selected"
}
color: Theme.bodyColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
elide: Text.ElideRight
}
}
// Row 3: Mode chip — neutral gray tag
Row {
spacing: 6
Layout.fillWidth: true
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: modeChipLabel.implicitWidth + 12
height: 18
radius: 4
color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
Label {
id: modeChipLabel
anchors.centerIn: parent
text: streamController.mode
? streamController.mode.toUpperCase() : "REVIEW"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
font.bold: true
font.letterSpacing: 0.8
}
}
}
}
}
@@ -594,8 +925,6 @@ Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.workspaceBackground
border.color: Theme.workspaceBorder
border.width: 1
StackLayout {
anchors.fill: parent
@@ -613,6 +942,8 @@ Rectangle {
}
Loader {
source: "Tabs/WaferMapTab.qml"
// TODO P3.1: append `&& licenseModel.licenses.length > 0`
// so the tab never instantiates while locked. See plan §3.1.
active: StackLayout.index === root.selectedTabIndex
}
}
File diff suppressed because it is too large Load Diff
+298 -308
View File
@@ -30,36 +30,45 @@ ColumnLayout {
radius: Theme.radiusSm
ColumnLayout {
anchors.centerIn: parent
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 4
Text {
id: labelText
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
}
Text {
id: valueText
color: active ? Theme.headingColor : Theme.sideMutedText
font.pixelSize: valSize
font.weight: Font.Bold
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignHCenter
}
Text {
id: labelText
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Medium
font.letterSpacing: 1.2
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignHCenter
Layout.fillHeight: true
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
fontSizeMode: Text.HorizontalFit
minimumPixelSize: Theme.fontMd
}
}
}
component GridCell : RowLayout {
// Stacked stat block: small label over big value, centered in its grid
// quadrant so the card's height is used instead of leaving dead space.
component GridCell : ColumnLayout {
property alias label: labelText.text
property alias value: valueText.text
property bool active: root.waferDetected
spacing: 8
spacing: 2
Layout.fillWidth: true
Layout.fillHeight: true
Item { Layout.fillHeight: true }
Text {
id: labelText
color: Theme.sideMutedText
@@ -67,17 +76,15 @@ ColumnLayout {
font.family: Theme.uiFontFamily
Layout.fillWidth: true
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
Text {
id: valueText
color: active ? Theme.headingColor : Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.pixelSize: Theme.fontXl
font.weight: Font.Bold
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight
verticalAlignment: Text.AlignVCenter
}
Item { Layout.fillHeight: true }
}
// ── Aliases for external wiring ──
@@ -120,16 +127,64 @@ ColumnLayout {
}
}
// ═══════════════════════════════════════════════════════════════════════
// ROW 1: Connection Status (100%)
// ═══════════════════════════════════════════════════════════════════════
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 90
Layout.maximumHeight: 90
spacing: 8
// Directory-only picker for the DIRECTORY card button — sets the save dir
// without kicking off a parse/save of pending read data.
FolderDialog {
id: dirOnlyDialog
title: "Choose a folder to save Data"
onAccepted: deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
}
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
// Cancel keeps the CSV, skips the sidecar — matching C# behavior loosely.
Dialog {
id: editCsvDialog
parent: Overlay.overlay
modal: true
title: "Edit CSV Metadata"
width: 460
anchors.centerIn: parent
standardButtons: Dialog.Save | Dialog.Cancel
onAccepted: file_browser.saveMetadata(root.csvPath, metaWafer.text, metaDate.text, metaChamber.text, metaNotes.text, false, "")
ColumnLayout {
anchors.fill: parent
spacing: 6
Text {
text: root.csvPath.split("/").pop()
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
elide: Text.ElideMiddle
Layout.fillWidth: true
}
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 }
}
}
// ═══════════════════════════════════════════════════════════════════════
// ROWS 1-2 (3-column bento):
// [Connection Status] [Total Runtime ] [Wafer & Sensor]
// [Directory (span 2) ] [ (rowspan) ]
// ═══════════════════════════════════════════════════════════════════════
GridLayout {
Layout.fillWidth: true
Layout.preferredHeight: 188
Layout.maximumHeight: 188
columns: 3
rowSpacing: 8
columnSpacing: 8
// ── Connection Status: two cells wide, top-left ──
Rectangle {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
@@ -138,43 +193,24 @@ ColumnLayout {
radius: Theme.radiusSm
ColumnLayout {
id: connStatusCol
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.panelPadding
anchors.rightMargin: Theme.panelPadding
spacing: 8
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 6
Text {
text: "CONNECTION STATUS"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Medium
font.letterSpacing: 1.2
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
}
Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
spacing: 0
ColumnLayout {
spacing: 1
Text {
text: "HOST PC"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
}
Text {
text: "localhost"
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
font.family: Theme.uiFontFamily
}
}
spacing: 8
Item {
Layout.fillWidth: true
@@ -239,221 +275,204 @@ ColumnLayout {
}
}
ColumnLayout {
spacing: 1
Text {
text: "COM PORT"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight
}
Text {
text: root.portName
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight
}
}
}
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
// ═══════════════════════════════════════════════════════════════════════
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 90
Layout.maximumHeight: 90
spacing: 8
RowLayout {
Layout.preferredWidth: 65
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 8
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 6
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: "DIRECTORY"
color: Theme.sideMutedText
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
font.letterSpacing: 1.2
font.family: Theme.uiFontFamily
}
Text {
text: deviceController.saveDataDir || "Not configured"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
font.family: Theme.uiFontFamily
font.italic: true
opacity: 0.7
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Rectangle {
visible: root.csvPath !== ""
width: sessionSavedLabel.implicitWidth + 12
height: 18
radius: 4
color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
Text {
id: sessionSavedLabel
anchors.centerIn: parent
text: "SESSION SAVED"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
}
}
Item { Layout.fillWidth: true }
Button {
id: browseBtn
text: "Save As"
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
font.family: Theme.uiFontFamily
implicitHeight: 24
implicitWidth: 70
hoverEnabled: true
background: Rectangle {
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
}
contentItem: Text {
text: browseBtn.text
color: Theme.bodyColor
font: browseBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: saveDirDialog.open()
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 4
Text {
text: "WAFER & SENSOR DATA"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
text: root.portName
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
Layout.bottomMargin: 2
elide: Text.ElideMiddle
}
}
}
}
// ── Wafer & Sensor Data: far right, spans both rows ──
Rectangle {
Layout.rowSpan: 2
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 4
Text {
text: "WAFER & SENSOR DATA"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
Layout.bottomMargin: 2
}
GridLayout {
Layout.fillWidth: true
Layout.fillHeight: true
columns: 2
rowSpacing: 6
columnSpacing: 16
GridCell {
id: waferInfoFamilyCell
label: "Family Code"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 0 && info[0]) ? info[0] : "—";
}
}
GridCell {
id: waferSerialCell
label: "Serial"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 1 && info[1]) ? info[1] : "—";
}
}
GridLayout {
Rectangle {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.fillHeight: true
columns: 2
rowSpacing: 4
columnSpacing: 16
Layout.preferredHeight: 1
color: Theme.cardBorder
opacity: 0.5
}
GridCell {
id: waferInfoFamilyCell
label: "Family Code"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 0 && info[0]) ? info[0] : "—";
}
GridCell {
id: waferCyclesCell
label: "Cycles Completed"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
}
GridCell {
id: waferCyclesCell
label: "Cycles Completed"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
}
active: root.waferDetected
}
Rectangle {
Layout.columnSpan: 2
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
opacity: 0.5
}
GridCell {
id: waferSerialCell
label: "Serial"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 1 && info[1]) ? info[1] : "—";
}
}
GridCell {
id: waferSensorsCell
label: "Sensors"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
}
active: root.waferDetected
}
GridCell {
id: waferSensorsCell
label: "Sensors"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
}
}
}
}
}
// ── Directory: bottom-left cell ──
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 6
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "DIRECTORY"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
Layout.fillWidth: true
}
Rectangle {
visible: root.csvPath !== ""
implicitWidth: sessionSavedLabel.implicitWidth + 12
implicitHeight: 18
radius: 4
color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
Text {
id: sessionSavedLabel
anchors.centerIn: parent
text: "SESSION SAVED"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
}
}
}
Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
spacing: 12
// Path pinned bottom-left
Text {
text: deviceController.saveDataDir || "Not configured"
color: Theme.bodyColor
font.pixelSize: Theme.fontMd
font.family: Theme.uiFontFamily
font.italic: true
opacity: 0.7
elide: Text.ElideMiddle
Layout.fillWidth: true
}
Button {
id: browseBtn
text: "Select Folder"
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
font.family: Theme.uiFontFamily
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
}
contentItem: Text {
text: browseBtn.text
color: Theme.bodyColor
font: browseBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: dirOnlyDialog.open()
}
}
}
}
// ── Total Runtime: bottom center ──
StatCard {
id: runtimeCard
Layout.preferredWidth: 35
label: "TOTAL RUNTIME"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 4 && info[4] !== undefined) ? info[4] + "s" : "—";
if (!(info && info.length > 4 && info[4] !== undefined)) return "—";
var secs = parseFloat(info[4]);
if (isNaN(secs)) return info[4] + "s";
var m = Math.floor(secs / 60);
var s = Math.round(secs % 60);
return secs + "s / " + m + ":" + (s < 10 ? "0" : "") + s + " min";
}
label: "TOTAL RUNTIME"
}
}
@@ -473,10 +492,20 @@ ColumnLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: Theme.subtleSectionBackground
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: Theme.subtleSectionBackground
radius: Theme.radiusMd
// Cover bottom corners so only top-left and top-right are rounded
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: parent.radius
color: parent.color
}
RowLayout {
anchors.fill: parent
@@ -489,64 +518,22 @@ ColumnLayout {
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
color: Theme.sideMutedText
visible: root.csvPath !== ""
Layout.alignment: Qt.AlignVCenter
}
Label {
text: root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG"
color: Theme.bodyColor
font.pixelSize: root.csvPath !== "" ? Theme.fontMd : Theme.fontSm
font.letterSpacing: root.csvPath !== "" ? 0 : 1.8
font.weight: Font.Medium
text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
color: Theme.sideMutedText
font.pixelSize: Theme.fontMd
font.letterSpacing: 1.5
font.bold: true
font.family: Theme.uiFontFamily
elide: Text.ElideMiddle
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
Button {
id: refreshSessionBtn
text: "Refresh"
font.pixelSize: Theme.fontSm
font.family: Theme.uiFontFamily
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
color: refreshSessionBtn.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: Text {
text: refreshSessionBtn.text
color: Theme.bodyColor
font: refreshSessionBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: deviceController.clearSession()
}
Button {
id: clearLogBtn
text: "Clear"
font.pixelSize: Theme.fontSm
font.family: Theme.uiFontFamily
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
color: clearLogBtn.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: Text {
text: clearLogBtn.text
color: Theme.bodyColor
font: clearLogBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: deviceController.clearActivityLog()
}
}
Rectangle {
@@ -567,7 +554,9 @@ ColumnLayout {
TextArea {
id: activityLog
width: parent.width
text: qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
// Bound to backend so log survives tab switches (Loader
// destroys this tab); clears only via Clear Log / restart.
text: deviceController.activityLog || qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
readOnly: true
font.family: "monospace"
font.pixelSize: Theme.fontSm
@@ -578,12 +567,6 @@ ColumnLayout {
}
}
Connections {
target: deviceController
function onActivityLogUpdated(newLog) {
activityLog.text = newLog ? newLog : qsTr("Welcome — use the side rail to detect a wafer, or import data to review.");
}
}
}
// ═══════════════════════════════════════════════════════════════════════
@@ -635,6 +618,13 @@ ColumnLayout {
root.dataRows = result.rows;
root.dataCols = result.cols;
root.csvPath = result.csv_path || "";
if (root.csvPath) {
metaWafer.text = result.serialNumber || "";
metaDate.text = Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss");
metaChamber.text = settingsModel.chamberId || "";
metaNotes.text = "";
editCsvDialog.open();
}
} else {
root.dataParsed = false;
}
+44 -50
View File
@@ -74,8 +74,8 @@ Item {
}
TabButton {
text: "Review"
implicitWidth: 64
implicitHeight: 28
implicitWidth: 84
implicitHeight: 34
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusSm - 1
@@ -83,29 +83,61 @@ Item {
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: Theme.fontSm
font.pixelSize: Theme.fontMd
font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
TabButton {
id: liveTab
text: "Live"
enabled: deviceController.connectionStatus === "Connected"
opacity: enabled ? 1.0 : 0.4
implicitWidth: 58
implicitHeight: 28
implicitWidth: 84
implicitHeight: 34
// Disabled controls swallow no events at all, so hover for the
// "why is this greyed out" tooltip needs its own MouseArea.
ToolTip.visible: disabledHover.containsMouse && !enabled
ToolTip.text: "Connect a wafer to enable Live mode"
ToolTip.delay: 300
MouseArea {
id: disabledHover
anchors.fill: parent
enabled: !liveTab.enabled
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusSm - 1
}
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: Theme.fontSm
font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
contentItem: Row {
spacing: 5
leftPadding: 8
Rectangle {
id: liveTabDot
width: 7; height: 7; radius: 3.5
anchors.verticalCenter: parent.verticalCenter
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
// Flash while actually streaming live
SequentialAnimation on opacity {
running: streamController.mode === "live" && streamController.state !== "idle"
loops: Animation.Infinite
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
}
}
Text {
text: liveTab.text
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: Theme.fontMd
font.weight: liveTab.checked ? Font.Medium : Font.Normal
verticalAlignment: Text.AlignVCenter
height: parent.height
}
}
}
onCurrentIndexChanged: if (currentIndex === 1) {
@@ -127,44 +159,6 @@ Item {
}
}
// Live source info: WiFi icon + port · serial
RowLayout {
visible: streamController.mode === "live"
spacing: 5
// Live status dot (matches the connection-flow indicator dot elsewhere)
Rectangle {
id: liveIndicator
width: 8; height: 8
radius: 4
Layout.alignment: Qt.AlignVCenter
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor
SequentialAnimation on opacity {
running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle"
loops: Animation.Infinite
NumberAnimation {
to: 0.2
duration: 600
easing.type: Easing.InOutQuad
}
NumberAnimation {
to: 1.0
duration: 600
easing.type: Easing.InOutQuad
}
onRunningChanged: {
if (!running)
liveIndicator.opacity = 1.0;
}
}
}
Label {
text: "Live stream"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
}
Item {
Layout.fillWidth: true
}
+132 -23
View File
@@ -75,6 +75,115 @@ ColumnLayout {
}
}
// Live-mode stream stats + stop — moved here from the footer transport bar
// so that bar collapses to zero height in live mode, freeing vertical
// space for the trend chart.
Rectangle {
Layout.fillWidth: true
visible: streamController.mode === "live"
implicitHeight: visible ? liveStatsCard.implicitHeight + 24 : 0
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout {
id: liveStatsCard
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 12
}
spacing: 8
Label {
text: "LIVE STREAM"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
}
RowLayout {
Layout.fillWidth: true
Label {
text: "Received"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: streamController.receivedCount
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontLg
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
RowLayout {
Layout.fillWidth: true
Label {
text: "Errors"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: streamController.errorCount
color: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontLg
font.bold: true
}
}
Button {
id: stopStreamBtn
Layout.fillWidth: true
Layout.topMargin: 4
hoverEnabled: true
text: "STOP"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
font.letterSpacing: 1.0
background: Rectangle {
radius: 8
color: stopStreamBtn.down ? Theme.transportButtonHover
: (stopStreamBtn.hovered ? Theme.transportButtonBg : "transparent")
border.width: Theme.borderThin
border.color: Theme.sideBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: Text {
text: stopStreamBtn.text
color: Theme.headingColor
font: stopStreamBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: streamController.stopStream()
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: readoutCard.implicitHeight + 24
@@ -98,7 +207,7 @@ ColumnLayout {
text: "READOUT"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.leftMargin: 14
@@ -117,7 +226,7 @@ ColumnLayout {
text: "MIN TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
@@ -127,7 +236,7 @@ ColumnLayout {
text: s.min !== undefined ? s.min : "—"
color: Theme.sensorLow
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.pixelSize: Theme.fontLg
font.bold: true
}
Label {
@@ -158,7 +267,7 @@ ColumnLayout {
text: "MAX TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
@@ -168,7 +277,7 @@ ColumnLayout {
text: s.max !== undefined ? s.max : "—"
color: Theme.sensorHigh
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.pixelSize: Theme.fontLg
font.bold: true
}
Label {
@@ -199,7 +308,7 @@ ColumnLayout {
text: "DIFF"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
@@ -207,7 +316,7 @@ ColumnLayout {
text: s.diff !== undefined ? s.diff : "—"
color: Theme.diffAccent
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.pixelSize: Theme.fontLg
font.bold: true
}
}
@@ -228,7 +337,7 @@ ColumnLayout {
text: "AVERAGE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
@@ -236,7 +345,7 @@ ColumnLayout {
text: s.avg !== undefined ? s.avg : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.pixelSize: Theme.fontLg
font.bold: true
}
}
@@ -257,7 +366,7 @@ ColumnLayout {
text: "SIGMA (Σ)"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
@@ -265,7 +374,7 @@ ColumnLayout {
text: s.sigma !== undefined ? s.sigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.pixelSize: Theme.fontLg
font.bold: true
}
}
@@ -286,7 +395,7 @@ ColumnLayout {
text: "3Σ VALUE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
font.bold: true
}
Item { Layout.fillWidth: true }
@@ -294,7 +403,7 @@ ColumnLayout {
text: s.threeSigma !== undefined ? s.threeSigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.pixelSize: Theme.fontLg
font.bold: true
}
}
@@ -323,7 +432,7 @@ ColumnLayout {
text: "DISPLAY"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
@@ -333,21 +442,21 @@ ColumnLayout {
id: thicknessToggle
text: "Show Thickness"
checked: false
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
}
PanelCheckBox {
id: labelsToggle
text: "Labels"
checked: true
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
}
PanelCheckBox {
id: clusterAverageToggle
text: "Average Clusters"
checked: streamController.clusterAveragingEnabled
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
onCheckedChanged: streamController.clusterAveragingEnabled = checked
}
@@ -363,7 +472,7 @@ ColumnLayout {
Label {
text: "Heatmap"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
Layout.preferredWidth: 52
}
Slider {
@@ -391,7 +500,7 @@ ColumnLayout {
Label {
text: Math.round(heatmapSlider.value * 100) + "%"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
Layout.preferredWidth: 32
horizontalAlignment: Text.AlignRight
@@ -422,7 +531,7 @@ ColumnLayout {
text: "THRESHOLDS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
@@ -431,7 +540,7 @@ ColumnLayout {
Label {
text: "Set Point (°C)"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
}
TextField {
id: spField
@@ -461,7 +570,7 @@ ColumnLayout {
Label {
text: "Margin (±°C)"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
}
TextField {
id: mgField
@@ -492,7 +601,7 @@ ColumnLayout {
id: autoCheck
text: "Auto range (mean ± 1σ)"
checked: true
font.pixelSize: Theme.fontXs
font.pixelSize: Theme.fontSm
onCheckedChanged: pushThresholds()
}
}
+62 -13
View File
@@ -190,26 +190,44 @@ ColumnLayout {
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
}
// Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab).
readonly property bool isActive:{
readonly property var dataTab: {
try {
if (root.selectedTabIndex === 1 && dataTabLoader.item) {
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB;
}
} catch (e) {}
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
} catch (e) { return null; }
}
// Run A's effective file: the master file when "Compare vs Master
// File" is toggled on, otherwise the manually selected file.
readonly property string effectiveRunAFile: fileItem.dataTab
? (fileItem.dataTab.useMasterFile ? fileItem.dataTab.masterFileForRunB : fileItem.dataTab.compareFileA)
: ""
readonly property bool isRunA: fileItem.dataTab !== null
&& fileItem.effectiveRunAFile !== "" && modelData.fileName === fileItem.effectiveRunAFile
readonly property bool isRunB: fileItem.dataTab !== null
&& modelData.fileName === fileItem.dataTab.compareFileB
// Active highlight logic: on Data tab, highlight the Run A/Run B files (or the
// resolved master file standing in for Run A); otherwise highlight loadedFile (Map tab).
readonly property bool isActive: fileItem.dataTab !== null
? (fileItem.isRunA || fileItem.isRunB)
: (streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile)
// A file registered as any family's master in Settings → Master Files.
readonly property bool isMasterFile: {
var m = settingsModel.masters
for (var fam in m) {
if (m[fam] && m[fam] === modelData.fileName) return true
}
return false
}
// "live_<serial>_<timestamp>.csv" is SessionController's recording
// naming convention (see FileBrowser._refresh_files) — grey these out
// unconditionally rather than only while actively being written, since
// a stale directory listing can't tell "mid-write" from "just finished".
readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
readonly property bool liveFileBlocked: root.selectedTabIndex === 1 && isLiveFile
visible: matchesFilter
height: matchesFilter ? implicitHeight : 0
enabled: !isLiveFile
opacity: isLiveFile ? 0.45 : 1.0
enabled: !liveFileBlocked
opacity: liveFileBlocked ? 0.45 : 1.0
Behavior on opacity {
NumberAnimation { duration: Theme.durationFast }
}
@@ -236,6 +254,8 @@ ColumnLayout {
bottomMargin: 5
}
color: {
if (fileItem.isRunA) return Theme.primaryAccent;
if (fileItem.isRunB) return Theme.themeSkill;
var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P")
return Theme.familyBlueAccent;
@@ -293,6 +313,16 @@ ColumnLayout {
anchors.topMargin: -2
anchors.rightMargin: -2
}
// Accent ring — this file is registered as a master (Settings → Master Files).
Rectangle {
visible: fileItem.isMasterFile
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.color: Theme.primaryAccent
border.width: 2
}
}
// Typography hierarchy
@@ -332,6 +362,25 @@ ColumnLayout {
font.letterSpacing: 0.6
}
}
Rectangle {
visible: fileItem.isMasterFile
radius: 4
color: Qt.alpha(Theme.primaryAccent, 0.18)
implicitWidth: masterLabel.implicitWidth + 10
implicitHeight: masterLabel.implicitHeight + 4
Layout.alignment: Qt.AlignVCenter
Label {
id: masterLabel
anchors.centerIn: parent
text: "MASTER"
color: Theme.primaryAccent
font.pixelSize: Theme.font2xs
font.bold: true
font.letterSpacing: 0.6
}
}
}
// Secondary: date · time
@@ -212,6 +212,7 @@ Popup {
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Item { Layout.preferredWidth: 80 }
}
}
@@ -267,6 +268,31 @@ Popup {
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Button {
id: exportBtn
text: "Export"
Layout.preferredWidth: 80
Layout.preferredHeight: 26
hoverEnabled: true
onClicked: streamController.exportSegment(index)
background: Rectangle {
radius: Theme.radiusXs
color: exportBtn.pressed ? Theme.buttonNeutralPressed
: exportBtn.hovered ? Theme.buttonNeutralHover
: Theme.buttonNeutralBackground
border.color: Theme.fieldBorder
border.width: 1
}
contentItem: Label {
text: exportBtn.text
color: Theme.buttonNeutralText
font.pixelSize: Theme.fontXs
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
@@ -295,6 +321,16 @@ Popup {
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
// ── Export feedback ───────────────────────────────────────
Label {
id: exportStatus
visible: text !== ""
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
// ── Backend Connection ────────────────────────────────────────
@@ -302,6 +338,7 @@ Popup {
target: streamController
function onSplitResult(result) {
root.segmenting = false;
exportStatus.text = "";
if (result && result.success) {
root.segments = result.segments || [];
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
@@ -310,6 +347,13 @@ Popup {
console.log("[SplitDialog] Split failed:", result.error || "unknown");
}
}
function onSegmentExported(result) {
if (result && result.success)
exportStatus.text = "Exported: " + result.path.split("/").pop();
else
exportStatus.text = "Export failed: " + (result.error || "unknown");
}
}
}
+6 -111
View File
@@ -5,8 +5,10 @@ import QtQuick.Layouts
import ISC
// ===== Control Bar =====
// Mode-aware footer: cross-fades between Live and Review layouts.
// Both variants render as a single centered pill — no full-width bar.
// Review-mode transport footer (frame counter, play/pause/step, speed).
// Live-mode's Received/Errors/Stop moved to ReadoutPanel's "LIVE STREAM"
// card so this bar can collapse to zero height and free vertical space for
// the trend chart while streaming.
Item {
id: bar
Layout.fillWidth: true
@@ -16,120 +18,13 @@ Item {
}
clip: true
// Hide entirely when in review mode with no file loaded
readonly property bool hasContent: {
if (streamController.mode === "live") return true;
return streamController.loadedFile !== "";
}
readonly property bool isLive: streamController.mode === "live"
// ── LIVE MODE ─────────────────────────────────────────────────
Item {
id: liveContent
anchors.fill: parent
opacity: bar.isLive ? 1.0 : 0.0
visible: opacity > 0
Behavior on opacity {
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
}
// Single centered pill for Received + Errors + Stop
Rectangle {
anchors.centerIn: parent
implicitWidth: liveRow.implicitWidth + 48
implicitHeight: 68
radius: Theme.radiusMd
color: Theme.sidePanelBackground
border.color: Theme.sideBorder
border.width: 1
Row {
id: liveRow
anchors.centerIn: parent
spacing: 24
// Received counter
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Received: " + streamController.receivedCount
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
}
// Separator
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 36
color: Theme.sideBorder
}
// Errors counter
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Errors: " + streamController.errorCount
color: streamController.errorCount > 0
? Theme.statusWarningColor : Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
}
// Separator
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 36
color: Theme.sideBorder
}
// STOP button
Button {
id: stopBtn
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 112
implicitHeight: 48
hoverEnabled: true
text: "STOP"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
font.letterSpacing: 1.0
background: Rectangle {
radius: 8
color: stopBtn.pressed ? Theme.transportButtonHover
: Theme.transportButtonBg
border.color: Theme.sideBorder
border.width: 1
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: streamController.stopStream()
}
}
}
}
// Only shown in review mode, and only once a file is loaded
readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== ""
// ── REVIEW MODE ──────────────────────────────────────────────
Item {
id: reviewContent
anchors.fill: parent
opacity: !bar.isLive ? 1.0 : 0.0
visible: opacity > 0
Behavior on opacity {
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
}
// Single centered pill for frame counter + transport + speed
Rectangle {
+1 -1
View File
@@ -205,7 +205,7 @@ QtObject {
readonly property int rightPaneGap: 6
readonly property int panelPadding: 12
// Side rail layout
readonly property int sideRailWidth: 280
readonly property int sideRailWidth: 300
readonly property int sideRailMargin: 16
readonly property int sideRailSpacing: 16
readonly property int sideButtonHeight: 44
+20
View File
@@ -1,5 +1,6 @@
import logging
import sys
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from PySide6.QtQml import QQmlApplicationEngine
@@ -11,6 +12,7 @@ from pygui.backend.controllers.session_controller import SessionController
from pygui.backend.data.file_browser import FileBrowser
from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.data.local_settings_model import LocalSettingsModel
from pygui.backend.license.license_model import LicenseModel
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
@@ -37,12 +39,30 @@ def main() -> int:
engine.rootContext().setContextProperty("settingsModel", settings_model)
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
# App version from package metadata so the About dialog can't drift
# from pyproject.toml.
try:
app_version = version("pygui")
except PackageNotFoundError:
app_version = "dev"
engine.rootContext().setContextProperty("appVersion", app_version)
# ===== Device Controller (serial comm) =====
# TODO P1.1: construct LicenseModel BEFORE DeviceController and pass
# license_lookup=license_model.levelForWafer into the ctor.
# THINKING: controller must answer "is this serial licensed?" at
# read/erase time (P2.1); a bound Callable[[str], str] avoids a
# LicenseModel import in the controller and keeps it testable with a
# lambda. See docs/pending/license-gating-plan.md §1.1.
data_dir = str(settings_model._data_dir)
raw_settings = LocalSettings.read_settings(data_dir)
device_controller = DeviceController(raw_settings, data_dir)
engine.rootContext().setContextProperty("deviceController", device_controller)
# ===== License Model (About dialog grid, replay trial) =====
license_model = LicenseModel(data_dir)
engine.rootContext().setContextProperty("licenseModel", license_model)
# ===== Session Controller (live/review wafer dashboard) =====
raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {}
stream_controller = SessionController(settings=raw_settings_dict)
@@ -73,6 +73,15 @@ class DeviceController(QObject):
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")
# TODO P1.2: accept ctor param license_lookup: Callable[[str], str]
# (default lambda s: "") and add:
# def _wafer_license_level(self) -> str:
# return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
# plus @Slot(result=bool) waferLicensed() for QML (used by P2.2).
# THINKING: single shared guard — per-call-site checks are the
# sibling-caller bug (fix read, forget erase). Empty serial → ""
# → denied = safe default. Called by P2.1.
# See docs/pending/license-gating-plan.md §1.2.
self._last_wafer_info: dict[str, Any] = {}
self._last_csv_path: str = ""
self._selected_port: str = ""
@@ -116,6 +125,11 @@ class DeviceController(QObject):
"""Whether a hardware operation is currently running."""
return self._operation_in_progress
@Property(str, notify=activityLogUpdated)
def activityLog(self) -> str:
"""Full activity log text; survives QML tab reloads."""
return "\n".join(self._activity_log)
@Property(str, notify=activityLogUpdated)
def saveDataDir(self) -> str:
"""Directory where parsed CSV data files are saved."""
@@ -324,6 +338,7 @@ class DeviceController(QObject):
self._graph_view.resetChart()
self._activity_log.clear()
self.detectResult.emit(None)
self.selectedPortChanged.emit()
self.parsedDataReady.emit({"success": False})
self._set_operation_progress(True)
@@ -349,20 +364,27 @@ class DeviceController(QObject):
self, port: Optional[str], info: Optional[WaferInfo]
) -> None:
"""Main-thread completion handler for detectWafer."""
if info is not None:
self._selected_port = port or ""
self._set_connection_status("Connected")
wafer_dict = self._wafer_info_to_dict(info)
self._last_wafer_info = wafer_dict
self.detectResult.emit(wafer_dict)
self._save_status()
else:
self._selected_port = ""
self._set_connection_status("Disconnected")
self._append_log("No wafer detected on any port")
self._last_wafer_info = {}
self.detectResult.emit(None)
self._set_operation_progress(False)
try:
if info is not None:
# Build the dict (can raise) before declaring success, so a
# bad payload can't leave status="Connected" while
# waferDetected stays false and Read/Erase stay greyed.
wafer_dict = self._wafer_info_to_dict(info)
self._selected_port = port or ""
self._last_wafer_info = wafer_dict
self._set_connection_status("Connected")
self.detectResult.emit(wafer_dict)
self.selectedPortChanged.emit()
self._save_status()
else:
self._selected_port = ""
self._set_connection_status("Disconnected")
self._append_log("No wafer detected on any port")
self._last_wafer_info = {}
self.detectResult.emit(None)
self.selectedPortChanged.emit()
finally:
self._set_operation_progress(False)
@Slot()
@slot_error_boundary
@@ -377,6 +399,16 @@ class DeviceController(QObject):
self._append_log("Already busy — ignoring read request")
return
# TODO P2.1: license guard —
# if self._wafer_license_level() == "":
# self._append_log("Wafer not licensed — load its license .bin (About dialog)")
# self.readResult.emit({"error": "Wafer not licensed"})
# return
# THINKING: this slot is the trust boundary; the QML enabled
# binding (P2.2) is UX only. Reuses existing readResult error
# shape → zero new signals. Depends on P1.2. Same guard goes in
# eraseMemory (emit eraseResult there). See plan §2.1.
if not self._selected_port:
self._append_log("No wafer detected — run Detect Wafer first")
self.readResult.emit({"error": "No port — detect wafer first"})
@@ -436,6 +468,9 @@ class DeviceController(QObject):
if self._operation_in_progress:
self._append_log("Already busy — ignoring erase request")
return
# TODO P2.1: same license guard as readMemoryAsync, but emit
# self.eraseResult.emit({"success": False}) on denial. See plan §2.1.
self._set_operation_progress(True)
self._set_connection_status("Erasing...")
self.portsUpdated.emit(self._service.enumerate_ports())
@@ -4,7 +4,6 @@ from __future__ import annotations
import json
import logging
import time
from pathlib import Path
from typing import Optional
from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
@@ -36,7 +35,7 @@ class SessionController(QObject):
recordingChanged = Signal()
sensorsChanged = Signal()
loadedFileChanged = Signal()
loadFileError = Signal(str) # emitted when loadFile() fails, so QML can show why
loadFileError = Signal(str)
clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
@@ -46,6 +45,7 @@ class SessionController(QObject):
# dicts to QML as an opaque empty wrapper with no accessible fields.
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
splitResult = Signal(dict) # dict with success, segments
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
@@ -91,6 +91,11 @@ class SessionController(QObject):
self._received_count: int = 0
self._error_count: int = 0
# Comparison cache for dynamic overlap mapping
self._compare_recs_a: list = []
self._compare_recs_b: list = []
self._compare_alignment_map: dict[int, int] = {}
# Restore persisted state if available
self._load_settings(settings or {})
@@ -259,6 +264,8 @@ class SessionController(QObject):
@Slot(str)
@slot_error_boundary
def loadFile(self, file_path: str) -> None:
from pathlib import Path
from pygui.backend.data.data_records import (
is_official_csv,
read_data_records,
@@ -268,37 +275,25 @@ class SessionController(QObject):
frames = []
if is_official_csv(file_path):
sensors, records = read_official_csv(file_path)
frames = frames_from_wafer_data(None, records)
else:
try:
try:
if is_official_csv(file_path):
sensors, records = read_official_csv(file_path)
frames = frames_from_wafer_data(None, records)
else:
data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc:
msg = f"Could not parse {Path(file_path).name}: {exc}"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return
if data is None or not data.sensors:
msg = f"Could not parse {Path(file_path).name}"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return
records = read_data_records(file_path)
sensors = data.sensors
frames = frames_from_wafer_data(data, records)
if data is None or not data.sensors:
self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
return
records = read_data_records(file_path)
sensors = data.sensors
frames = frames_from_wafer_data(data, records)
if not sensors:
msg = f"No sensor layout found in {Path(file_path).name}"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return
if not frames:
# A well-formed header with zero data rows below it — most commonly
# a live recording that was stopped before any frames arrived.
msg = f"{Path(file_path).name} has no recorded frames"
log.warning("%s", msg)
self.loadFileError.emit(msg)
if not sensors or not frames:
self.loadFileError.emit("No sensors or frames found in data file.")
return
except Exception as exc:
log.warning("Could not parse file %s: %s", file_path, exc)
self.loadFileError.emit(f"Load error: {exc}")
return
wafer_id = ""
@@ -317,10 +312,6 @@ class SessionController(QObject):
self._model.reset()
self._stats_tracker.reset()
self._loaded_file = file_path
# A file was explicitly picked for review — show it regardless of
# whichever mode (e.g. Live) was previously active, otherwise a
# successful load can be invisible to the user.
self.setMode(MODE_REVIEW)
self.loadedFileChanged.emit()
self.sensorsChanged.emit()
self._emit_current()
@@ -347,46 +338,19 @@ class SessionController(QObject):
@slot_error_boundary
def compareFiles(self, file_a: str, file_b: str) -> None:
"""Run DTW comparison between two CSV files and emit result."""
from pathlib import Path
from pygui.backend.comparison import compare_runs
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
if Path(file_a).resolve() == Path(file_b).resolve():
self.comparisonResult.emit({
"success": False,
"error": "Cannot compare a file to itself. Choose two different runs."
})
return
try:
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
# startRecording (see FileBrowser.isRecording), distinct from the
# "<serial>-<timestamp>.csv" convention used for read-memory dumps.
# Comparing across those categories doesn't make sense (a live
# capture vs. a full memory readout aren't the same kind of run),
# so gate on that before even looking at wafer type.
stem_a, stem_b = Path(file_a).stem, Path(file_b).stem
is_recording_a = stem_a.lower().startswith("live_")
is_recording_b = stem_b.lower().startswith("live_")
if is_recording_a != is_recording_b:
self.comparisonResult.emit({
"success": False,
"error": "Cannot compare a recording file with a read-memory file"
})
return
# Gate on wafer type (family): comparing a P-wafer against an X-wafer
# (different sensor counts/layouts) would silently truncate the
# smaller sensor set and pair up unrelated physical sensors in the
# overlap view. Same convention as FileBrowser's waferType badge —
# first character of the filename stem, minus the "live_" prefix
# so the wafer letter is read from the serial, not the "L".
def _wafer_type(stem: str, is_recording: bool) -> str:
if is_recording:
stem = stem[len("live_"):]
return stem[0].upper() if stem else ""
type_a = _wafer_type(stem_a, is_recording_a)
type_b = _wafer_type(stem_b, is_recording_b)
if type_a and type_b and type_a != type_b:
self.comparisonResult.emit({
"success": False,
"error": f"Cannot compare different wafer types: {type_a} vs {type_b}"
})
return
if is_official_csv(file_a):
_, recs_a = read_official_csv(file_a)
else:
@@ -403,30 +367,36 @@ class SessionController(QObject):
})
return
# Compare the full overlapping length of both runs. DTW's O(n*m) cost
# matrix is cheap even at a few thousand frames (~26MB at 1800x1800),
# so only cap against pathologically long files, not typical runs.
max_frames = min(5000, len(recs_a), len(recs_b))
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
for i in range(max_frames)]
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
for i in range(max_frames)]
self._compare_recs_a = recs_a
self._compare_recs_b = recs_b
result = compare_runs(series_a, series_b)
len_a, len_b = len(recs_a), len(recs_b)
# DTW can only align frames both runs actually have; the longer
# run's extra tail is still returned for display, just unaligned.
overlap_frames = min(len_a, len_b)
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 for i in range(len_a)]
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 for i in range(len_b)]
time_a = [round(recs_a[i].time, 3) for i in range(len_a)]
time_b = [round(recs_b[i].time, 3) for i in range(len_b)]
result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames])
self._compare_alignment_map = {i: j for i, j in result["warping_path"]}
# Mean temporal shift along the DTW path: positive means run B
# reaches the same profile features later than run A.
path = result["warping_path"]
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
frame_offset_seconds = round(
sum(time_b[j] - time_a[i] for i, j in path) / len(path), 2
) if path else 0.0
# Max sensor deviation: walk the DTW-aligned frame pairs and take
# the largest per-sensor abs difference across all sensors, not
# just the sensor[0] series used for alignment.
# Max sensor deviation: walk the DTW-aligned frame pairs (bounded to
# the overlap both runs share) and take the largest per-sensor abs
# difference across all sensors, not just the sensor[0] series used
# for alignment.
num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0
max_sensor_deviation = 0.0
for i, j in zip(result["index_a"], result["index_b"]):
if i >= max_frames or j >= max_frames:
continue
values_a = recs_a[i].values
values_b = recs_b[j].values
for s in range(min(num_sensors, len(values_a), len(values_b))):
@@ -435,9 +405,8 @@ class SessionController(QObject):
max_sensor_deviation = diff
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
# same sensor-parsing branch as loadFile(); file_a and file_b are now
# guaranteed to share a wafer type by the gate above.
from pygui.backend.data.data_records import is_official_csv, read_official_csv
# same sensor-parsing branch as loadFile() (file_a is assumed representative
# of both files' wafer type).
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -457,8 +426,8 @@ class SessionController(QObject):
if sensors:
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
last_a = recs_a[max_frames - 1].values
last_b = recs_b[max_frames - 1].values
last_a = recs_a[overlap_frames - 1].values
last_b = recs_b[overlap_frames - 1].values
n = min(len(sensors), len(last_a), len(last_b))
sensor_layout = [
{
@@ -481,9 +450,14 @@ class SessionController(QObject):
# Lists, not tuples — tuples don't survive QVariant conversion
"warping_path": [list(p) for p in result["warping_path"][:50]],
"frame_offset": frame_offset,
"frame_offset_seconds": frame_offset_seconds,
"max_sensor_deviation": max_sensor_deviation,
"series_a": series_a,
"series_b": series_b,
"time_a": time_a,
"time_b": time_b,
"frame_count_a": len_a,
"frame_count_b": len_b,
"sensor_layout": sensor_layout,
"sensor_diff": sensor_diff,
"wafer_shape": wafer_shape,
@@ -496,6 +470,25 @@ class SessionController(QObject):
"error": str(exc)
})
@Slot(int, result="QVariantList")
def getSensorDiffAt(self, scrub_idx: int) -> list[float]:
"""Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment."""
if not self._compare_recs_a or not self._compare_recs_b:
return []
idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1)
if idx_a not in self._compare_alignment_map:
# DTW path only covers indices up to the shorter run's length —
# past that, Run B has no data at this frame, not a clampable one.
return []
idx_b = self._compare_alignment_map[idx_a]
values_a = self._compare_recs_a[idx_a].values
values_b = self._compare_recs_b[idx_b].values
n = min(len(values_a), len(values_b))
return [round(values_b[i] - values_a[i], 3) for i in range(n)]
# ---- splitting: threshold-based segmentation ----
@Slot(str, float)
@slot_error_boundary
@@ -520,6 +513,10 @@ class SessionController(QObject):
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
segments = segment_profile(avg_temps, threshold)
# cache for exportSegment (per-segment Export in SplitDialog)
self._last_split_file = file_path
self._last_split_segments = segments
self.splitResult.emit({
"success": True,
"segments": [{
@@ -536,6 +533,48 @@ class SessionController(QObject):
"error": str(exc)
})
@Slot(int)
@slot_error_boundary
def exportSegment(self, index: int) -> None:
"""Write one segment from the last split as a standalone CSV.
Output lands next to the source file as
``<stem>_<label><index>.csv`` — the stem keeps its wafer-id prefix
so read_official_csv still resolves the layout on re-import.
Emits segmentExported with success + path (or error).
"""
from pathlib import Path
from pygui.backend.data.csv_recorder import CsvRecorder
source = getattr(self, "_last_split_file", "")
segments = getattr(self, "_last_split_segments", [])
if not source or not (0 <= index < len(segments)):
self.segmentExported.emit({
"success": False,
"error": "No split result to export"
})
return
seg = segments[index]
src = Path(source)
dest = src.with_name(f"{src.stem}_{seg.label.lower()}{index + 1}.csv")
try:
ok = CsvRecorder.write_segment(
str(dest), str(src), seg.start_frame, seg.end_frame)
except Exception as exc:
log.warning("Segment export failed: %s", exc)
self.segmentExported.emit({"success": False, "error": str(exc)})
return
if ok:
self.segmentExported.emit({"success": True, "path": str(dest)})
else:
self.segmentExported.emit({
"success": False,
"error": "No frames in range"
})
@Slot()
@slot_error_boundary
def play(self) -> None:
@@ -826,3 +865,4 @@ class SessionController(QObject):
def _flush_trend(self) -> None:
import json
self.trendData.emit(json.dumps(self._trend_buffer))
+55 -7
View File
@@ -12,7 +12,7 @@ from pygui.backend.wafer.zwafer_models import Sensor
class CsvRecorder:
def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None
self._path: Optional[str] = None
self._oath: Optional[str] = None
@property
@@ -47,9 +47,15 @@ class CsvRecorder:
return path
@staticmethod
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) ->bool:
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) -> bool:
"""Copy a range of frames from source CSV into a new file.
Preserves the source's header structure so the segment re-imports
through FileBrowser unchanged: official CSVs keep their sensor-name
row, Z-wafer CSVs keep every metadata line through the "data"
sentinel. Frame indices count only valid data rows, matching how
the readers in data_records.py number frames.
Args:
file_path: Output CSV path.
source_path: Source CSV path with full wafer data
@@ -57,11 +63,53 @@ class CsvRecorder:
end_frame: Last frame index (inclusive)
Returns:
True on success.
True on success (at least one frame written).
"""
import pandas as pd
df = pd.read_csv(source_path)
segment = df.iloc[start_frame:end_frame + 1]
segment.to_csv(file_path, index=False)
from pygui.backend.data.data_records import is_official_csv
if start_frame < 0 or end_frame < start_frame:
return False
def _is_data_row(stripped: str) -> bool:
cols = [c.strip() for c in stripped.split(",") if c.strip()]
if not cols:
return False
try:
[float(c) for c in cols]
except ValueError:
return False
return True
with open(source_path, "r", encoding="utf-8") as f:
lines = f.readlines()
out: list[str] = []
written = 0
frame_idx = -1
in_data = is_official_csv(source_path)
for i, line in enumerate(lines):
stripped = line.rstrip().rstrip(",").strip()
if not in_data:
out.append(line)
if stripped and stripped.split(",")[0].lower() == "data":
in_data = True
continue
if i == 0:
# official format: row 0 is the sensor-name header
out.append(line)
continue
if not stripped or not _is_data_row(stripped):
continue
frame_idx += 1
if start_frame <= frame_idx <= end_frame:
out.append(line if line.endswith("\n") else line + "\n")
written += 1
if written == 0:
return False
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8", newline="") as f:
f.writelines(out)
return True
+9 -10
View File
@@ -71,17 +71,16 @@ class FileBrowser(QObject):
self._show_error(f"CSV file was not found:\n{file_path}")
return
# ponytail: row is absent when saving metadata for a CSV just read from
# the wafer (not in the browser listing) — still write the sidecar.
row = self._find_row(file_path)
if row is None:
self._show_error("The selected CSV row is no longer available.")
return
row["wafer"] = wafer
row["date"] = date
row["chamber"] = chamber
row["notes"] = notes
row["selected"] = selected
row["masterType"] = master_type
if row is not None:
row["wafer"] = wafer
row["date"] = date
row["chamber"] = chamber
row["notes"] = notes
row["selected"] = selected
row["masterType"] = master_type
payload = {
"wafer": wafer,
+15
View File
@@ -0,0 +1,15 @@
from pygui.backend.license.license_manager import (
LICENSE_FILENAME_RE,
License,
parse_license_blob,
read_license_files,
)
from pygui.backend.license.license_model import LicenseModel
__all__ = [
"LICENSE_FILENAME_RE",
"License",
"LicenseModel",
"parse_license_blob",
"read_license_files",
]
@@ -0,0 +1,126 @@
"""C#-compatible wafer license file parsing.
License files are AES-128-CBC encrypted `.bin` blobs named
``X00000-00.bin`` where ``X`` is the wafer family code, ``00000`` the
5-digit serial number, and ``00`` the 2-digit license level. The blob is
``ciphertext || iv`` (IV is the trailing 16 bytes). The decrypted text is
a fixed-offset record::
[0:6] serial e.g. "A00001"
[7:15] mfg date 8 hex chars
[16:18] level 2 decimal digits ("00" basic, "01"+ adds replay)
[19:27] license date 8 hex chars
The key is hardcoded to match the C# application exactly
"""
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from pygui.backend.crypto.crypto_helper import CryptoHelper
log = logging.getLogger(__name__)
LICENSE_KEY = b"ThisIsA16ByteKey"
LICENSE_FILENAME_RE = re.compile(r"^[A-Z]\d{5}-\d{2}\.bin$")
_MIN_TEXT_LEN = 27
# ===== License Record =====
@dataclass(frozen=True)
class License:
"""One parsed license file (all fields as stored in the file)."""
serial: str # "A00001"
level: str # "00".."03"
mfg_date_hex: str # 8 hex chars
lic_date_hex: str # 8 hex chars
@property
def level_int(self) -> int:
"""Level as int; -1 if unparseable."""
try:
return int(self.level, 10)
except ValueError:
return -1
@property
def mfg_date_decimal(self) -> str:
"""Mfg date shown as decimal (C# grid does Convert.ToInt64(hex, 16))."""
return _hex_to_decimal(self.mfg_date_hex)
@property
def lic_date_decimal(self) -> str:
"""License date shown as decimal, same conversion as the C# grid."""
return _hex_to_decimal(self.lic_date_hex)
def _hex_to_decimal(hex_str: str) -> str:
try:
return str(int(hex_str, 16))
except ValueError:
return ""
# ===== Blob Parsing =====
def split_data_iv(blob: bytes) -> tuple[bytes, bytes]:
"""Split a license blob into (ciphertext, iv) — IV is the last 16 bytes."""
if len(blob) < 32:
raise ValueError("License blob too short")
return blob[:-16], blob[-16:]
def parse_license_blob(blob: bytes) -> License | None:
"""Decrypt and parse one license blob. None if malformed."""
try:
data, iv = split_data_iv(blob)
message = CryptoHelper.decrypt_aes(data, LICENSE_KEY, iv)
text = message.decode("utf-8")
except (ValueError, UnicodeDecodeError) as exc:
log.warning("Undecryptable license blob: %s", exc)
return None
if len(text) < _MIN_TEXT_LEN:
log.warning("License text too short (%d chars)", len(text))
return None
return License(
serial=text[0:6],
mfg_date_hex=text[7:15],
level=text[16:18],
lic_date_hex=text[19:27],
)
# ===== Directory Scan =====
def read_license_files(licenses_dir: str | Path) -> list[License]:
"""Parse every valid license .bin in the directory.
A file counts only if its name matches ``X00000-00.bin`` AND equals
``{serial}-{level}.bin`` from its own decrypted payload same
tamper check as the C# app.
"""
directory = Path(licenses_dir)
if not directory.is_dir():
return []
licenses: list[License] = []
for path in sorted(directory.glob("*.bin")):
if not LICENSE_FILENAME_RE.match(path.name):
continue
try:
blob = path.read_bytes()
except OSError as exc:
log.warning("Cannot read license file %s: %s", path, exc)
continue
lic = parse_license_blob(blob)
if lic is None:
continue
if path.name != f"{lic.serial}-{lic.level}.bin":
log.warning("License filename mismatch: %s", path.name)
continue
licenses.append(lic)
return licenses
+146
View File
@@ -0,0 +1,146 @@
"""QML-facing license model: grid rows, Load License, replay trial marker."""
import logging
import shutil
import time
from pathlib import Path
from urllib.parse import unquote, urlparse
from PySide6.QtCore import Property, QObject, Signal, Slot
from pygui.backend.license.license_manager import (
LICENSE_FILENAME_RE,
License,
parse_license_blob,
read_license_files,
)
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.
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
``DataDir\\licenses``). The 30-day replay trial marker is
``<data_dir>/replay.lic``.
"""
licensesChanged = Signal()
def __init__(self, data_dir: str, parent: QObject | None = None) -> None:
super().__init__(parent)
self._data_dir = Path(data_dir)
self._licenses_dir = self._data_dir / "licenses"
try:
self._licenses_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
log.warning("Cannot create licenses dir %s: %s", self._licenses_dir, exc)
self._licenses: list[License] = []
self.refresh()
# ── Grid rows ─────────────────────────────────────────────────────
@Property("QVariantList", notify=licensesChanged)
def licenses(self) -> list:
"""Rows for the About grid: serial, mfg date, level, license date."""
return [
{
"serial": lic.serial,
"mfgDate": lic.mfg_date_decimal,
"level": lic.level,
"licDate": lic.lic_date_decimal,
}
for lic in self._licenses
]
@Slot()
def refresh(self) -> None:
"""Re-scan the licenses directory."""
self._licenses = read_license_files(self._licenses_dir)
self.licensesChanged.emit()
# ── Load License ──────────────────────────────────────────────────
@Slot(str, result=bool)
def loadLicenseFile(self, path_or_url: str) -> bool:
"""Validate a picked .bin and copy it into the licenses directory."""
src = _to_local_path(path_or_url)
if not src.is_file():
log.warning("License file does not exist: %s", src)
return False
if not LICENSE_FILENAME_RE.match(src.name):
log.warning("License filename invalid: %s", src.name)
return False
try:
blob = src.read_bytes()
except OSError as exc:
log.warning("Cannot read license file %s: %s", src, exc)
return False
lic = parse_license_blob(blob)
if lic is None or src.name != f"{lic.serial}-{lic.level}.bin":
log.warning("License file failed validation: %s", src.name)
return False
try:
self._licenses_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, self._licenses_dir / src.name)
except OSError as exc:
log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
return False
self.refresh()
return True
# ── Wafer detect (informational) ──────────────────────────────────
@Slot(str, result=str)
def levelForWafer(self, serial: str) -> str:
"""License level for a wafer serial like "A00001"; "" if unlicensed."""
for lic in self._licenses:
if lic.serial == serial:
return lic.level
return ""
# ── Replay trial (30-day marker, C# parity) ───────────────────────
@property
def _trial_marker(self) -> Path:
return self._data_dir / "replay.lic"
@Slot(result=str)
def replayLicenseState(self) -> str:
""""permanent" if any license level >= 1, "temporary" if trial
marker exists, else "none" (trial not started)."""
if any(lic.level_int >= 1 for lic in self._licenses):
return "permanent"
if self._trial_marker.exists():
return "temporary"
return "none"
@Slot(result=bool)
def startReplayTrial(self) -> bool:
"""Create the trial marker (idempotent). False on I/O failure."""
try:
self._trial_marker.touch(exist_ok=True)
return True
except OSError as exc:
log.warning("Cannot create trial marker: %s", exc)
return False
@Slot(result=bool)
def replayTrialExpired(self) -> bool:
"""True once the marker is older than 30 days (or missing)."""
marker = self._trial_marker
if not marker.exists():
return True
age_days = (time.time() - marker.stat().st_mtime) / 86400.0
return age_days > TRIAL_DAYS
@@ -12,6 +12,7 @@ from __future__ import annotations
import json
import logging
import math
from typing import Optional
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
@@ -44,6 +45,11 @@ class TrendChartItem(QQuickPaintedItem):
self._data: list[float] = []
self._padding: int = 8
# Separate from `_padding` (breathing room around the plot area): these
# reserve space for the axis labels themselves, which are wider than
# `_padding` alone — that mismatch is what clipped y-axis text before.
self._margin_left: int = 34
self._margin_bottom: int = 16
self._line_color = QColor("#5B9DF5")
self._grid_color = QColor("#2A3441")
self._text_color = QColor("#CBD5E1")
@@ -153,9 +159,11 @@ class TrendChartItem(QQuickPaintedItem):
if w <= 0 or h <= 0:
return
plot_rect = QRectF(self._padding, self._padding,
max(1, w - 2 * self._padding),
max(1, h - 2 * self._padding))
left = self._padding + self._margin_left
bottom_inset = self._padding + self._margin_bottom
plot_rect = QRectF(left, self._padding,
max(1, w - left - self._padding),
max(1, h - self._padding - bottom_inset))
self._draw_grid(painter, plot_rect)
@@ -183,9 +191,14 @@ class TrendChartItem(QQuickPaintedItem):
lo = min(self._data)
hi = max(self._data)
if hi - lo < 1e-9:
return lo - 5.0, hi + 5.0
buf = (hi - lo) * 0.1
return lo - buf, hi + buf
lo, hi = lo - 5.0, hi + 5.0
# Snap to a "nice" tick step (1/2/5 × 10^k) so the axis holds still
# while streaming and only moves when data crosses a step boundary —
# raw min/max rescaled every frame, which read as an unstable chart.
raw_step = (hi - lo) / 4
mag = 10 ** math.floor(math.log10(raw_step))
step = next(s * mag for s in (1, 2, 5, 10) if s * mag >= raw_step)
return math.floor(lo / step) * step, math.ceil(hi / step) * step
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
if n <= 1:
@@ -220,16 +233,41 @@ class TrendChartItem(QQuickPaintedItem):
font = QFont(painter.font())
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
painter.setFont(font)
rows = 4
label_w = self._margin_left - 4 # 4px gutter before the plot area
for i in range(rows + 1):
t = i / rows
y_val = y_max - t * (y_max - y_min)
y_px = plot_rect.top() + t * plot_rect.height()
label = f"{y_val:.1f}"
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
label = f"{y_val:g}"
painter.drawText(QRectF(0, y_px - 7, label_w, 14),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
label)
# X-axis: sample index. `trendData` carries plain averages with no
# timestamps attached, so index is the only x-coordinate available —
# it reads correctly for review mode (index == frame number) and as a
# relative recency order for the live rolling window. paint() already
# guarantees len(self._data) >= 2 before calling this.
n = len(self._data)
cols = min(4, n - 1)
y_px = plot_rect.bottom() + 4
for i in range(cols + 1):
idx = round((i / cols) * (n - 1))
x_px = self._x_to_px(idx, n, plot_rect)
if i == 0:
rect = QRectF(x_px, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignLeft
elif i == cols:
# Right-anchor inside the plot so the label never clips off-widget
rect = QRectF(x_px - 40, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignRight
else:
rect = QRectF(x_px - 20, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignHCenter
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, str(idx))
def _draw_line(
self,
painter: QPainter,
@@ -347,6 +347,10 @@ class WaferMapItem(QQuickPaintedItem):
self._thickness_heatmap = None
return
field = refine_field(field)
# ponytail: RBF overshoots around outlier sensors, inventing values no
# sensor produced (e.g. negative diffs when all diffs are positive).
# Clamp to the real data range so color only reflects measured values.
field = np.clip(field, vs.min(), vs.max())
if self._shape == "round":
alpha = ellipse_alpha(*field.shape)
else:
@@ -467,6 +471,10 @@ class WaferMapItem(QQuickPaintedItem):
self._heatmap = None
return
field = refine_field(field)
# ponytail: RBF overshoots around outlier sensors, inventing values no
# sensor produced (e.g. negative diffs when all diffs are positive).
# Clamp to the real data range so color only reflects measured values.
field = np.clip(field, vs.min(), vs.max())
if self._shape == "round":
alpha = ellipse_alpha(*field.shape)
else:
@@ -609,11 +617,11 @@ class WaferMapItem(QQuickPaintedItem):
font_scale = 0.85
id_font = QFont()
id_font.setPointSize(max(4, int(r * font_scale)))
id_font.setPointSize(max(9, int(r * font_scale)))
id_font.setBold(True)
temp_font = QFont()
temp_font.setPointSize(max(3, int((r - 1) * font_scale)))
temp_font.setPointSize(max(9, int(r * font_scale)))
band_color = {
"in_range": self._in_range_color,
+4
View File
@@ -65,6 +65,10 @@ class SerialPort:
)
if timeout is not None:
kwargs["timeout"] = timeout
# Write timeout must never be None (= block forever): on macOS,
# pseudo-ports like Bluetooth-Incoming-Port accept the open and then
# hang the write, which froze detect in "Detecting..." permanently.
kwargs["write_timeout"] = timeout if timeout is not None else 2.0
try:
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
+11
View File
@@ -0,0 +1,11 @@
Wafer ID=A12
Acquisition Date=03/26/2025
Label,1,2,3
X (mm),0,10,-10
Y (mm),10,-10,-10
data
0.0,150.0,149.5,151.6
0.5,150.2,149.4,151.7
1.0,150.1,149.6,151.5
1.5,150.3,149.7,151.4
2.0,150.4,149.8,151.3
1 Wafer ID=A12
2 Acquisition Date=03/26/2025
3 Label,1,2,3
4 X (mm),0,10,-10
5 Y (mm),10,-10,-10
6 data
7 0.0,150.0,149.5,151.6
8 0.5,150.2,149.4,151.7
9 1.0,150.1,149.6,151.5
10 1.5,150.3,149.7,151.4
11 2.0,150.4,149.8,151.3
+67
View File
@@ -38,3 +38,70 @@ def test_recorded_data_rows_are_readable(tmp_path):
assert records[0].values == [149.0, 148.0]
assert records[1].time == 0.5
assert records[1].values == [149.5, 148.5]
# ===== write_segment (P3.3) =====
def _make_zwafer_csv(tmp_path, rows=5):
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
path = tmp_path / "src.csv"
rec = CsvRecorder()
rec.start(str(path), sensors, serial="A12")
for i in range(rows):
rec.write(Frame(seq=i, time=i * 0.5, values=[100.0 + i, 200.0 + i]))
rec.stop()
return path
def test_write_segment_zwafer_slice(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=5)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 1, 3) is True
records = read_data_records(str(dest))
assert len(records) == 3
assert records[0].values == [101.0, 201.0]
assert records[-1].values == [103.0, 203.0]
# header survives → still parses as a wafer CSV
data, _ = ZWaferParser().parse(str(dest))
assert data is not None
assert [s.label for s in data.sensors] == ["1", "2"]
def test_write_segment_full_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=4)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 0, 3) is True
assert len(read_data_records(str(dest))) == 4
def test_write_segment_out_of_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=3)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 10, 20) is False
assert not dest.exists()
def test_write_segment_invalid_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=3)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 2, 1) is False
assert CsvRecorder.write_segment(str(dest), str(src), -1, 1) is False
def test_write_segment_official_csv(tmp_path):
# official format: row 0 = sensor names, rows 1+ = values only
src = tmp_path / "A00001-x.csv"
src.write_text(
"Sensor1,Sensor2\n"
"10.0,20.0\n"
"11.0,21.0\n"
"12.0,22.0\n",
encoding="utf-8",
)
dest = tmp_path / "A00001-x_seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 1, 2) is True
lines = dest.read_text(encoding="utf-8").splitlines()
assert lines[0] == "Sensor1,Sensor2"
assert lines[1] == "11.0,21.0"
assert lines[2] == "12.0,22.0"
assert len(lines) == 3
+8
View File
@@ -0,0 +1,8 @@
# TODO P4.2: unit tests for the DeviceController license guard (plan §4.2,
# docs/pending/license-gating-plan.md). Two cases:
# 1. license_lookup=lambda s: "" + fake _last_wafer_info → readMemoryAsync()
# emits readResult {"error": ...} and spawns no thread.
# 2. lookup returning "02" → proceeds to _read_worker (mock DeviceService).
# THINKING: guard is a trust boundary (plan §2.1); these fail if someone
# reorders the busy/license checks or renames the "serialNumber" key.
# Depends on P1.2 + P2.1 being implemented.
+195
View File
@@ -0,0 +1,195 @@
"""Tests for src/pygui/backend/license/ (manager + model)."""
import os
import time
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from pygui.backend.license.license_manager import (
LICENSE_KEY,
parse_license_blob,
read_license_files,
split_data_iv,
)
from pygui.backend.license.license_model import LicenseModel
# ===== Fixture helpers =====
def _encrypt_aes(plaintext: bytes, key: bytes, iv: bytes) -> bytes:
"""AES-128-CBC + PKCS7, mirroring what the C# license generator does."""
pad_len = 16 - (len(plaintext) % 16)
padded = plaintext + bytes([pad_len]) * pad_len
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
enc = cipher.encryptor()
return enc.update(padded) + enc.finalize()
def make_license_blob(
serial: str = "A00001",
mfg_hex: str = "0001E240",
level: str = "01",
lic_hex: str = "0001E24F",
) -> bytes:
"""Build a C#-format license blob: ciphertext || iv, fixed-offset text."""
text = f"{serial} {mfg_hex} {level} {lic_hex}"
iv = bytes(range(16))
return _encrypt_aes(text.encode("utf-8"), LICENSE_KEY, iv) + iv
def write_license(directory, serial="A00001", level="01", **kw) -> str:
path = directory / f"{serial}-{level}.bin"
path.write_bytes(make_license_blob(serial=serial, level=level, **kw))
return str(path)
# ===== Manager: blob parsing =====
def test_parse_valid_blob():
lic = parse_license_blob(make_license_blob())
assert lic is not None
assert lic.serial == "A00001"
assert lic.level == "01"
assert lic.level_int == 1
assert lic.mfg_date_hex == "0001E240"
assert lic.mfg_date_decimal == "123456"
assert lic.lic_date_decimal == str(int("0001E24F", 16))
def test_parse_blob_too_short():
assert parse_license_blob(b"short") is None
def test_parse_blob_garbage():
assert parse_license_blob(os.urandom(64)) is None
def test_parse_blob_short_text():
iv = bytes(16)
blob = _encrypt_aes(b"A00001 tiny", LICENSE_KEY, iv) + iv
assert parse_license_blob(blob) is None
def test_split_data_iv():
data, iv = split_data_iv(bytes(48))
assert len(data) == 32
assert len(iv) == 16
# ===== Manager: directory scan =====
def test_read_license_files_valid(tmp_path):
write_license(tmp_path)
licenses = read_license_files(tmp_path)
assert len(licenses) == 1
assert licenses[0].serial == "A00001"
def test_read_license_files_missing_dir(tmp_path):
assert read_license_files(tmp_path / "nope") == []
def test_read_license_files_bad_filename_skipped(tmp_path):
(tmp_path / "notalicense.bin").write_bytes(make_license_blob())
assert read_license_files(tmp_path) == []
def test_read_license_files_name_payload_mismatch(tmp_path):
# payload says A00001-01 but file claims B00002-03 → tamper, reject
(tmp_path / "B00002-03.bin").write_bytes(make_license_blob())
assert read_license_files(tmp_path) == []
def test_read_license_files_corrupt_skipped(tmp_path):
(tmp_path / "A00001-01.bin").write_bytes(os.urandom(64))
assert read_license_files(tmp_path) == []
# ===== Model =====
def test_model_lists_licenses(tmp_path, qapp):
(tmp_path / "licenses").mkdir()
write_license(tmp_path / "licenses")
model = LicenseModel(str(tmp_path))
rows = model.licenses
assert len(rows) == 1
assert rows[0]["serial"] == "A00001"
assert rows[0]["level"] == "01"
assert rows[0]["mfgDate"] == "123456"
def test_model_load_license_file(tmp_path, qapp):
src_dir = tmp_path / "downloads"
src_dir.mkdir()
src = write_license(src_dir, serial="B00002", level="00")
model = LicenseModel(str(tmp_path / "appdata"))
assert model.loadLicenseFile(src) is True
assert len(model.licenses) == 1
assert model.licenses[0]["serial"] == "B00002"
# copy landed in <data_dir>/licenses
assert (tmp_path / "appdata" / "licenses" / "B00002-00.bin").exists()
def test_model_load_license_file_url(tmp_path, qapp):
src_dir = tmp_path / "dl"
src_dir.mkdir()
src = write_license(src_dir)
model = LicenseModel(str(tmp_path / "appdata"))
assert model.loadLicenseFile("file://" + src) is True
def test_model_load_rejects_invalid(tmp_path, qapp):
src_dir = tmp_path / "dl"
src_dir.mkdir()
bad = src_dir / "A00001-01.bin"
bad.write_bytes(os.urandom(64))
model = LicenseModel(str(tmp_path / "appdata"))
assert model.loadLicenseFile(str(bad)) is False
assert model.licenses == []
def test_model_load_rejects_missing(tmp_path, qapp):
model = LicenseModel(str(tmp_path))
assert model.loadLicenseFile(str(tmp_path / "ghost.bin")) is False
def test_model_level_for_wafer(tmp_path, qapp):
(tmp_path / "licenses").mkdir()
write_license(tmp_path / "licenses", serial="A00001", level="02")
model = LicenseModel(str(tmp_path))
assert model.levelForWafer("A00001") == "02"
assert model.levelForWafer("Z99999") == ""
# ===== Replay trial =====
def test_replay_state_none_then_temporary(tmp_path, qapp):
model = LicenseModel(str(tmp_path))
assert model.replayLicenseState() == "none"
assert model.startReplayTrial() is True
assert model.replayLicenseState() == "temporary"
assert model.replayTrialExpired() is False
def test_replay_state_permanent_with_level1(tmp_path, qapp):
(tmp_path / "licenses").mkdir()
write_license(tmp_path / "licenses", level="01")
model = LicenseModel(str(tmp_path))
assert model.replayLicenseState() == "permanent"
def test_replay_level0_not_permanent(tmp_path, qapp):
(tmp_path / "licenses").mkdir()
write_license(tmp_path / "licenses", level="00")
model = LicenseModel(str(tmp_path))
assert model.replayLicenseState() == "none"
def test_replay_trial_expiry(tmp_path, qapp):
model = LicenseModel(str(tmp_path))
model.startReplayTrial()
marker = tmp_path / "replay.lic"
old = time.time() - 31 * 86400
os.utime(marker, (old, old))
assert model.replayTrialExpired() is True
def test_replay_trial_expired_without_marker(tmp_path, qapp):
model = LicenseModel(str(tmp_path))
assert model.replayTrialExpired() is True
+63 -129
View File
@@ -1,10 +1,7 @@
from unittest.mock import MagicMock
import pytest
from unittest.mock import MagicMock, patch
from pygui.backend.controllers.session_controller import SessionController
@pytest.fixture
def controller():
return SessionController()
@@ -12,7 +9,7 @@ def controller():
def test_initial_default(controller):
assert controller.mode == "review"
assert controller.state == "idle"
assert not controller.recording
assert controller.recording == False
def test_playback_flow(controller):
controller.loadFile("tests/fixtures/sample_stream.csv")
@@ -50,44 +47,13 @@ def test_pause_stop_timer(controller):
controller.stop()
assert controller.frameIndex == 0
def test_load_file_emits_error_for_empty_recording(controller, tmp_path):
"""A recording with a valid header but zero data rows (e.g. Record was
started/stopped without any live frames arriving) must surface a clear
loadFileError instead of silently doing nothing."""
empty_recording = tmp_path / "live_A00001_20260706_120427.csv"
empty_recording.write_text(
"Wafer ID=A00001\n"
"Acquisition Date=07/06/2026\n"
"Label,1,2\n"
"X (mm),0.0,10.0\n"
"Y (mm),10.0,-10.0\n"
"data\n"
)
mock_slot = MagicMock()
controller.loadFileError.connect(mock_slot)
controller.loadFile(str(empty_recording))
assert mock_slot.call_count == 1
assert "no recorded frames" in mock_slot.call_args[0][0]
assert controller.loadedFile == ""
def test_load_file_switches_to_review_mode(controller):
"""Loading a file for playback must always land in review mode, even if
the controller was left in live mode from a previous session otherwise
a successful load can be invisible behind a stale Live toggle."""
controller._mode = "live"
controller.loadFile("tests/fixtures/sample_stream.csv")
assert controller.mode == "review"
def test_compare_files_integration(controller):
# Mock the comparisonResult signal
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
# Use sample_stream.csv for both arguments to test identical files compare
csv_path = "tests/fixtures/sample_stream.csv"
controller.compareFiles(csv_path, csv_path)
# Two runs with the same first 3 frames but run B has 2 extra trailing frames.
controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
# Verify that the comparison signal is emitted with a success dict
assert mock_slot.call_count == 1
@@ -96,97 +62,24 @@ def test_compare_files_integration(controller):
assert "distance" in args
assert "series_a" in args
assert "series_b" in args
assert args["frame_offset"] == 0
def test_compare_files_uses_full_run_not_first_100_frames(controller, tmp_path):
"""compareFiles must score the whole run, not just an initial 100-frame window.
Regression test: max_frames used to be hard-capped at 100, so a >100-frame
run's soak/cool phases were silently excluded from the DTW comparison.
"""
num_frames = 150
csv_path = tmp_path / "long_run.csv"
lines = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data"]
for i in range(num_frames):
t = i * 0.5
lines.append(f"{t},{149.0 + i * 0.01},{148.5 + i * 0.01},{150.6 + i * 0.01}")
csv_path.write_text("\n".join(lines) + "\n")
# time_a/time_b expose per-frame timestamps in seconds for the mismatched run lengths.
assert args["time_a"] == [0.0, 0.5, 1.0]
assert args["time_b"] == [0.0, 0.5, 1.0, 1.5, 2.0]
assert args["frame_count_a"] == 3
assert args["frame_count_b"] == 5
def test_compare_files_same_file_is_gated(controller):
"""Comparing a run against itself should be rejected, not silently DTW'd."""
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(csv_path), str(csv_path))
csv_path = "tests/fixtures/sample_stream.csv"
controller.compareFiles(csv_path, csv_path)
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is True
assert len(result["series_a"]) == num_frames
assert len(result["series_b"]) == num_frames
def test_compare_files_rejects_mismatched_wafer_types(controller, tmp_path):
"""compareFiles must refuse to compare two different wafer families.
Otherwise the overlap view silently truncates to min(sensor_count) and
pairs up unrelated physical sensors between the two wafer types.
"""
header = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
file_a = tmp_path / "A00055-20250326.csv"
file_b = tmp_path / "X00108-20250326.csv"
file_a.write_text("\n".join(header) + "\n")
file_b.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(file_a), str(file_b))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is False
assert "A" in result["error"] and "X" in result["error"]
def test_compare_files_rejects_recording_vs_read_mem(controller, tmp_path):
"""A live_-prefixed recording must not be compared against a read-memory
dump. Regression: stem[0] on "live_P0001_..." is "L", which used to be
misreported as a wafer-type mismatch ("L vs P") instead of this clearer
recording-vs-read-mem error.
"""
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
recording = tmp_path / "live_P0001_20260706_143022.csv"
read_mem = tmp_path / "P0001-20260706_143500.csv"
recording.write_text("\n".join(header) + "\n")
read_mem.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(recording), str(read_mem))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is False
assert result["error"] == "Cannot compare a recording file with a read-memory file"
def test_compare_files_allows_two_recordings_of_same_wafer_type(controller, tmp_path):
"""Two live recordings of the same wafer family should compare fine —
the "live_" prefix must not be mistaken for the wafer type letter."""
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
"0.0,149.0,148.5,150.6"]
file_a = tmp_path / "live_P0001_20260706_143022.csv"
file_b = tmp_path / "live_P0002_20260706_150000.csv"
file_a.write_text("\n".join(header) + "\n")
file_b.write_text("\n".join(header) + "\n")
mock_slot = MagicMock()
controller.comparisonResult.connect(mock_slot)
controller.compareFiles(str(file_a), str(file_b))
assert mock_slot.call_count == 1
result = mock_slot.call_args[0][0]
assert result["success"] is True
args = mock_slot.call_args[0][0]
assert args["success"] is False
assert "error" in args
def test_comparison_result_reaches_qml(qapp, controller):
"""The result dict must arrive in QML as a real JS object with fields.
@@ -216,26 +109,25 @@ Item {
assert engine.rootObjects(), "probe QML failed to load"
root = engine.rootObjects()[0]
csv_path = "tests/fixtures/sample_stream.csv"
controller.compareFiles(csv_path, csv_path)
controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
qapp.processEvents()
assert root.property("gotSuccess") is True
assert root.property("gotDistance") >= 0
def test_recording_toggle(controller, tmp_path):
csv_path = str(tmp_path / "rec" / "live_test.csv")
controller.startRecording(csv_path, "SN123")
assert controller.recording
assert controller.recording == True
controller.stopRecording()
assert not controller.recording
assert controller.recording == False
# Header written with wafer serial
content = open(csv_path).read()
assert "Wafer ID=SN123" in content
def test_resync_count_default(controller):
# No active reader -> zero
assert controller.resyncCount == 0
@@ -257,3 +149,45 @@ def test_split_data_integration(controller):
args = mock_slot.call_args[0][0]
assert args["success"] is True
assert "segments" in args
def test_export_segment_after_split(qapp, controller, tmp_path):
import shutil
src = tmp_path / "sample.csv"
shutil.copyfile("tests/fixtures/sample_stream.csv", src)
controller.splitData(str(src), 149.0)
exported = MagicMock()
controller.segmentExported.connect(exported)
controller.exportSegment(0)
assert exported.call_count == 1
result = exported.call_args[0][0]
assert result["success"] is True
out = result["path"]
assert out.startswith(str(tmp_path))
from pygui.backend.data.data_records import read_data_records
assert len(read_data_records(out)) > 0
def test_export_segment_without_split(qapp, controller):
exported = MagicMock()
controller.segmentExported.connect(exported)
controller.exportSegment(0)
result = exported.call_args[0][0]
assert result["success"] is False
def test_export_segment_bad_index(qapp, controller, tmp_path):
import shutil
src = tmp_path / "sample.csv"
shutil.copyfile("tests/fixtures/sample_stream.csv", src)
controller.splitData(str(src), 149.0)
exported = MagicMock()
controller.segmentExported.connect(exported)
controller.exportSegment(99)
assert exported.call_args[0][0]["success"] is False
Generated
+211 -46
View File
@@ -58,6 +58,104 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
]
[[package]]
name = "cffi"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" },
{ url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" },
{ url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" },
{ url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" },
{ url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" },
{ url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" },
{ url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" },
{ url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" },
{ url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
{ url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
{ url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
{ url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
{ url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
{ url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
{ url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
{ url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
{ url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
{ url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
{ url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -72,7 +170,7 @@ name = "contourpy"
version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
wheels = [
@@ -149,6 +247,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" },
]
[[package]]
name = "cryptography"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
{ url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
{ url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
{ url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
{ url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
]
[[package]]
name = "cycler"
version = "0.12.1"
@@ -163,8 +317,8 @@ name = "dtw-python"
version = "1.7.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "numpy" },
{ name = "scipy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/ac/e0967e71019959197e2f8a4e99937f043f35fa1efc2ac30294ecc8b3df77/dtw_python-1.7.5.tar.gz", hash = "sha256:650dec798812dcda3aab059a5dcf5954d34d33903479df00b90d33956b7b04b3", size = 274203, upload-time = "2026-06-12T12:01:02.056Z" }
wheels = [
@@ -437,15 +591,15 @@ name = "matplotlib"
version = "3.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "contourpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "cycler", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "fonttools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "kiwisolver", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyparsing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "contourpy" },
{ name = "cycler" },
{ name = "fonttools" },
{ name = "kiwisolver" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "pyparsing" },
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" }
wheels = [
@@ -501,11 +655,11 @@ name = "mypy"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "librt", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" },
{ name = "mypy-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pathspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
wheels = [
@@ -649,8 +803,8 @@ name = "pandas"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
@@ -809,6 +963,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -823,31 +986,33 @@ name = "pygui"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "dtw-python", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pandas", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyqtgraph", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyserial", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyside6", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "cryptography" },
{ name = "dtw-python" },
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "pyqtgraph" },
{ name = "pyserial" },
{ name = "pyside6" },
{ name = "pyyaml" },
{ name = "scipy" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest" },
]
[package.dev-dependencies]
dev = [
{ name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=41.0" },
{ name = "dtw-python" },
{ name = "matplotlib", specifier = ">=3.7.0" },
{ name = "numpy", specifier = ">=1.24.0" },
@@ -882,8 +1047,8 @@ name = "pyqtgraph"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "colorama" },
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5", size = 1924755, upload-time = "2025-11-16T19:43:22.251Z" },
@@ -903,9 +1068,9 @@ name = "pyside6"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-addons", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyside6-essentials", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "shiboken6", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyside6-addons" },
{ name = "pyside6-essentials" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" },
@@ -920,8 +1085,8 @@ name = "pyside6-addons"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-essentials", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "shiboken6", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pyside6-essentials" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" },
@@ -936,7 +1101,7 @@ name = "pyside6-essentials"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "shiboken6", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" },
@@ -952,10 +1117,10 @@ version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
@@ -967,7 +1132,7 @@ name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
@@ -1059,7 +1224,7 @@ name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [