Compare commits
5 Commits
799155f249
...
b6903af625
| Author | SHA1 | Date | |
|---|---|---|---|
| b6903af625 | |||
| 92f130b3bd | |||
| 1e03227788 | |||
| 7df7fd4c6f | |||
| e2d05d2c33 |
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"scipy>=1.17.1",
|
||||
"pyyaml>=6.0.3",
|
||||
"dtw-python",
|
||||
"cryptography>=41.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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
|
||||
|
||||
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,10 +154,34 @@ Popup {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close ──
|
||||
// ── Buttons ──
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Button {
|
||||
text: "Close"
|
||||
Layout.alignment: Qt.AlignRight
|
||||
Layout.preferredWidth: 100
|
||||
Layout.preferredHeight: 32
|
||||
onClicked: root.close()
|
||||
@@ -143,3 +201,4 @@ Popup {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+433
-102
@@ -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.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: 10
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
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
|
||||
|
||||
ColumnLayout {
|
||||
id: hwActionsCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 8
|
||||
|
||||
StackLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
||||
|
||||
// ── Status tab: Hardware Actions ────────────────
|
||||
ColumnLayout {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Theme.sideFooterConnection
|
||||
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
|
||||
|
||||
// Row 1: Title + status badge (unchanged)
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
Layout.fillWidth: true
|
||||
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
|
||||
Label {
|
||||
text: "CONNECTION FLOW"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.letterSpacing: 1.2
|
||||
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: 6
|
||||
Layout.fillWidth: 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,76 +804,8 @@ Rectangle {
|
||||
anchors.centerIn: parent
|
||||
text: deviceController.connectionStatus || "Disconnected"
|
||||
color: Theme.statusBadgeText
|
||||
font.pixelSize: Theme.font2xs
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+563
-375
File diff suppressed because it is too large
Load Diff
+232
-242
@@ -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,44 +275,97 @@ 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
|
||||
}
|
||||
}
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 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
|
||||
// ── Wafer & Sensor Data: far right, spans both rows ──
|
||||
Rectangle {
|
||||
Layout.rowSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 8
|
||||
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] : "—";
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.columnSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 1
|
||||
color: Theme.cardBorder
|
||||
opacity: 0.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
|
||||
}
|
||||
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
|
||||
@@ -290,38 +379,24 @@ ColumnLayout {
|
||||
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
|
||||
|
||||
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 !== ""
|
||||
width: sessionSavedLabel.implicitWidth + 12
|
||||
height: 18
|
||||
implicitWidth: sessionSavedLabel.implicitWidth + 12
|
||||
implicitHeight: 18
|
||||
radius: 4
|
||||
color: Theme.fieldBackground
|
||||
border.color: Theme.sideBorder
|
||||
@@ -338,17 +413,33 @@ ColumnLayout {
|
||||
font.letterSpacing: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
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: "Save As"
|
||||
text: "Select Folder"
|
||||
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
|
||||
@@ -363,97 +454,25 @@ ColumnLayout {
|
||||
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
|
||||
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: 4
|
||||
columnSpacing: 16
|
||||
|
||||
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]) : "—";
|
||||
}
|
||||
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]) : "—";
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,6 +496,16 @@ ColumnLayout {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,12 +364,17 @@ class DeviceController(QObject):
|
||||
self, port: Optional[str], info: Optional[WaferInfo]
|
||||
) -> None:
|
||||
"""Main-thread completion handler for detectWafer."""
|
||||
try:
|
||||
if info is not None:
|
||||
self._selected_port = port or ""
|
||||
self._set_connection_status("Connected")
|
||||
# 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 = ""
|
||||
@@ -362,6 +382,8 @@ class DeviceController(QObject):
|
||||
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()
|
||||
@@ -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 = []
|
||||
|
||||
try:
|
||||
if is_official_csv(file_path):
|
||||
sensors, records = read_official_csv(file_path)
|
||||
frames = frames_from_wafer_data(None, records)
|
||||
else:
|
||||
try:
|
||||
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)
|
||||
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)
|
||||
if not sensors or not frames:
|
||||
self.loadFileError.emit("No sensors or frames found in data file.")
|
||||
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)
|
||||
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))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -50,6 +50,12 @@ class CsvRecorder:
|
||||
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
|
||||
|
||||
|
||||
@@ -71,11 +71,10 @@ 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
|
||||
|
||||
if row is not None:
|
||||
row["wafer"] = wafer
|
||||
row["date"] = date
|
||||
row["chamber"] = chamber
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+11
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user