Initial implementation
This commit is contained in:
17 files changed
+5435
-4
No files matched your search
@@ -8,3 +8,6 @@ wheels/
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
# Generated visual review captures
|
||||||
|
artifacts/
|
||||||
+11
-2
@@ -1,13 +1,19 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "dynalab-py"
|
name = "dynalab-py"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Add your description here"
|
description = "Cross-platform desktop UI for dynalab-core signal acquisition and playback"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
license-files = ["LICENSE"]
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Hector van der Aa", email = "hector@h3cx.dev" }
|
{ name = "Hector van der Aa", email = "hector@h3cx.dev" }
|
||||||
]
|
]
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
"dynalab-core @ git+https://git.h3cx.dev/Exergie/dynalab-core.git@v0.2.1",
|
||||||
|
"numpy>=2.5.4",
|
||||||
|
"pyqtgraph>=0.14.0",
|
||||||
|
"pyside6>=6.12.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
dynalab-py = "dynalab_py:main"
|
dynalab-py = "dynalab_py:main"
|
||||||
@@ -15,3 +21,6 @@ dynalab-py = "dynalab_py:main"
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.13.0,<0.14.0"]
|
requires = ["uv_build>=0.13.0,<0.14.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[tool.uv.build-backend]
|
||||||
|
source-include = ["API_BOUNDARIES.md", "src/dynalab_py/icons/*.svg"]
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
def main() -> None:
|
"""DynaLab desktop application entry point."""
|
||||||
print("Hello from dynalab-py!")
|
from .app import MainWindow, main
|
||||||
|
|
||||||
|
__all__ = ["MainWindow", "main"]
|
||||||
@@ -0,0 +1,853 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from PySide6.QtCore import QByteArray, QSettings, Qt, QTimer
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QFileDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QMainWindow,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QToolBar,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .core_adapter import CoreAdapter
|
||||||
|
from .data_panel import DataPanel
|
||||||
|
from .derive_panel import DerivePanel
|
||||||
|
from .docking import GridWorkspace, PanelDockWidget
|
||||||
|
from .instrument_group import InstrumentGroupPanel
|
||||||
|
from .panels import GaugePanel, GraphPanel, NumericPanel, SignalBrowser
|
||||||
|
from .session_panel import SessionPanel
|
||||||
|
from .theme import apply_theme
|
||||||
|
from .workspace import (
|
||||||
|
SYSTEM_PANELS,
|
||||||
|
VISUALIZATION_TYPES,
|
||||||
|
WorkspaceError,
|
||||||
|
load_workspace,
|
||||||
|
new_workspace,
|
||||||
|
save_workspace,
|
||||||
|
validate_workspace,
|
||||||
|
)
|
||||||
|
|
||||||
|
PANEL_TITLES = {
|
||||||
|
"numeric": "Numeric readout", "gauge": "Gauge", "graph": "Time graph",
|
||||||
|
"numeric_group": "Readout group", "gauge_group": "Gauge group",
|
||||||
|
**{panel["type"]: panel["title"] for panel in SYSTEM_PANELS},
|
||||||
|
}
|
||||||
|
SYSTEM_IDS = {panel["type"]: panel["id"] for panel in SYSTEM_PANELS}
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.settings = QSettings(
|
||||||
|
QSettings.defaultFormat(), QSettings.Scope.UserScope, "DynaLab", "DynaLab"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.ui_update_hz = max(1, min(60, int(self.settings.value("ui_update_hz", 5))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.ui_update_hz = 5
|
||||||
|
self.workspace: dict[str, Any] | None = None
|
||||||
|
self.workspace_path: Path | None = None
|
||||||
|
self.docks: dict[str, PanelDockWidget] = {}
|
||||||
|
self.grid: GridWorkspace | None = None
|
||||||
|
self._recording = False
|
||||||
|
self.panel_widgets: dict[str, QWidget] = {}
|
||||||
|
self.core_adapter: CoreAdapter | None = None
|
||||||
|
self._core_started = False
|
||||||
|
self._session_ended = False
|
||||||
|
self._core_busy = False
|
||||||
|
self._plot_busy = False
|
||||||
|
self._plot_request_ids: dict[str, list[str]] = {}
|
||||||
|
self._plot_request_mode: str | None = None
|
||||||
|
self._session_mode: str | None = None
|
||||||
|
self._workspace_generation = 0
|
||||||
|
self._plot_request_generation = 0
|
||||||
|
self._available_signal_ids: set[str] = set()
|
||||||
|
self._signal_names: dict[str, str] = {}
|
||||||
|
self._close_after_stop = False
|
||||||
|
self.setWindowTitle("DynaLab")
|
||||||
|
self.resize(1280, 840)
|
||||||
|
self._build_menus()
|
||||||
|
self._build_session_toolbar()
|
||||||
|
self._show_start_screen()
|
||||||
|
self._refresh_timer = QTimer(self)
|
||||||
|
self._refresh_timer.setTimerType(Qt.TimerType.PreciseTimer)
|
||||||
|
self._refresh_timer.timeout.connect(self._refresh_session)
|
||||||
|
self._playhead_timer = QTimer(self)
|
||||||
|
self._playhead_timer.setTimerType(Qt.TimerType.PreciseTimer)
|
||||||
|
self._playhead_timer.timeout.connect(self._refresh_playback_cursors)
|
||||||
|
self.set_ui_update_rate(self.ui_update_hz)
|
||||||
|
self._refresh_timer.start()
|
||||||
|
self._playhead_timer.start()
|
||||||
|
|
||||||
|
def _build_menus(self) -> None:
|
||||||
|
workspace_menu = self.menuBar().addMenu("Workspace")
|
||||||
|
self.new_action = workspace_menu.addAction("New", self._new_workspace, "Ctrl+N")
|
||||||
|
self.open_action = workspace_menu.addAction("Open…", self._open_dialog, "Ctrl+O")
|
||||||
|
self.save_action = workspace_menu.addAction("Save", self._save_workspace, "Ctrl+S")
|
||||||
|
self.save_action.setEnabled(False)
|
||||||
|
self.save_as_action = workspace_menu.addAction("Save As…", self._save_as)
|
||||||
|
self.save_as_action.setEnabled(False)
|
||||||
|
self.view_menu = self.menuBar().addMenu("View")
|
||||||
|
self.add_panel_menu = self.menuBar().addMenu("Add panel")
|
||||||
|
self.add_panel_actions: dict[str, Any] = {}
|
||||||
|
for panel_type, title in PANEL_TITLES.items():
|
||||||
|
action = self.add_panel_menu.addAction(title)
|
||||||
|
action.triggered.connect(lambda _checked=False, kind=panel_type: self.add_panel(kind))
|
||||||
|
self.add_panel_actions[panel_type] = action
|
||||||
|
self.add_panel_menu.setEnabled(False)
|
||||||
|
settings_menu = self.menuBar().addMenu("Settings")
|
||||||
|
settings_menu.addAction("UI update rate…", self._configure_update_rate)
|
||||||
|
|
||||||
|
def set_ui_update_rate(self, hz: int) -> None:
|
||||||
|
self.ui_update_hz = max(1, min(60, int(hz)))
|
||||||
|
interval = round(1000 / self.ui_update_hz)
|
||||||
|
self._refresh_timer.setInterval(interval)
|
||||||
|
self._playhead_timer.setInterval(interval)
|
||||||
|
for panel in self.panel_widgets.values():
|
||||||
|
if isinstance(panel, DataPanel):
|
||||||
|
panel.set_update_interval(interval)
|
||||||
|
self.settings.setValue("ui_update_hz", self.ui_update_hz)
|
||||||
|
|
||||||
|
def _configure_update_rate(self) -> None:
|
||||||
|
dialog = QDialog(self)
|
||||||
|
dialog.setWindowTitle("UI update rate")
|
||||||
|
layout = QVBoxLayout(dialog)
|
||||||
|
explanation = QLabel(
|
||||||
|
"Refresh values, graphs and playback cursors at this rate.\n"
|
||||||
|
"Sampling and playback speed are unchanged. Heavy graphs may refresh more slowly."
|
||||||
|
)
|
||||||
|
explanation.setWordWrap(True)
|
||||||
|
layout.addWidget(explanation)
|
||||||
|
rate = QSpinBox(dialog)
|
||||||
|
rate.setRange(1, 60)
|
||||||
|
rate.setSuffix(" Hz")
|
||||||
|
rate.setAccessibleName("UI update frequency")
|
||||||
|
rate.setValue(self.ui_update_hz)
|
||||||
|
layout.addWidget(rate)
|
||||||
|
buttons = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||||
|
parent=dialog,
|
||||||
|
)
|
||||||
|
buttons.accepted.connect(dialog.accept)
|
||||||
|
buttons.rejected.connect(dialog.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
self.set_ui_update_rate(rate.value())
|
||||||
|
|
||||||
|
def _build_session_toolbar(self) -> None:
|
||||||
|
host, port = CoreAdapter.default_endpoint()
|
||||||
|
toolbar = QToolBar("Session", self)
|
||||||
|
toolbar.setObjectName("sessionToolbar")
|
||||||
|
toolbar.setMovable(False)
|
||||||
|
self.addToolBar(toolbar)
|
||||||
|
brand = QLabel("DynaLab")
|
||||||
|
brand.setObjectName("brand")
|
||||||
|
toolbar.addWidget(brand)
|
||||||
|
toolbar.addWidget(QLabel("Server"))
|
||||||
|
self.host_input = QLineEdit(self.settings.value("core_host", host, type=str))
|
||||||
|
self.host_input.setMaximumWidth(150)
|
||||||
|
self.host_input.setAccessibleName("Core server host")
|
||||||
|
toolbar.addWidget(self.host_input)
|
||||||
|
self.port_input = QSpinBox()
|
||||||
|
self.port_input.setMinimumWidth(85)
|
||||||
|
self.port_input.setRange(1, 65535)
|
||||||
|
self.port_input.setValue(self.settings.value("core_port", port, type=int))
|
||||||
|
self.port_input.setAccessibleName("Core server port")
|
||||||
|
toolbar.addWidget(self.port_input)
|
||||||
|
self.start_button = QPushButton("Start session")
|
||||||
|
self.start_button.setObjectName("primary")
|
||||||
|
self.start_button.clicked.connect(self._start_core)
|
||||||
|
toolbar.addWidget(self.start_button)
|
||||||
|
self.stop_button = QPushButton("Stop session")
|
||||||
|
self.stop_button.clicked.connect(self._stop_core)
|
||||||
|
toolbar.addWidget(self.stop_button)
|
||||||
|
self.mode_selector = QComboBox()
|
||||||
|
for label, mode in (("Realtime", "realtime"), ("Offline", "offline"), ("Playback", "playback")):
|
||||||
|
self.mode_selector.addItem(label, mode)
|
||||||
|
self.mode_selector.setAccessibleName("Session mode")
|
||||||
|
self.mode_selector.currentIndexChanged.connect(self._change_mode)
|
||||||
|
toolbar.addSeparator()
|
||||||
|
toolbar.addWidget(QLabel("Mode"))
|
||||||
|
toolbar.addWidget(self.mode_selector)
|
||||||
|
self.session_status = QLabel("Core stopped")
|
||||||
|
self.session_status.setObjectName("sessionStatus")
|
||||||
|
toolbar.addWidget(self.session_status)
|
||||||
|
self._update_session_controls()
|
||||||
|
|
||||||
|
def _session_panel_busy(self) -> bool:
|
||||||
|
return any(
|
||||||
|
isinstance(panel, (SessionPanel, DataPanel, DerivePanel)) and panel.busy
|
||||||
|
for panel in self.panel_widgets.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_session_controls(self) -> None:
|
||||||
|
editable = not self._core_busy and not self._session_panel_busy()
|
||||||
|
self.new_action.setEnabled(editable)
|
||||||
|
self.open_action.setEnabled(editable)
|
||||||
|
available = self.core_adapter is None and not self._core_busy and not self._session_ended
|
||||||
|
can_stop = (self.core_adapter is not None and not self._core_busy
|
||||||
|
and not self._session_panel_busy())
|
||||||
|
running = can_stop and self._core_started
|
||||||
|
self.host_input.setEnabled(available)
|
||||||
|
self.port_input.setEnabled(available)
|
||||||
|
self.start_button.setEnabled(available)
|
||||||
|
self.stop_button.setEnabled(can_stop)
|
||||||
|
self.mode_selector.setEnabled(running and not self._recording)
|
||||||
|
operation_panels = [p for p in self.panel_widgets.values() if isinstance(p, (SessionPanel, DataPanel, DerivePanel))]
|
||||||
|
for panel in operation_panels:
|
||||||
|
panel.setEnabled(not self._core_busy and not any(other.busy for other in operation_panels if other is not panel))
|
||||||
|
if self.grid is not None:
|
||||||
|
dock = next((self.docks[panel_id] for panel_id, widget in self.panel_widgets.items() if widget is panel), None)
|
||||||
|
if dock is not None:
|
||||||
|
self.grid.fit_panel(dock)
|
||||||
|
|
||||||
|
def _start_core(self) -> None:
|
||||||
|
if self.core_adapter is not None or self._core_busy or self._session_ended:
|
||||||
|
return
|
||||||
|
host = self.host_input.text().strip()
|
||||||
|
try:
|
||||||
|
if not host:
|
||||||
|
raise ValueError("Enter a server host before starting Core.")
|
||||||
|
port = self.port_input.value()
|
||||||
|
self.core_adapter = CoreAdapter(host=host, port=port)
|
||||||
|
except Exception as error:
|
||||||
|
QMessageBox.warning(self, "Could not configure Core", str(error))
|
||||||
|
return
|
||||||
|
self.settings.setValue("core_host", host)
|
||||||
|
self.settings.setValue("core_port", port)
|
||||||
|
self._core_busy = True
|
||||||
|
self.session_status.setText("Starting Core…")
|
||||||
|
self._update_session_controls()
|
||||||
|
self.core_adapter.run_async(self.core_adapter.start, self._on_core_started)
|
||||||
|
|
||||||
|
def _on_core_started(self, _result: Any, error: Exception | None) -> None:
|
||||||
|
self._core_busy = False
|
||||||
|
if error is not None:
|
||||||
|
self._core_started = False
|
||||||
|
self.session_status.setText("Core failed to start")
|
||||||
|
self.statusBar().showMessage(f"Core start failed: {error}")
|
||||||
|
QMessageBox.warning(self, "Could not start Core", str(error))
|
||||||
|
else:
|
||||||
|
self._core_started = True
|
||||||
|
self.statusBar().showMessage("Core session started", 4000)
|
||||||
|
self._refresh_session()
|
||||||
|
self._update_session_controls()
|
||||||
|
if self._close_after_stop:
|
||||||
|
self._stop_core()
|
||||||
|
|
||||||
|
def _stop_core(self) -> None:
|
||||||
|
if self.core_adapter is None or self._core_busy or self._session_panel_busy():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
recording = self.core_adapter.snapshot()["recording"]
|
||||||
|
except Exception as error:
|
||||||
|
if QMessageBox.question(
|
||||||
|
self, "Recording state unavailable",
|
||||||
|
f"Could not confirm recording state: {error}\nEnd the session anyway? Any unsaved data will be lost.",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No,
|
||||||
|
) != QMessageBox.StandardButton.Yes:
|
||||||
|
self._close_after_stop = False
|
||||||
|
return
|
||||||
|
recording = False
|
||||||
|
if recording and QMessageBox.question(
|
||||||
|
self, "Stop active recording?",
|
||||||
|
"A recording is active. Stop recording and end the session? Save the recording before ending the session if you need to keep it.",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No,
|
||||||
|
) != QMessageBox.StandardButton.Yes:
|
||||||
|
self._close_after_stop = False
|
||||||
|
return
|
||||||
|
self._workspace_generation += 1
|
||||||
|
self._core_busy = True
|
||||||
|
self._refresh_playback_cursors()
|
||||||
|
self.session_status.setText("Stopping Core…")
|
||||||
|
self._update_session_controls()
|
||||||
|
self.core_adapter.run_async(self.core_adapter.stop, self._on_core_stopped)
|
||||||
|
|
||||||
|
def _on_core_stopped(self, stopped: Any, error: Exception | None) -> None:
|
||||||
|
self._core_busy = False
|
||||||
|
self._core_started = False
|
||||||
|
self._session_ended = True
|
||||||
|
self.core_adapter = None
|
||||||
|
if error is not None:
|
||||||
|
message = f"Core shutdown failed: {error}"
|
||||||
|
elif not stopped:
|
||||||
|
message = "Core did not stop completely. Restart the application before using it again."
|
||||||
|
else:
|
||||||
|
message = "Core session stopped. Restart the application to start another session."
|
||||||
|
self.session_status.setText(message)
|
||||||
|
self.statusBar().showMessage(message)
|
||||||
|
self._refresh_session()
|
||||||
|
self._update_session_controls()
|
||||||
|
if error is not None or not stopped:
|
||||||
|
QMessageBox.warning(self, "Core shutdown incomplete", message)
|
||||||
|
if self._close_after_stop:
|
||||||
|
self._close_after_stop = False
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _change_mode(self, index: int) -> None:
|
||||||
|
if self.core_adapter is None or self._core_busy or self._session_panel_busy() or index < 0:
|
||||||
|
return
|
||||||
|
mode = self.mode_selector.itemData(index)
|
||||||
|
self._workspace_generation += 1
|
||||||
|
self._core_busy = True
|
||||||
|
self._refresh_playback_cursors()
|
||||||
|
self._update_session_controls()
|
||||||
|
adapter = self.core_adapter
|
||||||
|
adapter.run_async(lambda: adapter.set_mode(mode), self._on_mode_changed)
|
||||||
|
|
||||||
|
def _on_mode_changed(self, _result: Any, error: Exception | None) -> None:
|
||||||
|
self._core_busy = False
|
||||||
|
if error is not None:
|
||||||
|
self.statusBar().showMessage(f"Mode change failed: {error}")
|
||||||
|
QMessageBox.warning(self, "Could not change mode", str(error))
|
||||||
|
self._refresh_session()
|
||||||
|
self._update_session_controls()
|
||||||
|
if self._close_after_stop:
|
||||||
|
self._stop_core()
|
||||||
|
|
||||||
|
def _refresh_session(self) -> None:
|
||||||
|
session_panel = self.panel_widgets.get("dashboard")
|
||||||
|
data_panel = self.panel_widgets.get("data")
|
||||||
|
derive_panel = self.panel_widgets.get("derived")
|
||||||
|
for panel in (session_panel, data_panel, derive_panel):
|
||||||
|
if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
|
||||||
|
panel.set_adapter(self.core_adapter)
|
||||||
|
if self.core_adapter is None:
|
||||||
|
self._session_mode = None
|
||||||
|
for panel in (session_panel, data_panel, derive_panel):
|
||||||
|
if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
|
||||||
|
panel.refresh(None)
|
||||||
|
if "dashboard" in self.docks:
|
||||||
|
connector_list = self.docks["dashboard"].findChild(QListWidget)
|
||||||
|
if connector_list is not None:
|
||||||
|
connector_list.clear()
|
||||||
|
connector_list.addItem("Start a Core session to view connectors")
|
||||||
|
if "signals" in self.panel_widgets:
|
||||||
|
self.panel_widgets["signals"].refresh(None)
|
||||||
|
self._clear_visualizations()
|
||||||
|
return
|
||||||
|
if self._core_busy:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
snapshot = self.core_adapter.snapshot()
|
||||||
|
except Exception as error:
|
||||||
|
self._session_mode = None
|
||||||
|
self._clear_visualizations()
|
||||||
|
for panel in (session_panel, data_panel, derive_panel):
|
||||||
|
if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
|
||||||
|
panel.refresh(None)
|
||||||
|
self._core_started = False
|
||||||
|
self._update_session_controls()
|
||||||
|
self.session_status.setText("Core status unavailable")
|
||||||
|
self.statusBar().showMessage(f"Could not read Core status: {error}")
|
||||||
|
return
|
||||||
|
unit_workers = {unit["worker"]["name"] for unit in snapshot.get("derive_units", [])}
|
||||||
|
fatal_workers = [worker for worker in snapshot.get("workers", [])
|
||||||
|
if worker["state"] == "failed" and worker["name"] not in unit_workers]
|
||||||
|
self._core_started = snapshot["state"] == "started" and not fatal_workers
|
||||||
|
snapshot["session_usable"] = self._core_started
|
||||||
|
if snapshot["state"] in {"stopped", "stop_incomplete"}:
|
||||||
|
self._session_ended = True
|
||||||
|
|
||||||
|
for panel in (session_panel, data_panel, derive_panel):
|
||||||
|
if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
|
||||||
|
panel.refresh(snapshot)
|
||||||
|
self._update_session_controls()
|
||||||
|
if self._close_after_stop and not self._session_panel_busy():
|
||||||
|
self._stop_core()
|
||||||
|
return
|
||||||
|
if not self._core_started:
|
||||||
|
self._clear_visualizations()
|
||||||
|
details = "; ".join(f"{worker['name']}: {worker.get('error') or 'failed'}" for worker in fatal_workers)
|
||||||
|
message = ("Session worker failed" if fatal_workers else f"Core {snapshot['state']}") + ". Restart the application."
|
||||||
|
self.session_status.setText(message)
|
||||||
|
self.statusBar().showMessage(message + (f" {details}" if details else ""))
|
||||||
|
return
|
||||||
|
self._recording = snapshot["recording"]
|
||||||
|
self._update_session_controls()
|
||||||
|
mode = snapshot["mode"]
|
||||||
|
self._session_mode = mode
|
||||||
|
mode_index = self.mode_selector.findData(mode)
|
||||||
|
if mode_index >= 0 and mode_index != self.mode_selector.currentIndex():
|
||||||
|
self.mode_selector.blockSignals(True)
|
||||||
|
self.mode_selector.setCurrentIndex(mode_index)
|
||||||
|
self.mode_selector.blockSignals(False)
|
||||||
|
connectors = snapshot["connectors"]
|
||||||
|
signals = snapshot["signals"]
|
||||||
|
self._available_signal_ids = {signal["id"] for signal in signals}
|
||||||
|
self.session_status.setText(
|
||||||
|
f"Core {snapshot['state']} · "
|
||||||
|
f"{'recording' if snapshot['recording'] else 'idle'}"
|
||||||
|
)
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"Core {snapshot['state']} · {mode} · {len(connectors)} connectors · {len(signals)} signals"
|
||||||
|
)
|
||||||
|
if "dashboard" in self.docks:
|
||||||
|
connector_list = self.docks["dashboard"].findChild(QListWidget)
|
||||||
|
if connector_list is not None:
|
||||||
|
connector_list.clear()
|
||||||
|
if connectors:
|
||||||
|
for connector in connectors:
|
||||||
|
connector_list.addItem(
|
||||||
|
f"{connector['name']} — {connector['state']} — "
|
||||||
|
f"{connector['signal_count']} signals"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
connector_list.addItem("No connectors connected")
|
||||||
|
if "signals" in self.panel_widgets:
|
||||||
|
self.panel_widgets["signals"].refresh(signals)
|
||||||
|
self._refresh_visualizations(signals, mode)
|
||||||
|
|
||||||
|
def _refresh_visualizations(self, signals: list[dict[str, Any]], mode: str) -> None:
|
||||||
|
for panel_id, widget in list(self.panel_widgets.items()):
|
||||||
|
if isinstance(widget, (NumericPanel, GaugePanel, InstrumentGroupPanel)):
|
||||||
|
widget.refresh(signals)
|
||||||
|
self.grid.fit_panel(self.docks[panel_id])
|
||||||
|
|
||||||
|
available_ids = {signal["id"] for signal in signals}
|
||||||
|
visible_graphs: dict[str, list[str]] = {}
|
||||||
|
union_ids: list[str] = []
|
||||||
|
for panel_id, widget in list(self.panel_widgets.items()):
|
||||||
|
if not isinstance(widget, GraphPanel):
|
||||||
|
continue
|
||||||
|
widget.set_signals(signals)
|
||||||
|
selected = [signal_id for signal_id in widget.signal_ids if signal_id in available_ids]
|
||||||
|
dock = self.docks.get(panel_id)
|
||||||
|
if not selected:
|
||||||
|
widget.update_plot({"time_range": None, "data": {}})
|
||||||
|
elif dock is not None and dock.isVisible():
|
||||||
|
visible_graphs[panel_id] = selected
|
||||||
|
for signal_id in selected:
|
||||||
|
if signal_id not in union_ids:
|
||||||
|
union_ids.append(signal_id)
|
||||||
|
|
||||||
|
self._update_panel_titles(signals)
|
||||||
|
if not union_ids or self._plot_busy or self.core_adapter is None:
|
||||||
|
return
|
||||||
|
self._plot_busy = True
|
||||||
|
self._plot_request_ids = {panel_id: list(ids) for panel_id, ids in visible_graphs.items()}
|
||||||
|
self._plot_request_mode = mode
|
||||||
|
self._plot_request_generation = self._workspace_generation
|
||||||
|
self._update_session_controls()
|
||||||
|
adapter = self.core_adapter
|
||||||
|
adapter.run_async(
|
||||||
|
lambda: adapter.plot_snapshot(union_ids),
|
||||||
|
self._on_graph_updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clear_visualizations(self) -> None:
|
||||||
|
self._available_signal_ids.clear()
|
||||||
|
browser = self.panel_widgets.get("signals")
|
||||||
|
if browser is not None:
|
||||||
|
browser.refresh(None)
|
||||||
|
for widget in list(self.panel_widgets.values()):
|
||||||
|
if isinstance(widget, (NumericPanel, GaugePanel, InstrumentGroupPanel)):
|
||||||
|
widget.refresh([])
|
||||||
|
elif isinstance(widget, GraphPanel):
|
||||||
|
widget.set_signals([])
|
||||||
|
widget.update_plot({"time_range": None, "data": {}})
|
||||||
|
self._update_panel_titles([])
|
||||||
|
self._refresh_playback_cursors()
|
||||||
|
|
||||||
|
def _refresh_playback_cursors(self) -> None:
|
||||||
|
graphs = [widget for widget in self.panel_widgets.values() if isinstance(widget, GraphPanel)]
|
||||||
|
if not graphs:
|
||||||
|
return
|
||||||
|
position = None
|
||||||
|
if self.core_adapter is not None and self._core_started and not self._core_busy and self._session_mode == "playback":
|
||||||
|
try:
|
||||||
|
state = self.core_adapter.playback_status()
|
||||||
|
if state["time_range"] is not None:
|
||||||
|
position = int(state["position"])
|
||||||
|
except Exception:
|
||||||
|
# A failed status read must not leave a stale playhead visible.
|
||||||
|
position = None
|
||||||
|
for graph in graphs:
|
||||||
|
graph.set_playback_position(position)
|
||||||
|
|
||||||
|
def _update_panel_titles(self, signals: list[dict[str, Any]]) -> None:
|
||||||
|
self._signal_names.update({signal["id"]: signal["name"] for signal in signals})
|
||||||
|
for panel_id, widget in self.panel_widgets.items():
|
||||||
|
if isinstance(widget, (NumericPanel, GaugePanel)):
|
||||||
|
selected = [widget.choice.signal_id] if widget.choice.signal_id else []
|
||||||
|
elif isinstance(widget, GraphPanel):
|
||||||
|
selected = widget.signal_ids
|
||||||
|
elif isinstance(widget, InstrumentGroupPanel):
|
||||||
|
selected = [item["signal_id"] for item in widget.config()["items"] if item["signal_id"]]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
title = " · ".join(dict.fromkeys(self._signal_names.get(signal_id, "Unavailable signal") for signal_id in selected))
|
||||||
|
self.docks[panel_id].set_display_title(title or "Unassigned")
|
||||||
|
|
||||||
|
def _on_graph_updated(self, snapshot: Any, error: Exception | None) -> None:
|
||||||
|
self._plot_busy = False
|
||||||
|
current_request = (
|
||||||
|
self.core_adapter is not None
|
||||||
|
and self._workspace_generation == self._plot_request_generation
|
||||||
|
and self._session_mode == self._plot_request_mode
|
||||||
|
)
|
||||||
|
if error is not None and current_request:
|
||||||
|
self.statusBar().showMessage(f"Could not update graph: {error}")
|
||||||
|
elif (
|
||||||
|
snapshot is not None
|
||||||
|
and self.core_adapter is not None
|
||||||
|
and self._session_mode == self._plot_request_mode
|
||||||
|
and self._workspace_generation == self._plot_request_generation
|
||||||
|
):
|
||||||
|
for panel_id, requested_ids in self._plot_request_ids.items():
|
||||||
|
graph = self.panel_widgets.get(panel_id)
|
||||||
|
dock = self.docks.get(panel_id)
|
||||||
|
if not isinstance(graph, GraphPanel) or dock is None or not dock.isVisible():
|
||||||
|
continue
|
||||||
|
current = [signal_id for signal_id in graph.signal_ids if signal_id in self._available_signal_ids]
|
||||||
|
if current == requested_ids:
|
||||||
|
graph.update_plot(
|
||||||
|
{
|
||||||
|
**snapshot,
|
||||||
|
"data": {signal_id: snapshot.get("data", {}).get(signal_id, []) for signal_id in current},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._plot_request_ids = {}
|
||||||
|
self._plot_request_mode = None
|
||||||
|
self._update_session_controls()
|
||||||
|
if self._close_after_stop:
|
||||||
|
self._stop_core()
|
||||||
|
|
||||||
|
def closeEvent(self, event: Any) -> None:
|
||||||
|
if self.core_adapter is None:
|
||||||
|
for dock in self.docks.values():
|
||||||
|
if dock.isFloating():
|
||||||
|
dock.hide()
|
||||||
|
event.accept()
|
||||||
|
return
|
||||||
|
event.ignore()
|
||||||
|
self._close_after_stop = True
|
||||||
|
if not self._core_busy:
|
||||||
|
self._stop_core()
|
||||||
|
|
||||||
|
def _show_start_screen(self) -> None:
|
||||||
|
self.save_action.setEnabled(False)
|
||||||
|
self.save_as_action.setEnabled(False)
|
||||||
|
self.add_panel_menu.setEnabled(False)
|
||||||
|
page = QWidget()
|
||||||
|
outer = QVBoxLayout(page)
|
||||||
|
outer.setContentsMargins(60, 40, 60, 40)
|
||||||
|
outer.addStretch(1)
|
||||||
|
card = QWidget()
|
||||||
|
card.setMaximumWidth(760)
|
||||||
|
layout = QVBoxLayout(card)
|
||||||
|
layout.setSpacing(18)
|
||||||
|
eyebrow = QLabel("DYNALAB / SIGNAL WORKSPACE")
|
||||||
|
eyebrow.setObjectName("eyebrow")
|
||||||
|
layout.addWidget(eyebrow)
|
||||||
|
title = QLabel("Your signals. In focus.")
|
||||||
|
title.setObjectName("heroTitle")
|
||||||
|
layout.addWidget(title)
|
||||||
|
subtitle = QLabel("Acquire, explore and replay your measurements in one workspace.")
|
||||||
|
subtitle.setObjectName("muted")
|
||||||
|
subtitle.setWordWrap(True)
|
||||||
|
layout.addWidget(subtitle)
|
||||||
|
actions = QHBoxLayout()
|
||||||
|
new_button = QPushButton("Create workspace")
|
||||||
|
new_button.setObjectName("primary")
|
||||||
|
new_button.clicked.connect(self._new_workspace)
|
||||||
|
actions.addWidget(new_button)
|
||||||
|
open_button = QPushButton("Open workspace…")
|
||||||
|
open_button.clicked.connect(self._open_dialog)
|
||||||
|
actions.addWidget(open_button)
|
||||||
|
actions.addStretch()
|
||||||
|
layout.addLayout(actions)
|
||||||
|
layout.addSpacing(16)
|
||||||
|
layout.addWidget(QLabel("Recent workspaces"))
|
||||||
|
recent = QListWidget()
|
||||||
|
recent.setMinimumHeight(150)
|
||||||
|
paths = self._recent_workspaces()
|
||||||
|
for path in paths:
|
||||||
|
recent.addItem(path)
|
||||||
|
if not paths:
|
||||||
|
recent.addItem("No recent workspaces. Create one to get started.")
|
||||||
|
recent.item(0).setFlags(Qt.ItemFlag.NoItemFlags)
|
||||||
|
recent.itemDoubleClicked.connect(lambda item: self._open_path(item.text()))
|
||||||
|
layout.addWidget(recent)
|
||||||
|
outer.addWidget(card, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||||
|
outer.addStretch(2)
|
||||||
|
self.setCentralWidget(page)
|
||||||
|
|
||||||
|
def _recent_workspaces(self) -> list[str]:
|
||||||
|
paths = self.settings.value("recent_workspaces", [], type=list)
|
||||||
|
existing = [str(Path(path)) for path in paths if Path(path).is_file()]
|
||||||
|
if existing != paths:
|
||||||
|
self.settings.setValue("recent_workspaces", existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
def _remember_workspace(self, path: Path) -> None:
|
||||||
|
recent = [str(path), *[p for p in self._recent_workspaces() if p != str(path)]]
|
||||||
|
self.settings.setValue("recent_workspaces", recent[:10])
|
||||||
|
|
||||||
|
def _new_workspace(self) -> None:
|
||||||
|
self._apply_workspace(new_workspace(), None)
|
||||||
|
|
||||||
|
def _open_dialog(self) -> None:
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, "Open workspace", "", "DynaLab workspace (*.json);;All files (*)"
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self._open_path(path)
|
||||||
|
|
||||||
|
def _open_path(self, path: str) -> None:
|
||||||
|
previous, previous_path = self.workspace, self.workspace_path
|
||||||
|
if previous is not None:
|
||||||
|
previous["grid_layout"] = self.grid.save_layout()
|
||||||
|
previous["geometry"] = self.saveGeometry().toBase64().data().decode("ascii")
|
||||||
|
for panel in previous["panels"]:
|
||||||
|
panel["visible"] = self.docks[panel["id"]].user_visible
|
||||||
|
try:
|
||||||
|
data = load_workspace(path)
|
||||||
|
self._apply_workspace(data, Path(path))
|
||||||
|
except (WorkspaceError, OSError, ValueError) as error:
|
||||||
|
if self.workspace is not previous:
|
||||||
|
if previous is None:
|
||||||
|
self._remove_docks()
|
||||||
|
self.workspace = self.workspace_path = None
|
||||||
|
self._show_start_screen()
|
||||||
|
else:
|
||||||
|
self._apply_workspace(previous, previous_path)
|
||||||
|
QMessageBox.warning(self, "Could not open workspace", str(error))
|
||||||
|
|
||||||
|
def _save_workspace(self) -> None:
|
||||||
|
if self.workspace_path is None:
|
||||||
|
self._save_as()
|
||||||
|
return
|
||||||
|
self._save_to(self.workspace_path)
|
||||||
|
|
||||||
|
def _save_as(self) -> None:
|
||||||
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self, "Save workspace", "workspace.json", "DynaLab workspace (*.json)"
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
target = Path(path)
|
||||||
|
if target.suffix.lower() != ".json":
|
||||||
|
target = target.with_suffix(".json")
|
||||||
|
self._save_to(target)
|
||||||
|
|
||||||
|
def _save_to(self, path: Path) -> None:
|
||||||
|
if self.workspace is None:
|
||||||
|
return
|
||||||
|
self.workspace["grid_layout"] = self.grid.save_layout()
|
||||||
|
self.workspace["geometry"] = self.saveGeometry().toBase64().data().decode("ascii")
|
||||||
|
for panel in self.workspace["panels"]:
|
||||||
|
panel["visible"] = self.docks[panel["id"]].user_visible
|
||||||
|
try:
|
||||||
|
save_workspace(path, self.workspace)
|
||||||
|
except (OSError, WorkspaceError) as error:
|
||||||
|
QMessageBox.warning(self, "Could not save workspace", str(error))
|
||||||
|
return
|
||||||
|
self.workspace_path = path
|
||||||
|
self._remember_workspace(path)
|
||||||
|
self._update_title()
|
||||||
|
self.statusBar().showMessage(f"Saved {path}", 4000)
|
||||||
|
|
||||||
|
def _apply_workspace(self, data: dict[str, Any], path: Path | None) -> None:
|
||||||
|
validate_workspace(data)
|
||||||
|
panels = data["panels"]
|
||||||
|
self._workspace_generation += 1
|
||||||
|
self._signal_names.clear()
|
||||||
|
self._remove_docks()
|
||||||
|
self.workspace = data
|
||||||
|
self.workspace_path = path
|
||||||
|
self.grid = GridWorkspace(self)
|
||||||
|
self.setCentralWidget(self.grid)
|
||||||
|
geometry = data.get("geometry")
|
||||||
|
if geometry:
|
||||||
|
if not self.restoreGeometry(QByteArray.fromBase64(geometry.encode("ascii"))):
|
||||||
|
raise WorkspaceError("Workspace window geometry could not be restored.")
|
||||||
|
self.save_action.setEnabled(True)
|
||||||
|
self.save_as_action.setEnabled(True)
|
||||||
|
self.add_panel_menu.setEnabled(True)
|
||||||
|
self.view_menu.clear()
|
||||||
|
for panel in panels:
|
||||||
|
self._create_panel_dock(panel)
|
||||||
|
self._refresh_view_menu()
|
||||||
|
|
||||||
|
if data.get("grid_layout") is not None:
|
||||||
|
self.grid.restore_layout(data["grid_layout"], self.docks)
|
||||||
|
for panel in panels:
|
||||||
|
if not panel.get("visible", True):
|
||||||
|
self.grid.set_panel_visible(self.docks[panel["id"]], False)
|
||||||
|
self._update_title()
|
||||||
|
if path is not None:
|
||||||
|
self._remember_workspace(path)
|
||||||
|
self._refresh_session()
|
||||||
|
|
||||||
|
def _create_panel_dock(self, panel: dict[str, Any]) -> PanelDockWidget:
|
||||||
|
panel_id = panel["id"]
|
||||||
|
widget = self._panel_content(panel)
|
||||||
|
if isinstance(widget, DataPanel):
|
||||||
|
widget.set_update_interval(round(1000 / self.ui_update_hz))
|
||||||
|
widget.setObjectName("panelBody")
|
||||||
|
widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||||
|
dock = PanelDockWidget(
|
||||||
|
panel_id,
|
||||||
|
panel["title"],
|
||||||
|
self,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
dock.setObjectName(panel_id)
|
||||||
|
dock.removeRequested.connect(
|
||||||
|
lambda current_id=panel_id, current_dock=dock: self.remove_panel(current_id)
|
||||||
|
if self.docks.get(current_id) is current_dock
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
dock.setWidget(widget)
|
||||||
|
self.docks[panel_id] = dock
|
||||||
|
self.panel_widgets[panel_id] = widget
|
||||||
|
if isinstance(widget, (SessionPanel, DataPanel, DerivePanel)):
|
||||||
|
widget.busyChanged.connect(lambda _busy: self._update_session_controls())
|
||||||
|
if isinstance(widget, (NumericPanel, GaugePanel, GraphPanel, InstrumentGroupPanel)):
|
||||||
|
widget.removeRequested.connect(
|
||||||
|
lambda current_id=panel_id, current_widget=widget: self.remove_panel(current_id)
|
||||||
|
if self.panel_widgets.get(current_id) is current_widget
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if hasattr(widget, "configChanged"):
|
||||||
|
widget.configChanged.connect(
|
||||||
|
lambda config, current_id=panel_id, current_widget=widget: self._panel_config_changed(current_id, config)
|
||||||
|
if self.panel_widgets.get(current_id) is current_widget
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self.grid.add_panel(dock, panel["type"])
|
||||||
|
return dock
|
||||||
|
|
||||||
|
def _refresh_view_menu(self) -> None:
|
||||||
|
self.view_menu.clear()
|
||||||
|
if self.workspace is None:
|
||||||
|
return
|
||||||
|
for panel in self.workspace["panels"]:
|
||||||
|
dock = self.docks.get(panel["id"])
|
||||||
|
if dock is not None:
|
||||||
|
self.view_menu.addAction(dock.toggleViewAction())
|
||||||
|
|
||||||
|
def add_panel(self, panel_type: str) -> str:
|
||||||
|
if self.workspace is None:
|
||||||
|
raise RuntimeError("Create or open a workspace before adding a panel.")
|
||||||
|
if panel_type not in PANEL_TITLES:
|
||||||
|
raise ValueError(f"Unsupported panel type: {panel_type!r}")
|
||||||
|
panel_id = SYSTEM_IDS.get(panel_type, f"{panel_type}-{uuid4()}")
|
||||||
|
if panel_id in self.docks:
|
||||||
|
self.grid.set_panel_visible(self.docks[panel_id], True)
|
||||||
|
self.docks[panel_id].raise_()
|
||||||
|
return panel_id
|
||||||
|
existing_titles = {panel["title"] for panel in self.workspace["panels"]}
|
||||||
|
number = 1
|
||||||
|
while f"{PANEL_TITLES[panel_type]} {number}" in existing_titles:
|
||||||
|
number += 1
|
||||||
|
if panel_type == "graph":
|
||||||
|
config = {"signal_ids": [], "follow_latest": True, "zero_baseline": False, "right_zero_baseline": False}
|
||||||
|
elif panel_type.endswith("_group"):
|
||||||
|
config = {"kind": panel_type.removesuffix("_group"), "items": []}
|
||||||
|
elif panel_type in VISUALIZATION_TYPES:
|
||||||
|
config = {"signal_id": None, "decimals": 2}
|
||||||
|
else:
|
||||||
|
config = {}
|
||||||
|
panel = {
|
||||||
|
"id": panel_id,
|
||||||
|
"title": PANEL_TITLES[panel_type] if panel_type in SYSTEM_IDS else f"{PANEL_TITLES[panel_type]} {number}",
|
||||||
|
"type": panel_type,
|
||||||
|
"config": config,
|
||||||
|
"visible": True,
|
||||||
|
}
|
||||||
|
self.workspace["panels"].append(panel)
|
||||||
|
dock = self._create_panel_dock(panel)
|
||||||
|
self._refresh_view_menu()
|
||||||
|
self._refresh_session()
|
||||||
|
self.grid.ensureWidgetVisible(dock, 16, 16)
|
||||||
|
return panel_id
|
||||||
|
|
||||||
|
def remove_panel(self, panel_id: str) -> bool:
|
||||||
|
if self.workspace is None:
|
||||||
|
return False
|
||||||
|
panel = next((item for item in self.workspace["panels"] if item["id"] == panel_id), None)
|
||||||
|
if panel is None:
|
||||||
|
return False
|
||||||
|
widget = self.panel_widgets.get(panel_id)
|
||||||
|
if isinstance(widget, (SessionPanel, DataPanel, DerivePanel)) and widget.busy:
|
||||||
|
self.statusBar().showMessage("Wait for the panel's current operation before removing it.", 5000)
|
||||||
|
return False
|
||||||
|
self.workspace["panels"].remove(panel)
|
||||||
|
widget = self.panel_widgets.pop(panel_id, None)
|
||||||
|
dock = self.docks.pop(panel_id, None)
|
||||||
|
if dock is not None:
|
||||||
|
self.grid.remove_panel(dock)
|
||||||
|
dock.hide()
|
||||||
|
if widget is not None:
|
||||||
|
widget.setParent(None)
|
||||||
|
widget.deleteLater()
|
||||||
|
dock.setParent(None)
|
||||||
|
dock.deleteLater()
|
||||||
|
self._refresh_view_menu()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _panel_content(panel: dict[str, Any]) -> QWidget:
|
||||||
|
if panel["type"] == "dashboard":
|
||||||
|
return SessionPanel()
|
||||||
|
if panel["type"] == "data":
|
||||||
|
return DataPanel(panel.get("config", {}))
|
||||||
|
if panel["type"] == "derive_manager":
|
||||||
|
return DerivePanel(panel.get("config", {}))
|
||||||
|
if panel["type"] == "signal_browser":
|
||||||
|
return SignalBrowser()
|
||||||
|
if panel["type"] in {"numeric_group", "gauge_group"}:
|
||||||
|
return InstrumentGroupPanel({"kind": panel["type"].removesuffix("_group"), **panel.get("config", {})})
|
||||||
|
if panel["type"] == "numeric":
|
||||||
|
return NumericPanel(panel.get("config", {}))
|
||||||
|
if panel["type"] == "gauge":
|
||||||
|
return GaugePanel(panel.get("config", {}))
|
||||||
|
if panel["type"] == "graph":
|
||||||
|
return GraphPanel(panel.get("config", {}))
|
||||||
|
return QLabel(f"{panel['title']} panel\nNot configured yet")
|
||||||
|
|
||||||
|
def _panel_config_changed(self, panel_id: str, config: dict[str, Any]) -> None:
|
||||||
|
if self.workspace is not None:
|
||||||
|
panel = next((p for p in self.workspace["panels"] if p["id"] == panel_id), None)
|
||||||
|
if panel is not None:
|
||||||
|
panel["config"] = config
|
||||||
|
self._update_panel_titles([])
|
||||||
|
|
||||||
|
def _remove_docks(self) -> None:
|
||||||
|
self.view_menu.clear()
|
||||||
|
self.add_panel_menu.setEnabled(False)
|
||||||
|
for dock in self.docks.values():
|
||||||
|
self.grid.remove_panel(dock)
|
||||||
|
dock.hide()
|
||||||
|
dock.setParent(None)
|
||||||
|
dock.deleteLater()
|
||||||
|
self.docks = {}
|
||||||
|
self.panel_widgets = {}
|
||||||
|
|
||||||
|
def _update_title(self) -> None:
|
||||||
|
name = self.workspace_path.name if self.workspace_path else "Untitled workspace"
|
||||||
|
self.setWindowTitle(f"DynaLab — {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app = QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
window = MainWindow()
|
||||||
|
window.show()
|
||||||
|
app.exec()
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
from math import ceil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from dynalab_core import Core, CoreConfig
|
||||||
|
from dynalab_core.errors import CoreError
|
||||||
|
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||||
|
|
||||||
|
|
||||||
|
class _CallSignals(QObject):
|
||||||
|
finished = Signal(object, object)
|
||||||
|
|
||||||
|
|
||||||
|
class _Call(QRunnable):
|
||||||
|
def __init__(self, method: Callable[[], Any]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.method = method
|
||||||
|
self.signals = _CallSignals()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
self.signals.finished.emit(self.method(), None)
|
||||||
|
except Exception as error:
|
||||||
|
self.signals.finished.emit(None, error)
|
||||||
|
|
||||||
|
|
||||||
|
class CoreAdapter:
|
||||||
|
"""Own the Core instance and keep its potentially blocking calls off Qt."""
|
||||||
|
|
||||||
|
MAX_DISPLAY_POINTS = 1500
|
||||||
|
|
||||||
|
def __init__(self, host: str | None = None, port: int | None = None) -> None:
|
||||||
|
config: dict[str, Any] = {"playback_mode": "read"}
|
||||||
|
if host is not None:
|
||||||
|
config["host"] = host
|
||||||
|
if port is not None:
|
||||||
|
config["port"] = port
|
||||||
|
self._core = Core(CoreConfig(**config))
|
||||||
|
self._playback_mode = config["playback_mode"]
|
||||||
|
self._pool = QThreadPool.globalInstance()
|
||||||
|
self._calls: set[_Call] = set()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def default_endpoint() -> tuple[str, int]:
|
||||||
|
config = CoreConfig()
|
||||||
|
return config.host, config.port
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._core.start()
|
||||||
|
|
||||||
|
def stop(self) -> bool:
|
||||||
|
recording_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
if self._core.get_state() == "started" and self._core.get_recording_state():
|
||||||
|
self._core.stop_recording()
|
||||||
|
except Exception as error:
|
||||||
|
recording_error = error
|
||||||
|
try:
|
||||||
|
stopped = self._core.stop()
|
||||||
|
except Exception as error:
|
||||||
|
if recording_error is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Could not finalize the active recording ({recording_error}); "
|
||||||
|
f"Core shutdown also failed ({error})"
|
||||||
|
) from error
|
||||||
|
raise
|
||||||
|
if recording_error is not None:
|
||||||
|
raise RuntimeError(f"Could not finalize the active recording: {recording_error}") from recording_error
|
||||||
|
return stopped
|
||||||
|
|
||||||
|
def set_mode(self, mode: str) -> None:
|
||||||
|
self._core.set_mode(mode)
|
||||||
|
|
||||||
|
def start_recording(self) -> None:
|
||||||
|
self._core.start_recording()
|
||||||
|
|
||||||
|
def stop_recording(self) -> None:
|
||||||
|
self._core.stop_recording()
|
||||||
|
|
||||||
|
def write_archive(self, path: str | Path, source: str = "recording") -> UUID:
|
||||||
|
"""Start writing a Core-owned archive and return its operation ID."""
|
||||||
|
target = Path(path)
|
||||||
|
operation_id = self._core.write(
|
||||||
|
target.parent,
|
||||||
|
target.name,
|
||||||
|
source=source,
|
||||||
|
background=True,
|
||||||
|
)
|
||||||
|
if operation_id is None:
|
||||||
|
raise RuntimeError("Core did not return an archive operation ID")
|
||||||
|
return operation_id
|
||||||
|
|
||||||
|
def operation_status(self, operation_id: str | UUID) -> dict[str, Any] | None:
|
||||||
|
status = self._core.get_operation(UUID(str(operation_id)))
|
||||||
|
if status is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": str(status.id),
|
||||||
|
"name": status.name,
|
||||||
|
"state": status.state,
|
||||||
|
"completed": status.completed,
|
||||||
|
"total": status.total,
|
||||||
|
"progress": status.progress,
|
||||||
|
"message": status.message,
|
||||||
|
"error_code": status.error_code,
|
||||||
|
"error_message": status.error_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
def playback_mode(self) -> str:
|
||||||
|
return self._playback_mode
|
||||||
|
|
||||||
|
def load_processing(self, path: str | Path | None = None) -> UUID:
|
||||||
|
input_path = Path(path) if path is not None else None
|
||||||
|
operation_id = self._core.load_processing_buffer(input_path, background=True)
|
||||||
|
if operation_id is None:
|
||||||
|
raise RuntimeError("Core did not return a processing load operation ID")
|
||||||
|
return operation_id
|
||||||
|
|
||||||
|
def reprocess(self) -> UUID:
|
||||||
|
operation_id = self._core.reprocess(background=True)
|
||||||
|
if operation_id is None:
|
||||||
|
raise RuntimeError("Core did not return a reprocess operation ID")
|
||||||
|
return operation_id
|
||||||
|
|
||||||
|
def load_playback(self, path: str | Path | None = None) -> UUID:
|
||||||
|
input_path = Path(path) if path is not None else None
|
||||||
|
operation_id = self._core.load_playback_engine(input_path, background=True)
|
||||||
|
if operation_id is None:
|
||||||
|
raise RuntimeError("Core did not return a playback load operation ID")
|
||||||
|
return operation_id
|
||||||
|
|
||||||
|
def play(self) -> None:
|
||||||
|
self._core.play()
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
self._core.pause()
|
||||||
|
|
||||||
|
def seek(self, timestamp_ns: int) -> None:
|
||||||
|
self._core.seek(timestamp_ns)
|
||||||
|
|
||||||
|
def playback_status(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"position": self._core.get_playback_position(),
|
||||||
|
"playing": self._core.get_playback_state(),
|
||||||
|
"time_range": self._safe_time_range("playback"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def bind_unit(self, path: str | Path) -> str:
|
||||||
|
return str(self._core.bind_derive_unit(file=Path(path)))
|
||||||
|
|
||||||
|
def unbind_unit(self, unit_id: str | UUID) -> None:
|
||||||
|
self._core.unbind_derive_unit(UUID(str(unit_id)))
|
||||||
|
|
||||||
|
def reload_unit(self, unit_id: str | UUID, path: str | Path) -> dict[str, Any]:
|
||||||
|
"""Replace a bound unit, reporting when replacement fails after unbind."""
|
||||||
|
self._core.unbind_derive_unit(UUID(str(unit_id)))
|
||||||
|
try:
|
||||||
|
replacement_id = self._core.bind_derive_unit(file=Path(path))
|
||||||
|
except Exception as error:
|
||||||
|
return {
|
||||||
|
"state": "unbound",
|
||||||
|
"unit_id": None,
|
||||||
|
"error_code": getattr(error, "code", None),
|
||||||
|
"error_message": str(error),
|
||||||
|
}
|
||||||
|
return {"state": "bound", "unit_id": str(replacement_id)}
|
||||||
|
|
||||||
|
def _safe_time_range(self, source: str) -> tuple[int, int] | None:
|
||||||
|
try:
|
||||||
|
return self._core.get_time_range(source=source)
|
||||||
|
except CoreError as error:
|
||||||
|
if error.code == "no_recording":
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
connectors = self._core.get_connectors()
|
||||||
|
signals = self._core.get_all_signal_descriptors()
|
||||||
|
values = self._core.get_all_live_values()
|
||||||
|
recording = self._core.get_recording_state()
|
||||||
|
time_ranges = {
|
||||||
|
source: self._safe_time_range(source)
|
||||||
|
for source in ("recording", "processing", "playback")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"state": self._core.get_state(),
|
||||||
|
"mode": self._core.get_mode(),
|
||||||
|
"recording": recording,
|
||||||
|
"recording_available": not recording and time_ranges["recording"] is not None,
|
||||||
|
"processing_available": time_ranges["processing"] is not None,
|
||||||
|
"playback_available": time_ranges["playback"] is not None,
|
||||||
|
"time_ranges": time_ranges,
|
||||||
|
"playback_position": self._core.get_playback_position(),
|
||||||
|
"playback_playing": self._core.get_playback_state(),
|
||||||
|
"connectors": [
|
||||||
|
{"name": item.name, "state": item.state, "signal_count": len(item.signal_ids)}
|
||||||
|
for item in connectors
|
||||||
|
],
|
||||||
|
"signals": [
|
||||||
|
{
|
||||||
|
"id": str(item.id),
|
||||||
|
"name": item.name,
|
||||||
|
"type": item.type,
|
||||||
|
"unit": item.unit,
|
||||||
|
"min_value": item.min_value,
|
||||||
|
"max_value": item.max_value,
|
||||||
|
"timeout_ms": item.timeout_ms,
|
||||||
|
"origin": item.origin,
|
||||||
|
"value": values.get(item.id),
|
||||||
|
}
|
||||||
|
for item in signals
|
||||||
|
],
|
||||||
|
"derive_units": [
|
||||||
|
{
|
||||||
|
"id": str(item.id),
|
||||||
|
"input_signal_ids": [str(signal_id) for signal_id in item.input_signal_ids],
|
||||||
|
"output_signal_id": str(item.output_signal_id),
|
||||||
|
"worker": {
|
||||||
|
"name": item.worker.name,
|
||||||
|
"state": item.worker.state,
|
||||||
|
"error": item.worker.error,
|
||||||
|
"queue_size": item.worker.queue_size,
|
||||||
|
"queue_capacity": item.worker.queue_capacity,
|
||||||
|
"dropped_samples": item.worker.dropped_samples,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for item in self._core.get_derive_units()
|
||||||
|
],
|
||||||
|
"workers": [
|
||||||
|
{
|
||||||
|
"name": item.name,
|
||||||
|
"state": item.state,
|
||||||
|
"error": item.error,
|
||||||
|
"queue_size": item.queue_size,
|
||||||
|
"queue_capacity": item.queue_capacity,
|
||||||
|
"dropped_samples": item.dropped_samples,
|
||||||
|
}
|
||||||
|
for item in self._core.get_worker_statuses()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def plot_snapshot(self, signal_ids: list[str]) -> dict[str, Any]:
|
||||||
|
"""Read the full active timeline, bounding only display point counts."""
|
||||||
|
try:
|
||||||
|
time_range = self._core.get_time_range()
|
||||||
|
except CoreError as error:
|
||||||
|
if error.code == "no_recording":
|
||||||
|
return {"time_range": None, "data": {}}
|
||||||
|
raise
|
||||||
|
if not time_range or not signal_ids:
|
||||||
|
return {"time_range": time_range, "data": {}}
|
||||||
|
start_ns, end_ns = time_range
|
||||||
|
ids = [UUID(signal_id) for signal_id in signal_ids]
|
||||||
|
data = self._core.get_data_range(ids, start_ns, end_ns)
|
||||||
|
display_data = {}
|
||||||
|
for signal_id, samples in data.items():
|
||||||
|
if len(samples) > self.MAX_DISPLAY_POINTS:
|
||||||
|
stride = ceil((len(samples) - 1) / (self.MAX_DISPLAY_POINTS - 1))
|
||||||
|
reduced = samples[::stride]
|
||||||
|
if reduced[-1] != samples[-1]:
|
||||||
|
reduced.append(samples[-1])
|
||||||
|
samples = reduced
|
||||||
|
display_data[str(signal_id)] = samples
|
||||||
|
return {
|
||||||
|
"time_range": time_range,
|
||||||
|
"data": display_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_async(
|
||||||
|
self, method: Callable[[], Any], finished: Callable[[Any, Exception | None], None]
|
||||||
|
) -> None:
|
||||||
|
call = _Call(method)
|
||||||
|
self._calls.add(call)
|
||||||
|
|
||||||
|
def deliver(result: Any, error: Exception | None) -> None:
|
||||||
|
try:
|
||||||
|
finished(result, error)
|
||||||
|
finally:
|
||||||
|
call.signals.finished.disconnect(deliver)
|
||||||
|
self._calls.discard(call)
|
||||||
|
|
||||||
|
call.signals.finished.connect(deliver)
|
||||||
|
self._pool.start(call)
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFileDialog,
|
||||||
|
QLabel,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QSlider,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataPanel(QWidget):
|
||||||
|
"""Explicit Offline buffer loading, processing, and saving controls."""
|
||||||
|
|
||||||
|
busyChanged = Signal(bool)
|
||||||
|
configChanged = Signal(dict)
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._adapter: Any | None = None
|
||||||
|
self._config: dict[str, Any] = {}
|
||||||
|
self._session_usable = False
|
||||||
|
self._core_state = "stopped"
|
||||||
|
self._mode = ""
|
||||||
|
self._recording = False
|
||||||
|
self._recording_available = False
|
||||||
|
self._processing_available = False
|
||||||
|
self._playback_available = False
|
||||||
|
self._playback_playing = False
|
||||||
|
self._playback_position = 0
|
||||||
|
self._playback_range: tuple[int, int] | None = None
|
||||||
|
self._duration_ns = 0
|
||||||
|
self._busy = False
|
||||||
|
self._operation_id: str | None = None
|
||||||
|
self._operation_kind = ""
|
||||||
|
self.set_config(config or {})
|
||||||
|
|
||||||
|
self.status = QLabel("Start Core and select Offline mode to manage data")
|
||||||
|
self.status.setObjectName("muted")
|
||||||
|
self.status.setWordWrap(True)
|
||||||
|
self.operation = QLabel("")
|
||||||
|
self.operation.setObjectName("muted")
|
||||||
|
self.operation.setWordWrap(True)
|
||||||
|
self.load_archive_button = QPushButton("Load archive…")
|
||||||
|
self.load_recording_button = QPushButton("Load recording")
|
||||||
|
self.reprocess_button = QPushButton("Reprocess")
|
||||||
|
self.save_button = QPushButton("Save processed…")
|
||||||
|
self.load_processed_button = QPushButton("Load processed result")
|
||||||
|
self.play_button = QPushButton("Play")
|
||||||
|
self.pause_button = QPushButton("Pause")
|
||||||
|
self.position_label = QLabel("Elapsed 0.00 / 0.00 s")
|
||||||
|
self.position_label.setObjectName("muted")
|
||||||
|
self.position_label.setWordWrap(True)
|
||||||
|
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
|
self.seek_slider.setRange(0, 1000)
|
||||||
|
self.seek_slider.setToolTip("Seek by elapsed time from the archive's first sample")
|
||||||
|
self.load_archive_button.clicked.connect(self._choose_archive)
|
||||||
|
self.load_recording_button.clicked.connect(self._load_recording)
|
||||||
|
self.reprocess_button.clicked.connect(self._reprocess)
|
||||||
|
self.save_button.clicked.connect(self._choose_save_path)
|
||||||
|
self.load_processed_button.clicked.connect(self._load_processed_result)
|
||||||
|
self.play_button.clicked.connect(self._play)
|
||||||
|
self.pause_button.clicked.connect(self._pause)
|
||||||
|
self.seek_slider.sliderReleased.connect(self._seek_playback)
|
||||||
|
self.seek_slider.actionTriggered.connect(self._slider_action_triggered)
|
||||||
|
self.progress = QProgressBar()
|
||||||
|
self.progress.setRange(0, 100)
|
||||||
|
self.progress.setValue(0)
|
||||||
|
self.progress.hide()
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.addWidget(self.status)
|
||||||
|
layout.addWidget(self.load_archive_button)
|
||||||
|
layout.addWidget(self.load_recording_button)
|
||||||
|
layout.addWidget(self.reprocess_button)
|
||||||
|
layout.addWidget(self.save_button)
|
||||||
|
layout.addWidget(self.load_processed_button)
|
||||||
|
layout.addWidget(self.play_button)
|
||||||
|
layout.addWidget(self.pause_button)
|
||||||
|
layout.addWidget(self.position_label)
|
||||||
|
layout.addWidget(self.seek_slider)
|
||||||
|
layout.addWidget(self.operation)
|
||||||
|
layout.addWidget(self.progress)
|
||||||
|
layout.addStretch(1)
|
||||||
|
self._operation_timer = QTimer(self)
|
||||||
|
self._operation_timer.setInterval(350)
|
||||||
|
self._operation_timer.timeout.connect(self._poll_operation)
|
||||||
|
self._playback_timer = QTimer(self)
|
||||||
|
self._playback_timer.setTimerType(Qt.TimerType.PreciseTimer)
|
||||||
|
self._playback_timer.setInterval(200)
|
||||||
|
self._playback_timer.timeout.connect(self._poll_playback)
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def set_update_interval(self, interval_ms: int) -> None:
|
||||||
|
self._playback_timer.setInterval(interval_ms)
|
||||||
|
|
||||||
|
def set_config(self, config: dict[str, Any]) -> None:
|
||||||
|
self._config = {
|
||||||
|
"last_archive_path": str(config.get("last_archive_path", "")),
|
||||||
|
"last_save_path": str(config.get("last_save_path", "")),
|
||||||
|
"last_playback_path": str(config.get("last_playback_path", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_config(self) -> dict[str, Any]:
|
||||||
|
return dict(self._config)
|
||||||
|
|
||||||
|
def set_adapter(self, adapter: Any | None) -> None:
|
||||||
|
if adapter is self._adapter:
|
||||||
|
return
|
||||||
|
self._operation_timer.stop()
|
||||||
|
self._playback_timer.stop()
|
||||||
|
self._adapter = adapter
|
||||||
|
self._core_state = "stopped"
|
||||||
|
self._mode = ""
|
||||||
|
self._recording = False
|
||||||
|
self._recording_available = False
|
||||||
|
self._processing_available = False
|
||||||
|
self._playback_available = False
|
||||||
|
self._playback_playing = False
|
||||||
|
self._playback_position = 0
|
||||||
|
self._playback_range = None
|
||||||
|
self._duration_ns = 0
|
||||||
|
self._operation_id = None
|
||||||
|
self._operation_kind = ""
|
||||||
|
self.progress.hide()
|
||||||
|
self.operation.clear()
|
||||||
|
self._set_busy(False)
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def busy(self) -> bool:
|
||||||
|
return self._busy
|
||||||
|
|
||||||
|
def refresh(self, snapshot: dict[str, Any] | None) -> None:
|
||||||
|
self._session_usable = bool(snapshot and snapshot.get("session_usable", snapshot.get("state") == "started"))
|
||||||
|
if snapshot is None:
|
||||||
|
self._core_state = "stopped"
|
||||||
|
self._mode = ""
|
||||||
|
self._recording = False
|
||||||
|
self._recording_available = False
|
||||||
|
self._processing_available = False
|
||||||
|
self._playback_available = False
|
||||||
|
self._playback_playing = False
|
||||||
|
self._playback_position = 0
|
||||||
|
self._playback_range = None
|
||||||
|
self._duration_ns = 0
|
||||||
|
self._playback_timer.stop()
|
||||||
|
self.status.setText("Start Core and select Offline mode to manage data")
|
||||||
|
self._update_controls()
|
||||||
|
return
|
||||||
|
self._core_state = snapshot.get("state", "")
|
||||||
|
self._mode = snapshot.get("mode", "")
|
||||||
|
self._recording = bool(snapshot.get("recording", False))
|
||||||
|
self._recording_available = bool(snapshot.get("recording_available", False))
|
||||||
|
self._processing_available = bool(snapshot.get("processing_available", False))
|
||||||
|
self._playback_available = bool(snapshot.get("playback_available", False))
|
||||||
|
self._playback_playing = bool(snapshot.get("playback_playing", False))
|
||||||
|
self._playback_position = int(snapshot.get("playback_position", 0))
|
||||||
|
time_ranges = snapshot.get("time_ranges", {})
|
||||||
|
self._playback_range = time_ranges.get("playback")
|
||||||
|
if self._playback_range:
|
||||||
|
self._duration_ns = max(0, self._playback_range[1] - self._playback_range[0])
|
||||||
|
else:
|
||||||
|
self._duration_ns = 0
|
||||||
|
if self._core_state == "started" and self._mode == "playback":
|
||||||
|
if not self._playback_timer.isActive():
|
||||||
|
self._playback_timer.start()
|
||||||
|
else:
|
||||||
|
self._playback_timer.stop()
|
||||||
|
if self._core_state != "started":
|
||||||
|
message = f"Core session state: {self._core_state}"
|
||||||
|
elif self._mode == "playback":
|
||||||
|
self.status.setText(self._playback_help())
|
||||||
|
self._update_position()
|
||||||
|
self._update_controls()
|
||||||
|
return
|
||||||
|
elif self._mode != "offline":
|
||||||
|
message = f"Select Offline mode to manage data · current mode: {self._mode}"
|
||||||
|
elif self._recording:
|
||||||
|
message = "Stop recording before loading or processing data"
|
||||||
|
elif self._processing_available:
|
||||||
|
message = "Offline processing buffer is loaded"
|
||||||
|
elif self._recording_available:
|
||||||
|
message = "No processing buffer loaded · a completed recording is available"
|
||||||
|
else:
|
||||||
|
message = "No processing buffer loaded"
|
||||||
|
self.status.setText(message)
|
||||||
|
self._update_position()
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool) -> None:
|
||||||
|
if self._busy == busy:
|
||||||
|
return
|
||||||
|
self._busy = busy
|
||||||
|
self.busyChanged.emit(busy)
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def _update_controls(self) -> None:
|
||||||
|
running = self._adapter is not None and self._session_usable and self._core_state == "started"
|
||||||
|
offline = self._mode == "offline"
|
||||||
|
playback = self._mode == "playback"
|
||||||
|
enabled = running and offline and not self._recording and not self._busy
|
||||||
|
self.load_archive_button.setVisible(offline or playback)
|
||||||
|
self.load_archive_button.setEnabled(
|
||||||
|
running and (offline or playback) and not self._recording and not self._busy
|
||||||
|
)
|
||||||
|
self.load_recording_button.setVisible(offline)
|
||||||
|
self.load_recording_button.setEnabled(enabled and self._recording_available)
|
||||||
|
self.reprocess_button.setVisible(offline)
|
||||||
|
self.reprocess_button.setEnabled(enabled and self._processing_available)
|
||||||
|
self.save_button.setVisible(offline)
|
||||||
|
self.save_button.setEnabled(enabled and self._processing_available)
|
||||||
|
self.load_processed_button.setVisible(playback)
|
||||||
|
self.load_processed_button.setEnabled(
|
||||||
|
running and playback and self._processing_available and not self._busy
|
||||||
|
)
|
||||||
|
self.play_button.setVisible(playback)
|
||||||
|
self.play_button.setEnabled(
|
||||||
|
running and playback and self._playback_available and not self._playback_playing and not self._busy
|
||||||
|
)
|
||||||
|
self.pause_button.setVisible(playback)
|
||||||
|
self.pause_button.setEnabled(
|
||||||
|
running and playback and self._playback_available and self._playback_playing and not self._busy
|
||||||
|
)
|
||||||
|
self.position_label.setVisible(playback)
|
||||||
|
self.seek_slider.setVisible(playback)
|
||||||
|
self.seek_slider.setEnabled(
|
||||||
|
running and playback and self._playback_available and self._duration_ns > 0 and not self._busy
|
||||||
|
)
|
||||||
|
|
||||||
|
def _playback_help(self) -> str:
|
||||||
|
if not self._playback_available:
|
||||||
|
return "No playback data loaded · load an archive or processed result"
|
||||||
|
playback_mode = self._adapter.playback_mode() if self._adapter is not None else "read"
|
||||||
|
if playback_mode == "read":
|
||||||
|
return (
|
||||||
|
"Playback reads recorded derived values from the loaded archive. To regenerate them, "
|
||||||
|
"switch to Offline, reprocess, then choose Load processed result."
|
||||||
|
)
|
||||||
|
return "Playback uses configured derived units for derived signal values."
|
||||||
|
|
||||||
|
def _update_position(self) -> None:
|
||||||
|
duration = self._duration_ns
|
||||||
|
position = min(duration, max(0, self._playback_position))
|
||||||
|
self.position_label.setText(
|
||||||
|
f"Elapsed {position / 1_000_000_000:.2f} / {duration / 1_000_000_000:.2f} s"
|
||||||
|
)
|
||||||
|
if not self.seek_slider.isSliderDown():
|
||||||
|
value = round(1000 * position / duration) if duration else 0
|
||||||
|
self.seek_slider.setValue(value)
|
||||||
|
|
||||||
|
def _poll_playback(self) -> None:
|
||||||
|
if self._adapter is None or self._core_state != "started" or self._mode != "playback":
|
||||||
|
self._playback_timer.stop()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
state = self._adapter.playback_status()
|
||||||
|
except Exception as error:
|
||||||
|
self.operation.setText(f"Could not read playback status: {error}")
|
||||||
|
return
|
||||||
|
self._playback_position = int(state["position"])
|
||||||
|
self._playback_playing = bool(state["playing"])
|
||||||
|
self._update_position()
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def _slider_action_triggered(self, _action: int) -> None:
|
||||||
|
QTimer.singleShot(0, self._seek_after_slider_action)
|
||||||
|
|
||||||
|
def _seek_after_slider_action(self) -> None:
|
||||||
|
if not self.seek_slider.isSliderDown():
|
||||||
|
self._seek_playback()
|
||||||
|
|
||||||
|
def _choose_archive(self) -> None:
|
||||||
|
if self._adapter is None or self._busy or self._mode not in {"offline", "playback"}:
|
||||||
|
return
|
||||||
|
playback = self._mode == "playback"
|
||||||
|
key = "last_playback_path" if playback else "last_archive_path"
|
||||||
|
previous = self._config[key]
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"Load playback archive" if playback else "Load Offline archive",
|
||||||
|
previous,
|
||||||
|
"DynaLab archive (*.dlpak);;All files (*)",
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
self._config[key] = path
|
||||||
|
self.configChanged.emit(self.get_config())
|
||||||
|
if playback:
|
||||||
|
self._begin_operation(
|
||||||
|
"playback_load",
|
||||||
|
lambda: self._adapter.load_playback(path),
|
||||||
|
"Loading playback archive…",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._begin_operation("load", lambda: self._adapter.load_processing(path), "Loading Offline archive…")
|
||||||
|
|
||||||
|
def _load_recording(self) -> None:
|
||||||
|
if self._adapter is None or self._busy or not self._recording_available:
|
||||||
|
return
|
||||||
|
self._begin_operation("load", lambda: self._adapter.load_processing(), "Loading recording…")
|
||||||
|
|
||||||
|
def _reprocess(self) -> None:
|
||||||
|
if self._adapter is None or self._busy or not self._processing_available:
|
||||||
|
return
|
||||||
|
self._begin_operation("reprocess", self._adapter.reprocess, "Preparing reprocess…")
|
||||||
|
|
||||||
|
def _load_processed_result(self) -> None:
|
||||||
|
if self._adapter is None or self._busy or not self._processing_available or self._mode != "playback":
|
||||||
|
return
|
||||||
|
self._begin_operation(
|
||||||
|
"playback_load",
|
||||||
|
self._adapter.load_playback,
|
||||||
|
"Loading processed result into Playback…",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _choose_save_path(self) -> None:
|
||||||
|
if self._adapter is None or self._busy or not self._processing_available:
|
||||||
|
return
|
||||||
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Save processed data",
|
||||||
|
self._config["last_save_path"] or "processed.dlpak",
|
||||||
|
"DynaLab archive (*.dlpak)",
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
target = Path(path)
|
||||||
|
if target.suffix.lower() != ".dlpak":
|
||||||
|
target = target.with_name(target.name + ".dlpak")
|
||||||
|
self._config["last_save_path"] = str(target)
|
||||||
|
self.configChanged.emit(self.get_config())
|
||||||
|
self._begin_operation(
|
||||||
|
"save",
|
||||||
|
lambda: self._adapter.write_archive(target, source="processing"),
|
||||||
|
"Preparing archive save…",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _begin_operation(self, kind: str, request: Any, initial_message: str) -> None:
|
||||||
|
if self._adapter is None or self._busy:
|
||||||
|
return
|
||||||
|
self._operation_kind = kind
|
||||||
|
self.operation.setText(initial_message)
|
||||||
|
self.progress.setValue(0)
|
||||||
|
self.progress.show()
|
||||||
|
self._set_busy(True)
|
||||||
|
adapter = self._adapter
|
||||||
|
adapter.run_async(request, self._on_operation_started)
|
||||||
|
|
||||||
|
def _play(self) -> None:
|
||||||
|
self._playback_control("play", "Starting playback…")
|
||||||
|
|
||||||
|
def _pause(self) -> None:
|
||||||
|
self._playback_control("pause", "Pausing playback…")
|
||||||
|
|
||||||
|
def _playback_control(self, action: str, message: str) -> None:
|
||||||
|
if self._adapter is None or self._busy or self._mode != "playback" or not self._playback_available:
|
||||||
|
return
|
||||||
|
self.operation.setText(message)
|
||||||
|
self._set_busy(True)
|
||||||
|
adapter = self._adapter
|
||||||
|
method = adapter.play if action == "play" else adapter.pause
|
||||||
|
adapter.run_async(method, self._on_playback_control)
|
||||||
|
|
||||||
|
def _on_playback_control(self, _result: Any, error: Exception | None) -> None:
|
||||||
|
self._set_busy(False)
|
||||||
|
if error is not None:
|
||||||
|
self.operation.setText(f"Playback control failed: {error}")
|
||||||
|
else:
|
||||||
|
self.operation.clear()
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def _seek_playback(self) -> None:
|
||||||
|
if (
|
||||||
|
self._adapter is None
|
||||||
|
or self._busy
|
||||||
|
or self._mode != "playback"
|
||||||
|
or not self._playback_available
|
||||||
|
or self._duration_ns <= 0
|
||||||
|
):
|
||||||
|
return
|
||||||
|
# Core.seek() takes elapsed nanoseconds from the archive's first sample.
|
||||||
|
timestamp_ns = round(self._duration_ns * self.seek_slider.value() / 1000)
|
||||||
|
self.operation.setText("Seeking…")
|
||||||
|
self._set_busy(True)
|
||||||
|
adapter = self._adapter
|
||||||
|
adapter.run_async(lambda: adapter.seek(timestamp_ns), self._on_playback_control)
|
||||||
|
|
||||||
|
def _on_operation_started(self, operation_id: UUID | str | None, error: Exception | None) -> None:
|
||||||
|
if error is not None:
|
||||||
|
self._finish_operation(f"Could not {self._operation_verb()}: {error}", success=False)
|
||||||
|
return
|
||||||
|
if operation_id is None:
|
||||||
|
self._finish_operation("Core did not return an operation ID", success=False)
|
||||||
|
return
|
||||||
|
self._operation_id = str(operation_id)
|
||||||
|
self._poll_operation()
|
||||||
|
if self._operation_id is not None:
|
||||||
|
self._operation_timer.start()
|
||||||
|
|
||||||
|
def _operation_verb(self) -> str:
|
||||||
|
return {
|
||||||
|
"load": "load data",
|
||||||
|
"reprocess": "reprocess data",
|
||||||
|
"save": "save processed data",
|
||||||
|
"playback_load": "load playback data",
|
||||||
|
}.get(self._operation_kind, "complete operation")
|
||||||
|
|
||||||
|
def _poll_operation(self) -> None:
|
||||||
|
if self._adapter is None or self._operation_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
status = self._adapter.operation_status(self._operation_id)
|
||||||
|
except Exception as error:
|
||||||
|
self._finish_operation(f"Could not read operation status: {error}", success=False)
|
||||||
|
return
|
||||||
|
if status is None:
|
||||||
|
self._finish_operation("Could not read operation status: operation is unavailable", success=False)
|
||||||
|
return
|
||||||
|
total = status.get("total", 0)
|
||||||
|
completed = status.get("completed", 0)
|
||||||
|
if total:
|
||||||
|
self.progress.setValue(round(100 * completed / total))
|
||||||
|
message = status.get("message") or f"{self._operation_kind.title()} in progress…"
|
||||||
|
self.operation.setText(message)
|
||||||
|
state = status.get("state")
|
||||||
|
if state == "completed":
|
||||||
|
result = {
|
||||||
|
"load": "Data loaded into the Offline processing buffer",
|
||||||
|
"reprocess": "Derived signals reprocessed",
|
||||||
|
"save": f"Saved processed data to {self._config['last_save_path']}",
|
||||||
|
"playback_load": "Playback data loaded",
|
||||||
|
}.get(self._operation_kind, "Operation completed")
|
||||||
|
self._finish_operation(result, success=True)
|
||||||
|
elif state == "failed":
|
||||||
|
detail = status.get("error_message") or status.get("error_code") or "unknown error"
|
||||||
|
self._finish_operation(f"Could not {self._operation_verb()}: {detail}", success=False)
|
||||||
|
|
||||||
|
def _finish_operation(self, message: str, *, success: bool) -> None:
|
||||||
|
kind = self._operation_kind
|
||||||
|
self._operation_timer.stop()
|
||||||
|
self._operation_id = None
|
||||||
|
self._operation_kind = ""
|
||||||
|
self.operation.setText(message)
|
||||||
|
self.progress.hide()
|
||||||
|
if success and kind in {"load", "reprocess"}:
|
||||||
|
self._processing_available = True
|
||||||
|
if success and kind == "playback_load":
|
||||||
|
self._playback_available = True
|
||||||
|
self._playback_position = 0
|
||||||
|
self._playback_range = None
|
||||||
|
self._duration_ns = 0
|
||||||
|
if not self.seek_slider.isSliderDown():
|
||||||
|
self.seek_slider.setValue(0)
|
||||||
|
self._set_busy(False)
|
||||||
|
self._update_controls()
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""Workspace-scoped DLUnit library and binding controls."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QSignalBlocker, Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFileDialog,
|
||||||
|
QGridLayout,
|
||||||
|
QScrollArea,
|
||||||
|
QLabel,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .dlunit_editor import DLUnitEditorDialog
|
||||||
|
|
||||||
|
|
||||||
|
class DerivePanel(QWidget):
|
||||||
|
busyChanged = Signal(bool)
|
||||||
|
configChanged = Signal(dict)
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._adapter: Any | None = None
|
||||||
|
self._config: dict[str, Any] = {"units": []}
|
||||||
|
self._signals: list[dict[str, Any]] = []
|
||||||
|
self._derive_units: list[dict[str, Any]] = []
|
||||||
|
self._workers: list[dict[str, Any]] = []
|
||||||
|
self._session_usable = False
|
||||||
|
self._core_state = "stopped"
|
||||||
|
self._busy = False
|
||||||
|
self._active_action = ""
|
||||||
|
self._feedback = ""
|
||||||
|
self._base_status = "Start Core to view or bind derived units"
|
||||||
|
self.unit_list = QListWidget()
|
||||||
|
self.unit_list.setObjectName("deriveUnitList")
|
||||||
|
self.unit_list.setMinimumHeight(60)
|
||||||
|
self.unit_list.setMaximumHeight(150)
|
||||||
|
self.status_label = QLabel("Start Core to view or bind derived units")
|
||||||
|
self.status_label.setObjectName("muted")
|
||||||
|
self.status_label.setWordWrap(True)
|
||||||
|
self.unit_details = QLabel("Select a unit to inspect its inputs, output, and worker state")
|
||||||
|
self.unit_details.setObjectName("muted")
|
||||||
|
self.unit_details.setWordWrap(True)
|
||||||
|
self.new_button = QPushButton("New unit…")
|
||||||
|
self.open_button = QPushButton("Open unit…")
|
||||||
|
self.edit_button = QPushButton("Edit unit…")
|
||||||
|
self.bind_button = QPushButton("Bind")
|
||||||
|
self.unbind_button = QPushButton("Unbind")
|
||||||
|
self.reload_button = QPushButton("Reload saved")
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.addWidget(QLabel("Derived units"))
|
||||||
|
layout.addWidget(self.unit_list, 1)
|
||||||
|
details = QScrollArea()
|
||||||
|
details.setWidgetResizable(True)
|
||||||
|
details.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
details.setMinimumHeight(100)
|
||||||
|
details.setMaximumHeight(170)
|
||||||
|
self.unit_details.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
details.setWidget(self.unit_details)
|
||||||
|
layout.addWidget(details)
|
||||||
|
buttons = QGridLayout()
|
||||||
|
for row, pair in enumerate(((self.new_button, self.open_button),
|
||||||
|
(self.edit_button, self.reload_button),
|
||||||
|
(self.bind_button, self.unbind_button))):
|
||||||
|
for column, button in enumerate(pair):
|
||||||
|
buttons.addWidget(button, row, column)
|
||||||
|
layout.addLayout(buttons)
|
||||||
|
layout.addWidget(self.status_label)
|
||||||
|
self.new_button.clicked.connect(self._new_unit)
|
||||||
|
self.open_button.clicked.connect(self._open_unit)
|
||||||
|
self.edit_button.clicked.connect(self._edit_unit)
|
||||||
|
self.bind_button.clicked.connect(self._bind_selected)
|
||||||
|
self.unbind_button.clicked.connect(self._unbind_selected)
|
||||||
|
self.reload_button.clicked.connect(self._reload_selected)
|
||||||
|
self.unit_list.currentItemChanged.connect(self._selection_changed)
|
||||||
|
self.set_config(config or {})
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def busy(self) -> bool:
|
||||||
|
return self._busy
|
||||||
|
|
||||||
|
def set_adapter(self, adapter: Any | None) -> None:
|
||||||
|
if adapter is self._adapter:
|
||||||
|
self._update_controls()
|
||||||
|
return
|
||||||
|
self._adapter = adapter
|
||||||
|
self._core_state = "stopped"
|
||||||
|
self._derive_units = []
|
||||||
|
self._workers = []
|
||||||
|
changed = False
|
||||||
|
for unit in self._config["units"]:
|
||||||
|
if unit["unit_id"] is not None:
|
||||||
|
unit["unit_id"] = None
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self._emit_config()
|
||||||
|
self._render_units()
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def set_config(self, config: dict[str, Any]) -> None:
|
||||||
|
units: list[dict[str, Any]] = []
|
||||||
|
for entry in config.get("units", []):
|
||||||
|
if not isinstance(entry, dict) or not entry.get("path"):
|
||||||
|
continue
|
||||||
|
units.append(
|
||||||
|
{
|
||||||
|
"path": str(entry["path"]),
|
||||||
|
"unit_id": str(entry["unit_id"]) if entry.get("unit_id") else None,
|
||||||
|
"binding_intent": bool(entry.get("binding_intent", False)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._config = {"units": units}
|
||||||
|
self._render_units()
|
||||||
|
|
||||||
|
def get_config(self) -> dict[str, Any]:
|
||||||
|
return {"units": [dict(item) for item in self._config["units"]]}
|
||||||
|
|
||||||
|
def refresh(self, snapshot: dict[str, Any] | None) -> None:
|
||||||
|
self._session_usable = bool(snapshot and snapshot.get("session_usable", snapshot.get("state") == "started"))
|
||||||
|
old_ids = {item.get("unit_id") for item in self._config["units"]}
|
||||||
|
if snapshot is None:
|
||||||
|
self._core_state = "stopped"
|
||||||
|
self._signals = []
|
||||||
|
self._derive_units = []
|
||||||
|
self._workers = []
|
||||||
|
for item in self._config["units"]:
|
||||||
|
item["unit_id"] = None
|
||||||
|
self._base_status = "Start Core to view or bind derived units"
|
||||||
|
else:
|
||||||
|
self._core_state = str(snapshot.get("state", "stopped"))
|
||||||
|
self._signals = list(snapshot.get("signals", []))
|
||||||
|
self._derive_units = list(snapshot.get("derive_units", []))
|
||||||
|
self._workers = list(snapshot.get("workers", []))
|
||||||
|
live_ids = {str(item.get("id")) for item in self._derive_units}
|
||||||
|
for item in self._config["units"]:
|
||||||
|
if item.get("unit_id") not in live_ids:
|
||||||
|
item["unit_id"] = None
|
||||||
|
state = snapshot.get("state", "stopped")
|
||||||
|
if state != "started":
|
||||||
|
message = f"Core session state: {state}"
|
||||||
|
elif not self._derive_units and not self._config["units"]:
|
||||||
|
message = "No derived units are bound or tracked"
|
||||||
|
else:
|
||||||
|
message = "Bound units are shown with worker state; opened files remain unbound until Bind"
|
||||||
|
self._base_status = message
|
||||||
|
self._render_units()
|
||||||
|
if not self._feedback:
|
||||||
|
self.status_label.setText(self._base_status)
|
||||||
|
if old_ids != {item.get("unit_id") for item in self._config["units"]}:
|
||||||
|
self._emit_config()
|
||||||
|
|
||||||
|
def _emit_config(self) -> None:
|
||||||
|
self.configChanged.emit(self.get_config())
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool, action: str = "") -> None:
|
||||||
|
if self._busy == busy:
|
||||||
|
if busy:
|
||||||
|
self._active_action = action
|
||||||
|
return
|
||||||
|
self._busy = busy
|
||||||
|
self._active_action = action if busy else ""
|
||||||
|
self.busyChanged.emit(busy)
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def _set_feedback(self, message: str) -> None:
|
||||||
|
self._feedback = message
|
||||||
|
self.status_label.setText(message)
|
||||||
|
|
||||||
|
def _clear_feedback(self) -> None:
|
||||||
|
self._feedback = ""
|
||||||
|
self.status_label.setText(self._base_status)
|
||||||
|
|
||||||
|
def _selection_changed(self, *_args: Any) -> None:
|
||||||
|
self._feedback = ""
|
||||||
|
self.status_label.setText(self._base_status)
|
||||||
|
self._update_controls()
|
||||||
|
self._update_details()
|
||||||
|
|
||||||
|
def _tracked_item(self, entry: dict[str, Any], bound_id: str | None = None) -> QListWidgetItem:
|
||||||
|
path = Path(entry["path"])
|
||||||
|
unit_id = entry.get("unit_id")
|
||||||
|
bound = next((unit for unit in self._derive_units if str(unit.get("id")) == unit_id), None)
|
||||||
|
if bound:
|
||||||
|
worker = bound.get("worker", {})
|
||||||
|
label = f"{path.name} · bound · {worker.get('state', 'unknown')}"
|
||||||
|
if worker.get("error"):
|
||||||
|
label += f" · {worker['error']}"
|
||||||
|
item = QListWidgetItem(label)
|
||||||
|
item.setToolTip(self._unit_summary(bound, worker))
|
||||||
|
else:
|
||||||
|
label = f"{path.name} · unbound"
|
||||||
|
item = QListWidgetItem(label)
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, {**entry, "external": False})
|
||||||
|
if not bound:
|
||||||
|
item.setToolTip(str(path))
|
||||||
|
return item
|
||||||
|
|
||||||
|
def _unit_summary(self, unit: dict[str, Any], worker: dict[str, Any]) -> str:
|
||||||
|
signal_names = {str(signal.get("id")): str(signal.get("name", signal.get("id"))) for signal in self._signals}
|
||||||
|
inputs = [
|
||||||
|
f"{signal_names.get(str(signal_id), str(signal_id))} [{signal_id}]"
|
||||||
|
for signal_id in unit.get("input_signal_ids", [])
|
||||||
|
]
|
||||||
|
output_id = str(unit.get("output_signal_id", ""))
|
||||||
|
output = f"{signal_names.get(output_id, output_id)} [{output_id}]"
|
||||||
|
lines = [f"Inputs: {', '.join(inputs) if inputs else '(none)'}", f"Output: {output}", f"Worker: {worker.get('name', 'unknown')} · {worker.get('state', 'unknown')}"]
|
||||||
|
if worker.get("error"):
|
||||||
|
lines.append(f"Error: {worker['error']}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _render_units(self) -> None:
|
||||||
|
blocker = QSignalBlocker(self.unit_list)
|
||||||
|
selected = self.unit_list.currentItem().data(Qt.ItemDataRole.UserRole) if self.unit_list.currentItem() else None
|
||||||
|
wanted = (selected or {}).get("path") or (selected or {}).get("unit_id")
|
||||||
|
self.unit_list.clear()
|
||||||
|
bound_by_id = {str(item.get("id")): item for item in self._derive_units}
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for entry in self._config["units"]:
|
||||||
|
unit_id = entry.get("unit_id")
|
||||||
|
if unit_id and unit_id in bound_by_id:
|
||||||
|
seen_ids.add(unit_id)
|
||||||
|
item = self._tracked_item(entry)
|
||||||
|
self.unit_list.addItem(item)
|
||||||
|
if wanted and wanted in (entry.get("path"), unit_id):
|
||||||
|
self.unit_list.setCurrentItem(item)
|
||||||
|
signal_by_id = {str(signal.get("id")): signal for signal in self._signals}
|
||||||
|
for unit in self._derive_units:
|
||||||
|
unit_id = str(unit.get("id"))
|
||||||
|
if unit_id in seen_ids:
|
||||||
|
continue
|
||||||
|
output_id = str(unit.get("output_signal_id"))
|
||||||
|
signal = signal_by_id.get(output_id, {})
|
||||||
|
worker = unit.get("worker", {})
|
||||||
|
output_name = signal.get("name", output_id)
|
||||||
|
item = QListWidgetItem(f"{output_name} · external Core unit · {worker.get('state', 'unknown')}")
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, {"unit_id": unit_id, "path": None, "external": True})
|
||||||
|
item.setToolTip(self._unit_summary(unit, worker) + "\nCore does not expose this unit's saved path or source")
|
||||||
|
if worker.get("error"):
|
||||||
|
item.setToolTip(f"Worker error: {worker['error']}\nPath and source are not exposed by Core")
|
||||||
|
self.unit_list.addItem(item)
|
||||||
|
if wanted == unit_id:
|
||||||
|
self.unit_list.setCurrentItem(item)
|
||||||
|
del blocker
|
||||||
|
self._update_controls()
|
||||||
|
self._update_details()
|
||||||
|
|
||||||
|
def _update_details(self) -> None:
|
||||||
|
selected = self._selected()
|
||||||
|
if selected is None:
|
||||||
|
self.unit_details.setText("Select a unit to inspect its inputs, output, and worker state")
|
||||||
|
return
|
||||||
|
bound = next((item for item in self._derive_units if str(item.get("id")) == selected.get("unit_id")), None)
|
||||||
|
if bound is None:
|
||||||
|
self.unit_details.setText(f"Unit ID: {selected.get('unit_id') or 'not bound'}\nPath: {selected.get('path') or 'not available'}")
|
||||||
|
return
|
||||||
|
worker = bound.get("worker", {})
|
||||||
|
self.unit_details.setText(f"Unit ID: {bound.get('id')}\n{self._unit_summary(bound, worker)}")
|
||||||
|
|
||||||
|
def _selected(self) -> dict[str, Any] | None:
|
||||||
|
item = self.unit_list.currentItem()
|
||||||
|
return item.data(Qt.ItemDataRole.UserRole) if item else None
|
||||||
|
|
||||||
|
def _update_controls(self, *_args: Any) -> None:
|
||||||
|
selected = self._selected()
|
||||||
|
entry = next((unit for unit in self._config["units"] if selected and unit["path"] == selected.get("path")), None)
|
||||||
|
running = self._adapter is not None and self._session_usable and self._core_state == "started" and not self._busy
|
||||||
|
exists = bool(entry and Path(entry["path"]).is_file())
|
||||||
|
bound = bool(selected and selected.get("unit_id") and any(str(unit.get("id")) == selected["unit_id"] for unit in self._derive_units))
|
||||||
|
self.new_button.setEnabled(not self._busy)
|
||||||
|
self.open_button.setEnabled(not self._busy)
|
||||||
|
self.edit_button.setEnabled(not self._busy and exists)
|
||||||
|
self.bind_button.setEnabled(running and exists and not bound)
|
||||||
|
self.unbind_button.setEnabled(running and bound)
|
||||||
|
self.reload_button.setEnabled(running and exists and bound and not bool(selected and selected.get("external", False)))
|
||||||
|
|
||||||
|
def _editor(self, path: str | Path | None) -> None:
|
||||||
|
dialog = DLUnitEditorDialog(path, self._signals, self)
|
||||||
|
if dialog.load_error:
|
||||||
|
QMessageBox.critical(self, "Could not open DLUnit", dialog.load_error)
|
||||||
|
return
|
||||||
|
if path is not None:
|
||||||
|
opened_path = str(Path(path))
|
||||||
|
if not any(unit["path"] == opened_path for unit in self._config["units"]):
|
||||||
|
self._config["units"].append({"path": opened_path, "unit_id": None, "binding_intent": False})
|
||||||
|
self._render_units()
|
||||||
|
self._emit_config()
|
||||||
|
if dialog.exec() != DLUnitEditorDialog.DialogCode.Accepted or not dialog.path:
|
||||||
|
return
|
||||||
|
saved_path = str(dialog.path)
|
||||||
|
entry = next((unit for unit in self._config["units"] if unit["path"] == saved_path), None)
|
||||||
|
if entry is None:
|
||||||
|
self._config["units"].append({"path": saved_path, "unit_id": None, "binding_intent": False})
|
||||||
|
self._render_units()
|
||||||
|
self._emit_config()
|
||||||
|
self._set_feedback("Saved to disk; bind or reload explicitly to apply changes in Core")
|
||||||
|
|
||||||
|
def _new_unit(self) -> None:
|
||||||
|
self._clear_feedback()
|
||||||
|
self._editor(None)
|
||||||
|
|
||||||
|
def _open_unit(self) -> None:
|
||||||
|
self._clear_feedback()
|
||||||
|
filename, _ = QFileDialog.getOpenFileName(self, "Open DLUnit", "", "DLUnit (*.dlunit)")
|
||||||
|
if filename:
|
||||||
|
self._editor(filename)
|
||||||
|
|
||||||
|
def _edit_unit(self) -> None:
|
||||||
|
self._clear_feedback()
|
||||||
|
selected = self._selected()
|
||||||
|
if selected and selected.get("path"):
|
||||||
|
self._editor(selected["path"])
|
||||||
|
|
||||||
|
def _run_async(self, action: str, method: Any, done: Any) -> None:
|
||||||
|
if self._adapter is None or self._busy:
|
||||||
|
return
|
||||||
|
self._set_busy(True, action)
|
||||||
|
|
||||||
|
def finished(result: Any, error: Exception | None) -> None:
|
||||||
|
self._set_busy(False)
|
||||||
|
if error is not None:
|
||||||
|
self._set_feedback(f"{action} failed: {error}")
|
||||||
|
return
|
||||||
|
done(result)
|
||||||
|
self._render_units()
|
||||||
|
|
||||||
|
self._adapter.run_async(method, finished)
|
||||||
|
|
||||||
|
def _bind_selected(self) -> None:
|
||||||
|
selected = self._selected()
|
||||||
|
if not selected or not selected.get("path"):
|
||||||
|
return
|
||||||
|
entry = next((unit for unit in self._config["units"] if unit["path"] == selected["path"]), None)
|
||||||
|
if entry is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def completed(unit_id: str) -> None:
|
||||||
|
entry["unit_id"] = str(unit_id)
|
||||||
|
entry["binding_intent"] = True
|
||||||
|
self._set_feedback("Unit bound in Core")
|
||||||
|
self._emit_config()
|
||||||
|
|
||||||
|
self._run_async("Bind", lambda: self._adapter.bind_unit(entry["path"]), completed)
|
||||||
|
|
||||||
|
def _unbind_selected(self) -> None:
|
||||||
|
selected = self._selected()
|
||||||
|
if not selected or not selected.get("unit_id"):
|
||||||
|
return
|
||||||
|
unit_id = selected["unit_id"]
|
||||||
|
entry = next((unit for unit in self._config["units"] if unit.get("unit_id") == unit_id), None)
|
||||||
|
|
||||||
|
def completed(_result: Any) -> None:
|
||||||
|
if entry is not None:
|
||||||
|
entry["unit_id"] = None
|
||||||
|
entry["binding_intent"] = False
|
||||||
|
self._emit_config()
|
||||||
|
self._set_feedback("Unit unbound from Core")
|
||||||
|
|
||||||
|
self._run_async("Unbind", lambda: self._adapter.unbind_unit(unit_id), completed)
|
||||||
|
|
||||||
|
def _reload_selected(self) -> None:
|
||||||
|
selected = self._selected()
|
||||||
|
if not selected or selected.get("external") or not selected.get("path") or not selected.get("unit_id"):
|
||||||
|
return
|
||||||
|
old_id = selected["unit_id"]
|
||||||
|
entry = next((unit for unit in self._config["units"] if unit.get("unit_id") == old_id), None)
|
||||||
|
if entry is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def completed(result: dict[str, Any]) -> None:
|
||||||
|
if result.get("state") == "bound":
|
||||||
|
entry["unit_id"] = str(result["unit_id"])
|
||||||
|
entry["binding_intent"] = True
|
||||||
|
self._set_feedback("Saved unit reloaded and bound in Core")
|
||||||
|
else:
|
||||||
|
entry["unit_id"] = None
|
||||||
|
entry["binding_intent"] = True
|
||||||
|
self._set_feedback(f"Reload failed after unbind; unit is unbound: {result.get('error_message', 'unknown error')}")
|
||||||
|
self._emit_config()
|
||||||
|
|
||||||
|
self._run_async("Reload saved", lambda: self._adapter.reload_unit(old_id, entry["path"]), completed)
|
||||||
|
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Small editor for the documented manifest.json/unit.py DLUnit archive."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
from zipfile import ZIP_DEFLATED, ZipFile
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QColor, QFont, QFontDatabase, QSyntaxHighlighter, QTextCharFormat
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QFileDialog,
|
||||||
|
QFormLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QMessageBox,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QSplitter,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _PythonHighlighter(QSyntaxHighlighter):
|
||||||
|
def __init__(self, document: Any) -> None:
|
||||||
|
super().__init__(document)
|
||||||
|
self._rules: list[tuple[re.Pattern[str], QTextCharFormat]] = []
|
||||||
|
keyword = QTextCharFormat()
|
||||||
|
keyword.setForeground(QColor("#c792ea"))
|
||||||
|
keyword.setFontWeight(QFont.Weight.Bold)
|
||||||
|
self._rules.extend(
|
||||||
|
(re.compile(rf"\b{word}\b"), keyword)
|
||||||
|
for word in ("def", "return", "import", "from", "as", "if", "else", "for", "in", "class", "True", "False", "None")
|
||||||
|
)
|
||||||
|
string = QTextCharFormat()
|
||||||
|
string.setForeground(QColor("#c3e88d"))
|
||||||
|
self._rules.extend(
|
||||||
|
((re.compile(r"'(?:[^'\\]|\\.)*'"), string), (re.compile(r'"(?:[^"\\]|\\.)*"'), string))
|
||||||
|
)
|
||||||
|
comment = QTextCharFormat()
|
||||||
|
comment.setForeground(QColor("#91a3b8"))
|
||||||
|
self._rules.append((re.compile(r"#[^\n]*"), comment))
|
||||||
|
|
||||||
|
def highlightBlock(self, text: str) -> None:
|
||||||
|
for pattern, style in self._rules:
|
||||||
|
for match in pattern.finditer(text):
|
||||||
|
self.setFormat(match.start(), match.end() - match.start(), style)
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptor(value: dict[str, Any], *, default_origin: str = "source") -> dict[str, Any]:
|
||||||
|
result = dict(value)
|
||||||
|
result.pop("value", None)
|
||||||
|
result.setdefault("name", "Signal")
|
||||||
|
result.setdefault("type", "number")
|
||||||
|
result.setdefault("unit", "")
|
||||||
|
result.setdefault("min_value", None)
|
||||||
|
result.setdefault("max_value", None)
|
||||||
|
result.setdefault("timeout_ms", 2000)
|
||||||
|
result.setdefault("origin", default_origin)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def read_dlunit(path: str | Path) -> tuple[dict[str, Any], str]:
|
||||||
|
with ZipFile(path) as archive:
|
||||||
|
manifest = json.loads(archive.read("manifest.json"))
|
||||||
|
source = archive.read("unit.py").decode("utf-8")
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
raise ValueError("manifest.json must contain an object")
|
||||||
|
return manifest, source
|
||||||
|
|
||||||
|
|
||||||
|
def write_dlunit(path: str | Path, manifest: dict[str, Any], source: str) -> Path:
|
||||||
|
"""Write the public two-entry archive atomically, preserving extra fields."""
|
||||||
|
target = Path(path)
|
||||||
|
if target.suffix.lower() != ".dlunit":
|
||||||
|
target = target.with_suffix(".dlunit")
|
||||||
|
target.parent.mkdir(parents=False, exist_ok=True)
|
||||||
|
temporary: Path | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
dir=target.parent, prefix=f".{target.name}.", suffix=".tmp", delete=False
|
||||||
|
) as handle:
|
||||||
|
temporary = Path(handle.name)
|
||||||
|
with ZipFile(handle, "w", compression=ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr("manifest.json", json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
|
||||||
|
archive.writestr("unit.py", source)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
temporary.replace(target)
|
||||||
|
finally:
|
||||||
|
if temporary is not None:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
class DLUnitEditorDialog(QDialog):
|
||||||
|
"""Form editor and plain Python source editor for a single DLUnit file."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path | None = None,
|
||||||
|
signals: list[dict[str, Any]] | None = None,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("DLUnit editor")
|
||||||
|
self.resize(900, 650)
|
||||||
|
self.path = Path(path) if path else None
|
||||||
|
self.load_error: str | None = None
|
||||||
|
self._original_manifest: dict[str, Any] = {}
|
||||||
|
self._original_inputs: dict[str, dict[str, Any]] = {}
|
||||||
|
self._available = {str(item.get("id")): dict(item) for item in (signals or []) if item.get("id")}
|
||||||
|
|
||||||
|
self.trust_warning = QLabel(
|
||||||
|
"Trusted code only: DynaLab Core executes this Python source when the unit is bound."
|
||||||
|
)
|
||||||
|
self.trust_warning.setWordWrap(True)
|
||||||
|
self.unit_uuid = QLineEdit()
|
||||||
|
self.input_list = QListWidget()
|
||||||
|
self.input_list.setDragDropMode(QListWidget.DragDropMode.InternalMove)
|
||||||
|
self.input_list.setToolTip("Inputs are passed to process in this order. Drag to reorder.")
|
||||||
|
self.input_list.setToolTip("Checked inputs are passed to process() in this order; drag to reorder")
|
||||||
|
self.input_list.model().rowsMoved.connect(self._renumber_inputs)
|
||||||
|
self.output_uuid = QLineEdit()
|
||||||
|
self.output_name = QLineEdit()
|
||||||
|
self.output_type = QLineEdit("number")
|
||||||
|
self.output_unit = QLineEdit()
|
||||||
|
self.output_min = QLineEdit()
|
||||||
|
self.output_max = QLineEdit()
|
||||||
|
self.comments = QPlainTextEdit()
|
||||||
|
self.source = QPlainTextEdit()
|
||||||
|
self.source.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
|
||||||
|
self.source.setTabStopDistance(self.source.fontMetrics().horizontalAdvance(" ") * 4)
|
||||||
|
self._highlighter = _PythonHighlighter(self.source.document())
|
||||||
|
form_widget = QWidget()
|
||||||
|
form = QFormLayout(form_widget)
|
||||||
|
form.addRow("Unit UUID", self.unit_uuid)
|
||||||
|
form.addRow("Inputs (ordered)", self.input_list)
|
||||||
|
form.addRow("Output UUID", self.output_uuid)
|
||||||
|
form.addRow("Output name", self.output_name)
|
||||||
|
form.addRow("Output type", self.output_type)
|
||||||
|
form.addRow("Output unit", self.output_unit)
|
||||||
|
form.addRow("Minimum", self.output_min)
|
||||||
|
form.addRow("Maximum", self.output_max)
|
||||||
|
form.addRow("Comments", self.comments)
|
||||||
|
code_widget = QWidget()
|
||||||
|
code_layout = QVBoxLayout(code_widget)
|
||||||
|
code_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
code_layout.addWidget(QLabel("Python source (unit.py)"))
|
||||||
|
code_layout.addWidget(self.source, 1)
|
||||||
|
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||||
|
splitter.addWidget(form_widget)
|
||||||
|
splitter.addWidget(code_widget)
|
||||||
|
splitter.setStretchFactor(0, 1)
|
||||||
|
splitter.setStretchFactor(1, 2)
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
|
||||||
|
buttons.accepted.connect(self._save)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.addWidget(self.trust_warning)
|
||||||
|
layout.addWidget(splitter, 1)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
if self.path:
|
||||||
|
self._load(self.path)
|
||||||
|
else:
|
||||||
|
self._load_new()
|
||||||
|
self._populate_inputs()
|
||||||
|
|
||||||
|
def _load(self, path: Path) -> None:
|
||||||
|
try:
|
||||||
|
manifest, source = read_dlunit(path)
|
||||||
|
inputs = manifest.get("input_signals", [])
|
||||||
|
output = manifest.get("return_signal", {})
|
||||||
|
if not isinstance(inputs, list) or not all(isinstance(item, dict) for item in inputs) or not isinstance(output, dict):
|
||||||
|
raise ValueError("manifest must contain input_signals and return_signal")
|
||||||
|
self._original_manifest = manifest
|
||||||
|
self._original_inputs = {str(item.get("id")): _descriptor(item) for item in inputs}
|
||||||
|
self.unit_uuid.setText(str(manifest.get("unit_uuid", "")))
|
||||||
|
output = _descriptor(output, default_origin="derived")
|
||||||
|
self.output_uuid.setText(output["id"])
|
||||||
|
self.output_name.setText(str(output["name"]))
|
||||||
|
self.output_type.setText(str(output["type"]))
|
||||||
|
self.output_unit.setText(str(output.get("unit") or ""))
|
||||||
|
self.output_min.setText("" if output.get("min_value") is None else str(output["min_value"]))
|
||||||
|
self.output_max.setText("" if output.get("max_value") is None else str(output["max_value"]))
|
||||||
|
self.comments.setPlainText(str(manifest.get("comments", "")))
|
||||||
|
self.source.setPlainText(source)
|
||||||
|
except Exception as error:
|
||||||
|
self.load_error = str(error)
|
||||||
|
|
||||||
|
def _load_new(self) -> None:
|
||||||
|
self.unit_uuid.setText(str(uuid4()))
|
||||||
|
self.output_uuid.setText(str(uuid4()))
|
||||||
|
self.output_name.setText("Derived signal")
|
||||||
|
self._original_manifest = {"unit_uuid": self.unit_uuid.text(), "input_signals": [], "return_signal": {}, "comments": ""}
|
||||||
|
self.source.setPlainText(
|
||||||
|
"from dynalab_core.protocols.packets.data import ValueDescriptor\n"
|
||||||
|
"from dynalab_core.protocols.packets.handshake import SignalDescriptor\n\n"
|
||||||
|
"def process(result: SignalDescriptor) -> ValueDescriptor:\n"
|
||||||
|
" return ValueDescriptor(result.id, 0.0, 0)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _populate_inputs(self) -> None:
|
||||||
|
descriptors = dict(self._original_inputs)
|
||||||
|
for signal_id, active in self._available.items():
|
||||||
|
if signal_id not in descriptors:
|
||||||
|
descriptors[signal_id] = _descriptor(active)
|
||||||
|
self.input_list.clear()
|
||||||
|
for index, (signal_id, descriptor) in enumerate(descriptors.items(), start=1):
|
||||||
|
item = QListWidgetItem(f"{index}. {descriptor.get('name', signal_id)} · {signal_id}")
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, descriptor)
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(Qt.CheckState.Checked if signal_id in self._original_inputs else Qt.CheckState.Unchecked)
|
||||||
|
if signal_id not in self._available:
|
||||||
|
item.setToolTip("Unavailable in the current mode; retained from the saved manifest")
|
||||||
|
self.input_list.addItem(item)
|
||||||
|
|
||||||
|
def _renumber_inputs(self, *_args: Any) -> None:
|
||||||
|
for index in range(self.input_list.count()):
|
||||||
|
item = self.input_list.item(index)
|
||||||
|
descriptor = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
item.setText(f"{index + 1}. {descriptor.get('name', descriptor.get('id'))} · {descriptor.get('id')}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _number(text: str, label: str) -> float | None:
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(text)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(f"{label} must be a number") from error
|
||||||
|
|
||||||
|
def _build_manifest(self) -> dict[str, Any]:
|
||||||
|
unit_id = str(UUID(self.unit_uuid.text().strip()))
|
||||||
|
output_id = str(UUID(self.output_uuid.text().strip()))
|
||||||
|
selected: list[dict[str, Any]] = []
|
||||||
|
for index in range(self.input_list.count()):
|
||||||
|
item = self.input_list.item(index)
|
||||||
|
if item.checkState() == Qt.CheckState.Checked:
|
||||||
|
descriptor = _descriptor(item.data(Qt.ItemDataRole.UserRole))
|
||||||
|
descriptor["id"] = str(UUID(str(descriptor.get("id", ""))))
|
||||||
|
if not str(descriptor.get("name", "")).strip() or descriptor.get("type") not in {"number", "binary"}:
|
||||||
|
raise ValueError("Each input needs a name and a number or binary type")
|
||||||
|
selected.append(descriptor)
|
||||||
|
min_value = self._number(self.output_min.text(), "Minimum")
|
||||||
|
max_value = self._number(self.output_max.text(), "Maximum")
|
||||||
|
if min_value is not None and max_value is not None and min_value > max_value:
|
||||||
|
raise ValueError("Minimum must not exceed maximum")
|
||||||
|
output = _descriptor(self._original_manifest.get("return_signal", {}), default_origin="derived")
|
||||||
|
output.update(
|
||||||
|
{
|
||||||
|
"id": output_id,
|
||||||
|
"name": self.output_name.text().strip(),
|
||||||
|
"type": self.output_type.text().strip(),
|
||||||
|
"unit": self.output_unit.text().strip(),
|
||||||
|
"min_value": min_value,
|
||||||
|
"max_value": max_value,
|
||||||
|
"origin": "derived",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not output["name"] or output["type"] not in {"number", "binary"}:
|
||||||
|
raise ValueError("Output name and a number or binary type are required")
|
||||||
|
manifest = dict(self._original_manifest)
|
||||||
|
manifest.update(
|
||||||
|
{
|
||||||
|
"unit_uuid": unit_id,
|
||||||
|
"input_signals": selected,
|
||||||
|
"return_signal": output,
|
||||||
|
"comments": self.comments.toPlainText(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
try:
|
||||||
|
manifest = self._build_manifest()
|
||||||
|
target = self.path
|
||||||
|
if target is None:
|
||||||
|
filename, _ = QFileDialog.getSaveFileName(self, "Save DLUnit", "", "DLUnit (*.dlunit)")
|
||||||
|
if not filename:
|
||||||
|
return
|
||||||
|
target = Path(filename)
|
||||||
|
self.path = write_dlunit(target, manifest, self.source.toPlainText())
|
||||||
|
except Exception as error:
|
||||||
|
QMessageBox.warning(self, "Could not save DLUnit", str(error))
|
||||||
|
return
|
||||||
|
self._original_manifest = manifest
|
||||||
|
self.accept()
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="8" viewBox="0 0 12 8"><path d="m2 2 4 4 4-4" fill="none" stroke="#aeb2b9" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 207 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M19.4 13.5a7.8 7.8 0 0 0 0-3l1.6-1.2-1.6-2.8-1.9.7a7.4 7.4 0 0 0-2.6-1.5L14.6 3h-3.2l-.3 2.7a7.4 7.4 0 0 0-2.6 1.5l-1.9-.7L5 9.3l1.6 1.2a7.8 7.8 0 0 0 0 3L5 14.7l1.6 2.8 1.9-.7a7.4 7.4 0 0 0 2.6 1.5l.3 2.7h3.2l.3-2.7a7.4 7.4 0 0 0 2.6-1.5l1.9.7 1.6-2.8-1.6-1.2Z" stroke="#aeb2b9" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<circle cx="13" cy="12" r="3" stroke="#aeb2b9" stroke-width="1.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,232 @@
|
|||||||
|
"""Resizable collections of independent gauge or numeric panels."""
|
||||||
|
|
||||||
|
from math import ceil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QSize, Signal
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLayout,
|
||||||
|
QMenu,
|
||||||
|
QPushButton,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .panels import GaugePanel, NumericPanel
|
||||||
|
|
||||||
|
|
||||||
|
class InstrumentGroupPanel(QWidget):
|
||||||
|
"""A grid of independently selectable gauges or numeric readouts."""
|
||||||
|
|
||||||
|
configChanged = Signal(dict)
|
||||||
|
removeRequested = Signal()
|
||||||
|
minimumSizeChanged = Signal()
|
||||||
|
|
||||||
|
_MIN_INSTRUMENT_WIDTH = 190
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
kind = config.get("kind", "gauge")
|
||||||
|
if kind not in {"gauge", "numeric"}:
|
||||||
|
raise ValueError("Instrument group kind must be 'gauge' or 'numeric'.")
|
||||||
|
self.kind = kind
|
||||||
|
self._signals: list[dict[str, Any]] = []
|
||||||
|
self._instruments: list[GaugePanel | NumericPanel] = []
|
||||||
|
self._columns = 0
|
||||||
|
self._reported_minimum: QSize | None = None
|
||||||
|
|
||||||
|
self.title = QLabel("")
|
||||||
|
self.title.hide()
|
||||||
|
self.title.setObjectName("panelHeaderTitle")
|
||||||
|
self.add_button = QPushButton("+")
|
||||||
|
self.add_button.setFixedSize(24, 24)
|
||||||
|
self.add_button.setAccessibleName("Add gauge" if kind == "gauge" else "Add readout")
|
||||||
|
self.add_button.setToolTip(self.add_button.accessibleName())
|
||||||
|
self.add_button.clicked.connect(lambda: self._add_instrument({"signal_id": None}))
|
||||||
|
|
||||||
|
self.settings_menu = QMenu(self)
|
||||||
|
self.settings_menu.addAction("Remove group", self.removeRequested.emit)
|
||||||
|
settings = QToolButton()
|
||||||
|
settings.setObjectName("panelSettingsButton")
|
||||||
|
settings.setIcon(QIcon(str(Path(__file__).parent / "icons" / "gear.svg")))
|
||||||
|
settings.setIconSize(QSize(16, 16))
|
||||||
|
settings.setAccessibleName("Group settings")
|
||||||
|
settings.setToolTip("Configure this group")
|
||||||
|
settings.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||||
|
settings.setMenu(self.settings_menu)
|
||||||
|
settings.setAutoRaise(True)
|
||||||
|
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header.addStretch(1)
|
||||||
|
header.addWidget(self.add_button)
|
||||||
|
header.addWidget(settings)
|
||||||
|
|
||||||
|
self.grid = QGridLayout()
|
||||||
|
self.grid.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.grid.setSpacing(4)
|
||||||
|
self.grid.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
||||||
|
self._layout = QVBoxLayout(self)
|
||||||
|
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
||||||
|
self._layout.addLayout(header)
|
||||||
|
self._layout.addLayout(self.grid)
|
||||||
|
self._layout.setContentsMargins(6, 2, 6, 4)
|
||||||
|
self._layout.setSpacing(2)
|
||||||
|
|
||||||
|
for item in config.get("items", []):
|
||||||
|
if isinstance(item, dict):
|
||||||
|
self._add_instrument(item, emit=False)
|
||||||
|
self._arrange()
|
||||||
|
|
||||||
|
def config(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": self.kind,
|
||||||
|
"items": [panel.config() for panel in self._instruments],
|
||||||
|
}
|
||||||
|
|
||||||
|
def refresh(self, signals: list[dict[str, Any]] | None) -> None:
|
||||||
|
self._signals = signals or []
|
||||||
|
for panel in self._instruments:
|
||||||
|
panel.refresh(self._signals)
|
||||||
|
self._arrange()
|
||||||
|
self.updateGeometry()
|
||||||
|
|
||||||
|
def minimumSizeHint(self) -> QSize:
|
||||||
|
width = max(self._available_grid_width(), self._MIN_INSTRUMENT_WIDTH)
|
||||||
|
columns = self._columns_for_width(width)
|
||||||
|
rows = ceil(len(self._instruments) / columns) if self._instruments else 0
|
||||||
|
row_height = self._row_height(width, columns)
|
||||||
|
margins = self._layout.contentsMargins()
|
||||||
|
height = margins.top() + margins.bottom() + self._header_height() + self._layout.spacing() + rows * row_height
|
||||||
|
if rows > 1:
|
||||||
|
height += (rows - 1) * self.grid.verticalSpacing()
|
||||||
|
return QSize(self._header_minimum_width(), height)
|
||||||
|
|
||||||
|
def resizeEvent(self, event: Any) -> None:
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self._arrange()
|
||||||
|
|
||||||
|
def _add_instrument(self, item: dict[str, Any], *, emit: bool = True) -> None:
|
||||||
|
panel_type = GaugePanel if self.kind == "gauge" else NumericPanel
|
||||||
|
panel = panel_type(item)
|
||||||
|
panel.setMinimumWidth(self._MIN_INSTRUMENT_WIDTH)
|
||||||
|
panel.settings_menu.removeAction(panel.settings_menu.actions()[-1])
|
||||||
|
panel.settings_menu.addAction("Remove instrument")
|
||||||
|
panel.settings_menu.actions()[-1].triggered.connect(
|
||||||
|
lambda _checked=False, child=panel: self._remove_instrument(child)
|
||||||
|
)
|
||||||
|
panel.removeRequested.connect(lambda child=panel: self._remove_instrument(child))
|
||||||
|
panel.configChanged.connect(lambda _config, child=panel: self._instrument_changed(child))
|
||||||
|
self._instruments.append(panel)
|
||||||
|
self.grid.addWidget(panel, 0, 0)
|
||||||
|
if self._signals:
|
||||||
|
panel.refresh(self._signals)
|
||||||
|
self._arrange(force_reflow=True, force_minimum_signal=True)
|
||||||
|
self.updateGeometry()
|
||||||
|
if emit:
|
||||||
|
self._emit_config()
|
||||||
|
|
||||||
|
def _remove_instrument(self, panel: GaugePanel | NumericPanel) -> None:
|
||||||
|
if panel not in self._instruments:
|
||||||
|
return
|
||||||
|
self._instruments.remove(panel)
|
||||||
|
self.grid.removeWidget(panel)
|
||||||
|
panel.deleteLater()
|
||||||
|
self._arrange(force_reflow=True, force_minimum_signal=True)
|
||||||
|
self.updateGeometry()
|
||||||
|
self._emit_config()
|
||||||
|
|
||||||
|
def _instrument_changed(self, _panel: GaugePanel | NumericPanel) -> None:
|
||||||
|
self._emit_config()
|
||||||
|
|
||||||
|
def _emit_config(self) -> None:
|
||||||
|
self.configChanged.emit(self.config())
|
||||||
|
|
||||||
|
def _arrange(self, *, force_reflow: bool = False, force_minimum_signal: bool = False) -> None:
|
||||||
|
columns = self._columns_for_width(self._available_grid_width())
|
||||||
|
reflowed = force_reflow or columns != self._columns
|
||||||
|
if reflowed:
|
||||||
|
while self.grid.count():
|
||||||
|
self.grid.takeAt(0)
|
||||||
|
for index, panel in enumerate(self._instruments):
|
||||||
|
self.grid.addWidget(panel, index // columns, index % columns)
|
||||||
|
for column in range(max(columns, self.grid.columnCount())):
|
||||||
|
self.grid.setColumnStretch(column, 1 if column < columns else 0)
|
||||||
|
self._columns = columns
|
||||||
|
self._set_row_heights(columns)
|
||||||
|
minimum = self.minimumSizeHint()
|
||||||
|
size_changed = minimum != self._reported_minimum
|
||||||
|
if force_minimum_signal or reflowed or size_changed:
|
||||||
|
self._reported_minimum = minimum
|
||||||
|
self.updateGeometry()
|
||||||
|
self.minimumSizeChanged.emit()
|
||||||
|
|
||||||
|
def _available_grid_width(self) -> int:
|
||||||
|
outer = self._layout.contentsMargins()
|
||||||
|
inner = self.grid.contentsMargins()
|
||||||
|
return max(
|
||||||
|
self._MIN_INSTRUMENT_WIDTH,
|
||||||
|
self.contentsRect().width()
|
||||||
|
- outer.left()
|
||||||
|
- outer.right()
|
||||||
|
- inner.left()
|
||||||
|
- inner.right(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _columns_for_width(self, width: int) -> int:
|
||||||
|
spacing = max(0, self.grid.horizontalSpacing())
|
||||||
|
return max(1, (width + spacing) // (self._MIN_INSTRUMENT_WIDTH + spacing))
|
||||||
|
|
||||||
|
def _header_minimum_width(self) -> int:
|
||||||
|
margins = self._layout.contentsMargins()
|
||||||
|
grid_margins = self.grid.contentsMargins()
|
||||||
|
header = self._layout.itemAt(0).layout()
|
||||||
|
header_width = header.sizeHint().width() if header is not None else 0
|
||||||
|
instrument_width = max(
|
||||||
|
(panel.minimumSizeHint().width() for panel in self._instruments),
|
||||||
|
default=self._MIN_INSTRUMENT_WIDTH,
|
||||||
|
)
|
||||||
|
grid_width = (
|
||||||
|
margins.left()
|
||||||
|
+ margins.right()
|
||||||
|
+ grid_margins.left()
|
||||||
|
+ grid_margins.right()
|
||||||
|
+ instrument_width
|
||||||
|
)
|
||||||
|
return max(margins.left() + margins.right() + header_width, grid_width)
|
||||||
|
|
||||||
|
def _header_height(self) -> int:
|
||||||
|
header = self._layout.itemAt(0).layout()
|
||||||
|
return header.sizeHint().height() if header is not None else 0
|
||||||
|
|
||||||
|
def _row_height(self, width: int, columns: int) -> int:
|
||||||
|
if not self._instruments:
|
||||||
|
return 0
|
||||||
|
spacing = max(0, self.grid.horizontalSpacing())
|
||||||
|
item_width = max(
|
||||||
|
self._MIN_INSTRUMENT_WIDTH,
|
||||||
|
(width - max(0, columns - 1) * spacing) // columns,
|
||||||
|
)
|
||||||
|
heights = []
|
||||||
|
for panel in self._instruments:
|
||||||
|
hinted = max(panel.minimumSizeHint().height(), panel.sizeHint().height())
|
||||||
|
height_for_width = panel.heightForWidth(item_width)
|
||||||
|
if height_for_width > 0:
|
||||||
|
hinted = max(hinted, height_for_width)
|
||||||
|
heights.append(hinted)
|
||||||
|
return max(heights, default=0)
|
||||||
|
|
||||||
|
def _set_row_heights(self, columns: int) -> None:
|
||||||
|
for row in range(self.grid.rowCount()):
|
||||||
|
self.grid.setRowMinimumHeight(row, 0)
|
||||||
|
if not self._instruments:
|
||||||
|
return
|
||||||
|
row_height = self._row_height(self._available_grid_width(), columns)
|
||||||
|
rows = ceil(len(self._instruments) / columns)
|
||||||
|
for row in range(rows):
|
||||||
|
self.grid.setRowMinimumHeight(row, row_height)
|
||||||
@@ -0,0 +1,877 @@
|
|||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pyqtgraph as pg
|
||||||
|
from PySide6.QtCore import QRectF, QSize, Qt, Signal
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QColor,
|
||||||
|
QFont,
|
||||||
|
QFontMetrics,
|
||||||
|
QIcon,
|
||||||
|
QPainter,
|
||||||
|
QPainterPath,
|
||||||
|
QPen,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QMenu,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
QWidgetAction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _PanelHeader(QWidget):
|
||||||
|
def __init__(self, title: str, settings_menu: QMenu, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.label = QLabel(title)
|
||||||
|
self.label.setObjectName("panelHeaderTitle")
|
||||||
|
self.label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||||
|
self._title = title
|
||||||
|
self.settings = QToolButton()
|
||||||
|
self.settings.setObjectName("panelSettingsButton")
|
||||||
|
self.settings.setIcon(QIcon(str(Path(__file__).parent / "icons" / "gear.svg")))
|
||||||
|
self.settings.setIconSize(QSize(18, 18))
|
||||||
|
self.settings.setAccessibleName("Panel settings")
|
||||||
|
self.settings.setToolTip("Configure this panel")
|
||||||
|
self.settings.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||||
|
self.settings.setMenu(settings_menu)
|
||||||
|
self.settings.setAutoRaise(True)
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.addWidget(self.label, 1)
|
||||||
|
layout.addWidget(self.settings)
|
||||||
|
|
||||||
|
def set_title(self, title: str) -> None:
|
||||||
|
self._title = title
|
||||||
|
self.label.setToolTip(title)
|
||||||
|
self._elide_title()
|
||||||
|
|
||||||
|
def resizeEvent(self, event: Any) -> None:
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self._elide_title()
|
||||||
|
|
||||||
|
def _elide_title(self) -> None:
|
||||||
|
self.label.setText(QFontMetrics(self.label.font()).elidedText(
|
||||||
|
self._title,
|
||||||
|
Qt.TextElideMode.ElideRight,
|
||||||
|
max(0, self.width() - self.settings.sizeHint().width() - 8),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_menu(parent: QWidget, title: str) -> QMenu:
|
||||||
|
menu = QMenu(parent)
|
||||||
|
heading = QLabel(title)
|
||||||
|
heading.setObjectName("panelSettingsTitle")
|
||||||
|
heading.setContentsMargins(12, 8, 12, 4)
|
||||||
|
action = QWidgetAction(menu)
|
||||||
|
action.setDefaultWidget(heading)
|
||||||
|
menu.addAction(action)
|
||||||
|
return menu
|
||||||
|
|
||||||
|
|
||||||
|
def _add_settings_widget(menu: QMenu, widget: QWidget) -> None:
|
||||||
|
action = QWidgetAction(menu)
|
||||||
|
action.setDefaultWidget(widget)
|
||||||
|
menu.addAction(action)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalBrowser(QWidget):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.signals: list[dict[str, Any]] | None = None
|
||||||
|
self.search = QLineEdit()
|
||||||
|
self.search.setPlaceholderText("Search signals…")
|
||||||
|
self.search.setAccessibleName("Search signals by name or ID")
|
||||||
|
self.items = QListWidget()
|
||||||
|
self.items.setWordWrap(True)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.addWidget(self.search)
|
||||||
|
layout.addWidget(self.items, 1)
|
||||||
|
self.search.textChanged.connect(self._render)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def refresh(self, signals: list[dict[str, Any]] | None) -> None:
|
||||||
|
self.signals = signals
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
selected = self.items.currentItem()
|
||||||
|
selected_id = selected.data(Qt.ItemDataRole.UserRole) if selected is not None else None
|
||||||
|
self.items.clear()
|
||||||
|
if self.signals is None:
|
||||||
|
self.items.addItem("Start a Core session to view signals")
|
||||||
|
return
|
||||||
|
query = self.search.text().strip().casefold()
|
||||||
|
for signal in self.signals:
|
||||||
|
if query and query not in (signal["name"] + " " + signal["id"]).casefold():
|
||||||
|
continue
|
||||||
|
unit = f" {signal['unit']}" if signal["unit"] else ""
|
||||||
|
current = f"{signal['value']:.6g}{unit}" if signal["value"] is not None else "No current value"
|
||||||
|
item = QListWidgetItem(f"{signal['name']} — {current}")
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, signal["id"])
|
||||||
|
item.setToolTip(f"{signal['id']} · {signal['type']}")
|
||||||
|
self.items.addItem(item)
|
||||||
|
if signal["id"] == selected_id:
|
||||||
|
self.items.setCurrentItem(item)
|
||||||
|
if not self.items.count():
|
||||||
|
self.items.addItem("No matching signals" if query else "No signals available in this mode")
|
||||||
|
|
||||||
|
|
||||||
|
class ArcGauge(QWidget):
|
||||||
|
"""Read-only gauge with a bounded value fraction in [0, 1]."""
|
||||||
|
|
||||||
|
TRACK = QColor("#3a3d42")
|
||||||
|
ARC = QColor("#b5bec9")
|
||||||
|
TEXT = QColor("#dedfe1")
|
||||||
|
MUTED = QColor("#a0a3a8")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.fraction: float | None = None
|
||||||
|
self.minimum: float | None = None
|
||||||
|
self.maximum: float | None = None
|
||||||
|
self.value_text = ""
|
||||||
|
self.unit = ""
|
||||||
|
self.decimals = 2
|
||||||
|
self.setMinimumSize(120, 110)
|
||||||
|
|
||||||
|
def set_fraction(self, fraction: float | None) -> None:
|
||||||
|
self.fraction = fraction
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_reading(self, value: float | None, unit: str, minimum: float | None,
|
||||||
|
maximum: float | None, decimals: int) -> None:
|
||||||
|
self.minimum, self.maximum = minimum, maximum
|
||||||
|
self.unit, self.decimals = unit, decimals
|
||||||
|
self.value_text = "—" if value is None else f"{value:.{decimals}f}"
|
||||||
|
if value is None or minimum is None or maximum is None or maximum <= minimum:
|
||||||
|
self.set_fraction(None)
|
||||||
|
else:
|
||||||
|
self.set_fraction(min(1.0, max(0.0, (value - minimum) / (maximum - minimum))))
|
||||||
|
|
||||||
|
def paintEvent(self, _event: Any) -> None:
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
diameter = max(1, min(self.width() - 20, self.height() - 28))
|
||||||
|
circle_x = (self.width() - diameter) / 2
|
||||||
|
circle_y = max(4, (self.height() - diameter - 18) / 2)
|
||||||
|
rect = QRectF(circle_x, circle_y, diameter, diameter)
|
||||||
|
pen = QPen(self.TRACK, max(3, min(5, self.width() / 70)), Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
path = QPainterPath()
|
||||||
|
path.arcMoveTo(rect, 225)
|
||||||
|
path.arcTo(rect, 225, -270)
|
||||||
|
painter.drawPath(path)
|
||||||
|
if self.fraction is not None:
|
||||||
|
pen.setColor(self.ARC)
|
||||||
|
painter.setPen(pen)
|
||||||
|
path = QPainterPath()
|
||||||
|
path.arcMoveTo(rect, 225)
|
||||||
|
path.arcTo(rect, 225, -270 * self.fraction)
|
||||||
|
painter.drawPath(path)
|
||||||
|
center = rect.center()
|
||||||
|
radius = diameter / 2
|
||||||
|
start_point = (center.x() - radius * .7071, center.y() + radius * .7071)
|
||||||
|
end_point = (center.x() + radius * .7071, center.y() + radius * .7071)
|
||||||
|
label_width = min(78, diameter * .42)
|
||||||
|
label_height = 18
|
||||||
|
label_font = QFont(painter.font())
|
||||||
|
label_font.setPointSize(max(7, min(10, int(diameter / 28))))
|
||||||
|
for label, point, alignment in (
|
||||||
|
("" if self.minimum is None else f"{self.minimum:g}", start_point, Qt.AlignmentFlag.AlignCenter),
|
||||||
|
("" if self.maximum is None else f"{self.maximum:g}", end_point, Qt.AlignmentFlag.AlignCenter),
|
||||||
|
):
|
||||||
|
while label_font.pointSize() > 7 and QFontMetrics(label_font).horizontalAdvance(label) > label_width:
|
||||||
|
label_font.setPointSize(label_font.pointSize() - 1)
|
||||||
|
painter.setFont(label_font)
|
||||||
|
painter.setPen(self.MUTED)
|
||||||
|
label_rect = QRectF(point[0] - label_width / 2, point[1] + 2, label_width, label_height)
|
||||||
|
painter.drawText(label_rect, alignment, label)
|
||||||
|
|
||||||
|
inner_width = diameter * .76
|
||||||
|
value_font = QFont(painter.font())
|
||||||
|
value_font.setPointSize(max(10, min(38, int(diameter * .18))))
|
||||||
|
value_font.setBold(True)
|
||||||
|
while value_font.pointSize() > 10 and QFontMetrics(value_font).horizontalAdvance(self.value_text) > inner_width:
|
||||||
|
value_font.setPointSize(value_font.pointSize() - 1)
|
||||||
|
unit_font = QFont(label_font)
|
||||||
|
unit_font.setPointSize(max(7, min(12, int(diameter * .055))))
|
||||||
|
unit_width = diameter * .62
|
||||||
|
while self.unit and unit_font.pointSize() > 7 and QFontMetrics(unit_font).horizontalAdvance(self.unit) > unit_width:
|
||||||
|
unit_font.setPointSize(unit_font.pointSize() - 1)
|
||||||
|
value_height = QFontMetrics(value_font).height()
|
||||||
|
unit_height = QFontMetrics(unit_font).height() if self.unit else 0
|
||||||
|
stack_height = value_height + unit_height + (2 if self.unit else 0)
|
||||||
|
value_top = center.y() - stack_height / 2
|
||||||
|
value_rect = QRectF(center.x() - inner_width / 2, value_top, inner_width, value_height)
|
||||||
|
painter.setFont(value_font)
|
||||||
|
painter.setPen(self.TEXT)
|
||||||
|
painter.drawText(value_rect, Qt.AlignmentFlag.AlignCenter, self.value_text)
|
||||||
|
if self.unit:
|
||||||
|
painter.setFont(unit_font)
|
||||||
|
painter.setPen(self.MUTED)
|
||||||
|
unit_rect = QRectF(center.x() - unit_width / 2, value_rect.bottom() + 2, unit_width, unit_height)
|
||||||
|
painter.drawText(unit_rect, Qt.AlignmentFlag.AlignCenter, self.unit)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalChoice(QComboBox):
|
||||||
|
changed = Signal(object)
|
||||||
|
|
||||||
|
def __init__(self, signal_id: str | None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.signal_id = signal_id
|
||||||
|
self._signature: tuple[Any, ...] | None = None
|
||||||
|
self.currentIndexChanged.connect(self._emit_change)
|
||||||
|
|
||||||
|
def set_signals(self, signals: list[dict[str, Any]]) -> None:
|
||||||
|
signature = (self.signal_id, tuple((s["id"], s["name"], s["unit"]) for s in signals))
|
||||||
|
if signature == self._signature:
|
||||||
|
return
|
||||||
|
self._signature = signature
|
||||||
|
self.blockSignals(True)
|
||||||
|
self.clear()
|
||||||
|
self.addItem("Select a signal…", None)
|
||||||
|
available = {signal["id"] for signal in signals}
|
||||||
|
for signal in signals:
|
||||||
|
unit = f" ({signal['unit']})" if signal["unit"] else ""
|
||||||
|
self.addItem(f"{signal['name']}{unit}", signal["id"])
|
||||||
|
if self.signal_id and self.signal_id not in available:
|
||||||
|
self.addItem(f"Unavailable signal ({self.signal_id})", self.signal_id)
|
||||||
|
self.setCurrentIndex(max(0, self.findData(self.signal_id)))
|
||||||
|
self.blockSignals(False)
|
||||||
|
|
||||||
|
def _emit_change(self, index: int) -> None:
|
||||||
|
self.signal_id = self.itemData(index)
|
||||||
|
self.changed.emit(self.signal_id)
|
||||||
|
|
||||||
|
|
||||||
|
class NumericPanel(QWidget):
|
||||||
|
configChanged = Signal(dict)
|
||||||
|
removeRequested = Signal()
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.decimals = max(0, min(8, int(config.get("decimals", 2))))
|
||||||
|
self._signals: list[dict[str, Any]] = []
|
||||||
|
self.choice = SignalChoice(config.get("signal_id"))
|
||||||
|
self.settings_menu = _settings_menu(self, "Readout settings")
|
||||||
|
self._add_common_settings()
|
||||||
|
precision = QWidget()
|
||||||
|
precision_layout = QHBoxLayout(precision)
|
||||||
|
precision_layout.setContentsMargins(12, 2, 12, 8)
|
||||||
|
precision_layout.addWidget(QLabel("Decimal places"))
|
||||||
|
self.precision = QSpinBox()
|
||||||
|
self.precision.setRange(0, 8)
|
||||||
|
self.precision.setValue(self.decimals)
|
||||||
|
self.precision.valueChanged.connect(self._precision_changed)
|
||||||
|
precision_layout.addWidget(self.precision)
|
||||||
|
_add_settings_widget(self.settings_menu, precision)
|
||||||
|
self.settings_menu.addSeparator()
|
||||||
|
self.settings_menu.addAction("Remove panel", self.removeRequested.emit)
|
||||||
|
self.header = _PanelHeader("Select a signal", self.settings_menu)
|
||||||
|
self.value = QLabel("—")
|
||||||
|
self.value.setObjectName("readoutValue")
|
||||||
|
self.value.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.unit = QLabel("")
|
||||||
|
self.unit.setObjectName("muted")
|
||||||
|
self.unit.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.unit.setWordWrap(True)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(2)
|
||||||
|
layout.addWidget(self.header)
|
||||||
|
layout.addStretch(1)
|
||||||
|
layout.addWidget(self.value)
|
||||||
|
layout.addWidget(self.unit)
|
||||||
|
layout.addStretch(1)
|
||||||
|
self.choice.changed.connect(self._changed)
|
||||||
|
|
||||||
|
def _add_common_settings(self) -> None:
|
||||||
|
choice_box = QWidget()
|
||||||
|
choice_layout = QVBoxLayout(choice_box)
|
||||||
|
choice_layout.setContentsMargins(12, 6, 12, 10)
|
||||||
|
choice_layout.addWidget(QLabel("Signal"))
|
||||||
|
choice_layout.addWidget(self.choice)
|
||||||
|
_add_settings_widget(self.settings_menu, choice_box)
|
||||||
|
|
||||||
|
def config(self) -> dict[str, Any]:
|
||||||
|
return {"signal_id": self.choice.signal_id, "decimals": self.decimals}
|
||||||
|
|
||||||
|
def _changed(self, signal_id: str | None) -> None:
|
||||||
|
self.refresh(self._signals)
|
||||||
|
self.configChanged.emit(self.config())
|
||||||
|
|
||||||
|
def _precision_changed(self, decimals: int) -> None:
|
||||||
|
self.decimals = decimals
|
||||||
|
self.configChanged.emit(self.config())
|
||||||
|
self.refresh(self._signals)
|
||||||
|
|
||||||
|
def refresh(self, signals: list[dict[str, Any]]) -> None:
|
||||||
|
self._signals = signals
|
||||||
|
self.choice.set_signals(signals)
|
||||||
|
selected = next((s for s in signals if s["id"] == self.choice.signal_id), None)
|
||||||
|
self.header.set_title(selected["name"] if selected else (f"Unavailable ({self.choice.signal_id})" if self.choice.signal_id else "Select a signal"))
|
||||||
|
if selected is None:
|
||||||
|
self.unit.clear()
|
||||||
|
self.value.setText("Unavailable" if self.choice.signal_id else "—")
|
||||||
|
elif selected["value"] is None:
|
||||||
|
self.unit.setText(selected["unit"] or "")
|
||||||
|
self.value.setText("No current value")
|
||||||
|
else:
|
||||||
|
value = selected["value"]
|
||||||
|
self.value.setText(f"{value:.{self.decimals}f}" if math.isfinite(value) else "—")
|
||||||
|
self.unit.setText(selected["unit"] or "")
|
||||||
|
self._fit_value_font()
|
||||||
|
|
||||||
|
def resizeEvent(self, event: Any) -> None:
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self._fit_value_font()
|
||||||
|
|
||||||
|
def _fit_value_font(self) -> None:
|
||||||
|
available_width = max(40, self.value.width() - 16)
|
||||||
|
font = self.value.font()
|
||||||
|
font.setBold(True)
|
||||||
|
text = self.value.text()
|
||||||
|
point_size = max(10, min(48, int(self.width() * .14)))
|
||||||
|
while point_size > 10:
|
||||||
|
font.setPointSize(point_size)
|
||||||
|
if QFontMetrics(font).horizontalAdvance(text) <= available_width:
|
||||||
|
break
|
||||||
|
point_size -= 1
|
||||||
|
self.value.setFont(font)
|
||||||
|
unit_font = self.unit.font()
|
||||||
|
unit_font.setPointSize(max(8, min(13, int(self.width() * .055))))
|
||||||
|
self.unit.setFont(unit_font)
|
||||||
|
|
||||||
|
|
||||||
|
class GaugePanel(QWidget):
|
||||||
|
configChanged = Signal(dict)
|
||||||
|
removeRequested = Signal()
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.decimals = max(0, min(8, int(config.get("decimals", 2))))
|
||||||
|
self._signals: list[dict[str, Any]] = []
|
||||||
|
self.choice = SignalChoice(config.get("signal_id"))
|
||||||
|
self.settings_menu = _settings_menu(self, "Gauge settings")
|
||||||
|
choice_box = QWidget()
|
||||||
|
choice_layout = QVBoxLayout(choice_box)
|
||||||
|
choice_layout.setContentsMargins(12, 6, 12, 10)
|
||||||
|
choice_layout.addWidget(QLabel("Signal"))
|
||||||
|
choice_layout.addWidget(self.choice)
|
||||||
|
_add_settings_widget(self.settings_menu, choice_box)
|
||||||
|
precision = QWidget()
|
||||||
|
precision_layout = QHBoxLayout(precision)
|
||||||
|
precision_layout.setContentsMargins(12, 2, 12, 8)
|
||||||
|
precision_layout.addWidget(QLabel("Decimal places"))
|
||||||
|
self.precision = QSpinBox()
|
||||||
|
self.precision.setRange(0, 8)
|
||||||
|
self.precision.setValue(self.decimals)
|
||||||
|
self.precision.valueChanged.connect(self._precision_changed)
|
||||||
|
precision_layout.addWidget(self.precision)
|
||||||
|
_add_settings_widget(self.settings_menu, precision)
|
||||||
|
self.settings_menu.addSeparator()
|
||||||
|
self.settings_menu.addAction("Remove panel", self.removeRequested.emit)
|
||||||
|
self.header = _PanelHeader("Select a signal", self.settings_menu)
|
||||||
|
self.gauge = ArcGauge()
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
layout.addWidget(self.header)
|
||||||
|
layout.addWidget(self.gauge, 1)
|
||||||
|
self.choice.changed.connect(self._changed)
|
||||||
|
|
||||||
|
def _changed(self, signal_id: str | None) -> None:
|
||||||
|
self.refresh(self._signals)
|
||||||
|
self.configChanged.emit(self.config())
|
||||||
|
|
||||||
|
def config(self) -> dict[str, Any]:
|
||||||
|
return {"signal_id": self.choice.signal_id, "decimals": self.decimals}
|
||||||
|
|
||||||
|
def _precision_changed(self, decimals: int) -> None:
|
||||||
|
self.decimals = decimals
|
||||||
|
self.configChanged.emit(self.config())
|
||||||
|
self.refresh(self._signals)
|
||||||
|
|
||||||
|
def refresh(self, signals: list[dict[str, Any]]) -> None:
|
||||||
|
self._signals = signals
|
||||||
|
self.choice.set_signals(signals)
|
||||||
|
selected = next((s for s in signals if s["id"] == self.choice.signal_id), None)
|
||||||
|
if selected is None:
|
||||||
|
self.header.set_title(f"Unavailable ({self.choice.signal_id})" if self.choice.signal_id else "Select a signal")
|
||||||
|
self.gauge.set_reading(None, "", None, None, self.decimals)
|
||||||
|
return
|
||||||
|
self.header.set_title(selected["name"])
|
||||||
|
minimum, maximum = selected["min_value"], selected["max_value"]
|
||||||
|
if minimum is not None and not math.isfinite(minimum):
|
||||||
|
minimum = None
|
||||||
|
if maximum is not None and not math.isfinite(maximum):
|
||||||
|
maximum = None
|
||||||
|
value = selected["value"]
|
||||||
|
if value is not None and not math.isfinite(value):
|
||||||
|
value = None
|
||||||
|
self.gauge.set_reading(value, selected["unit"] or "", minimum, maximum, self.decimals)
|
||||||
|
|
||||||
|
|
||||||
|
class GraphPanel(QWidget):
|
||||||
|
configChanged = Signal(dict)
|
||||||
|
removeRequested = Signal()
|
||||||
|
COLORS = ("#88a8c9", "#d2aa7d", "#94b39b", "#b29bc6", "#ce9292", "#a0c0c2")
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.signal_ids = list(config.get("signal_ids", []))
|
||||||
|
self.follow_latest = bool(config.get("follow_latest", True))
|
||||||
|
self.zero_baseline = bool(config.get("zero_baseline", False))
|
||||||
|
self.right_zero_baseline = bool(config.get("right_zero_baseline", False))
|
||||||
|
self.descriptors: dict[str, dict[str, Any]] = {}
|
||||||
|
self.signal_signature: tuple[Any, ...] = ()
|
||||||
|
self.curves: dict[str, pg.PlotDataItem] = {}
|
||||||
|
self.right_view: pg.ViewBox | None = None
|
||||||
|
self._last_snapshot: dict[str, Any] | None = None
|
||||||
|
self._baseline_auto_ranges = {"left": True, "right": True}
|
||||||
|
self._axis_signal_ids: dict[str, list[str]] = {"left": [], "right": []}
|
||||||
|
self.legend: QWidget | None = None
|
||||||
|
self.plot = pg.PlotWidget()
|
||||||
|
self.plot.setBackground("#202225")
|
||||||
|
self.plot_item = self.plot.getPlotItem()
|
||||||
|
self.playback_position_ns: int | None = None
|
||||||
|
self.playback_cursor = pg.InfiniteLine(
|
||||||
|
angle=90,
|
||||||
|
movable=False,
|
||||||
|
pen=pg.mkPen("#a0a3a8", width=1, style=Qt.PenStyle.DashLine),
|
||||||
|
)
|
||||||
|
self.playback_cursor_label = pg.TextItem(
|
||||||
|
color="#dedfe1", anchor=(0.0, 0.0), fill=(32, 34, 37, 230)
|
||||||
|
)
|
||||||
|
self.playback_cursor_label.setFont(QFont("Sans Serif", 8))
|
||||||
|
self.playback_cursor.setZValue(40)
|
||||||
|
self.playback_cursor_label.setZValue(50)
|
||||||
|
self.plot_item.addItem(self.playback_cursor, ignoreBounds=True)
|
||||||
|
self.plot_item.addItem(self.playback_cursor_label, ignoreBounds=True)
|
||||||
|
self.playback_cursor.hide()
|
||||||
|
self.playback_cursor_label.hide()
|
||||||
|
self.plot_item.vb.sigRangeChanged.connect(lambda *_: self._sync_playback_cursor())
|
||||||
|
self.plot_item.vb.sigRangeChangedManually.connect(
|
||||||
|
lambda *_: self._mark_axis_manual("left")
|
||||||
|
)
|
||||||
|
self.plot_item.vb.setMouseMode(pg.ViewBox.RectMode)
|
||||||
|
self.plot_item.setLabel("bottom", "Elapsed time", units="s")
|
||||||
|
self.plot_item.showGrid(x=True, y=True, alpha=0.25)
|
||||||
|
self._style_axes()
|
||||||
|
self.settings_menu = _settings_menu(self, "Graph settings")
|
||||||
|
self.selection = QListWidget()
|
||||||
|
self.selection.setMinimumWidth(280)
|
||||||
|
self.selection.setMaximumHeight(240)
|
||||||
|
self.selection.itemChanged.connect(self._selection_changed)
|
||||||
|
self.follow = QCheckBox("Follow latest")
|
||||||
|
self.follow.setChecked(self.follow_latest)
|
||||||
|
self.follow.toggled.connect(self._follow_changed)
|
||||||
|
self.fit_button = QPushButton("Reset to fit")
|
||||||
|
self.fit_button.clicked.connect(self._fit)
|
||||||
|
self.zero_baseline_check = QCheckBox("Left axis starts at zero")
|
||||||
|
self.zero_baseline_check.setChecked(self.zero_baseline)
|
||||||
|
self.zero_baseline_check.toggled.connect(self._zero_baseline_changed)
|
||||||
|
self.right_zero_baseline_check = QCheckBox("Right axis starts at zero")
|
||||||
|
self.right_zero_baseline_check.setChecked(self.right_zero_baseline)
|
||||||
|
self.right_zero_baseline_check.toggled.connect(self._right_zero_baseline_changed)
|
||||||
|
selection_box = QWidget()
|
||||||
|
selection_layout = QVBoxLayout(selection_box)
|
||||||
|
selection_layout.setContentsMargins(12, 6, 12, 10)
|
||||||
|
selection_layout.addWidget(QLabel("Signals"))
|
||||||
|
selection_layout.addWidget(self.selection)
|
||||||
|
selection_layout.addSpacing(6)
|
||||||
|
selection_layout.addWidget(self.follow)
|
||||||
|
selection_layout.addWidget(self.fit_button)
|
||||||
|
selection_layout.addWidget(self.zero_baseline_check)
|
||||||
|
selection_layout.addWidget(self.right_zero_baseline_check)
|
||||||
|
explanation = QLabel("One signal uses its native scale. Two signals use independent axes. Three or more are normalized to 0–1 using descriptor bounds when available, otherwise the visible data range.")
|
||||||
|
explanation.setObjectName("muted")
|
||||||
|
explanation.setWordWrap(True)
|
||||||
|
selection_layout.addWidget(explanation)
|
||||||
|
_add_settings_widget(self.settings_menu, selection_box)
|
||||||
|
self.settings_menu.addSeparator()
|
||||||
|
self.settings_menu.addAction("Remove panel", self.removeRequested.emit)
|
||||||
|
self.header = _PanelHeader("", self.settings_menu)
|
||||||
|
self.status = QLabel("Select signals to plot")
|
||||||
|
self.status.setObjectName("muted")
|
||||||
|
self.status.setWordWrap(True)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
top_row = QWidget()
|
||||||
|
top_row_layout = QHBoxLayout(top_row)
|
||||||
|
top_row_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
top_row_layout.setSpacing(0)
|
||||||
|
self.legend = QWidget()
|
||||||
|
self.legend_layout = QGridLayout(self.legend)
|
||||||
|
self.legend_layout.setContentsMargins(8, 2, 8, 2)
|
||||||
|
self.legend_layout.setHorizontalSpacing(10)
|
||||||
|
self.legend_layout.setVerticalSpacing(1)
|
||||||
|
top_row_layout.addWidget(self.legend, 1)
|
||||||
|
top_row_layout.addWidget(self.header)
|
||||||
|
layout.addWidget(top_row)
|
||||||
|
layout.addWidget(self.plot, 1)
|
||||||
|
layout.addWidget(self.status)
|
||||||
|
self.plot_item.vb.sigResized.connect(self._update_right_view)
|
||||||
|
|
||||||
|
def _style_axes(self) -> None:
|
||||||
|
for axis_name in ("bottom", "left", "right"):
|
||||||
|
axis = self.plot_item.getAxis(axis_name)
|
||||||
|
axis.setPen("#a0a3a8")
|
||||||
|
axis.setTextPen("#a0a3a8")
|
||||||
|
axis.enableAutoSIPrefix(False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def separate_axes(self) -> bool:
|
||||||
|
"""Whether the current selection uses the automatic dual-axis mode."""
|
||||||
|
return len([i for i in self.signal_ids if i in self.descriptors]) == 2
|
||||||
|
|
||||||
|
def _config(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"signal_ids": list(self.signal_ids),
|
||||||
|
"follow_latest": self.follow_latest,
|
||||||
|
"zero_baseline": self.zero_baseline,
|
||||||
|
"right_zero_baseline": self.right_zero_baseline,
|
||||||
|
}
|
||||||
|
|
||||||
|
def config(self) -> dict[str, Any]:
|
||||||
|
return self._config()
|
||||||
|
|
||||||
|
def _emit_config(self) -> None:
|
||||||
|
self.configChanged.emit(self._config())
|
||||||
|
|
||||||
|
def _selection_changed(self, _item: QListWidgetItem) -> None:
|
||||||
|
self.signal_ids = [
|
||||||
|
self.selection.item(i).data(Qt.ItemDataRole.UserRole)
|
||||||
|
for i in range(self.selection.count())
|
||||||
|
if self.selection.item(i).checkState() == Qt.CheckState.Checked
|
||||||
|
]
|
||||||
|
self._emit_config()
|
||||||
|
self._rebuild_curves()
|
||||||
|
|
||||||
|
def _follow_changed(self, follow: bool) -> None:
|
||||||
|
self.follow_latest = follow
|
||||||
|
self._emit_config()
|
||||||
|
|
||||||
|
def _mark_axis_manual(self, side: str) -> None:
|
||||||
|
self.follow.setChecked(False)
|
||||||
|
# The two views share X: a manual zoom must stop either baseline
|
||||||
|
# from refitting the linked view on the next data refresh.
|
||||||
|
self._baseline_auto_ranges["left"] = False
|
||||||
|
self._baseline_auto_ranges["right"] = False
|
||||||
|
|
||||||
|
def _baseline_enabled(self, side: str) -> bool:
|
||||||
|
count = len(self.curves)
|
||||||
|
if count >= 3:
|
||||||
|
return False
|
||||||
|
if side == "left":
|
||||||
|
return self.zero_baseline
|
||||||
|
return count == 2 and self.right_zero_baseline
|
||||||
|
|
||||||
|
def _zero_baseline_changed(self, enabled: bool) -> None:
|
||||||
|
self.zero_baseline = enabled
|
||||||
|
self._emit_config()
|
||||||
|
self._baseline_auto_ranges["left"] = True
|
||||||
|
self._reapply_last_snapshot()
|
||||||
|
|
||||||
|
def _right_zero_baseline_changed(self, enabled: bool) -> None:
|
||||||
|
self.right_zero_baseline = enabled
|
||||||
|
self._emit_config()
|
||||||
|
self._baseline_auto_ranges["right"] = True
|
||||||
|
self._reapply_last_snapshot()
|
||||||
|
|
||||||
|
def _reapply_last_snapshot(self) -> None:
|
||||||
|
self._apply_baseline_limits()
|
||||||
|
if self._last_snapshot is not None:
|
||||||
|
self.update_plot(self._last_snapshot)
|
||||||
|
|
||||||
|
def _apply_baseline_limits(self) -> None:
|
||||||
|
normalized = len(self.curves) >= 3
|
||||||
|
for side, view in (("left", self.plot_item.vb), ("right", self.right_view)):
|
||||||
|
if view is None:
|
||||||
|
continue
|
||||||
|
if normalized and side == "left":
|
||||||
|
view.setLimits(yMin=0, yMax=1)
|
||||||
|
view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False)
|
||||||
|
view.setYRange(0, 1, padding=0)
|
||||||
|
continue
|
||||||
|
enabled = self._baseline_enabled(side)
|
||||||
|
view.setLimits(yMin=0 if enabled else None, yMax=None)
|
||||||
|
if enabled:
|
||||||
|
view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False)
|
||||||
|
self._fit_zero_baseline(side, view)
|
||||||
|
else:
|
||||||
|
view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=True)
|
||||||
|
|
||||||
|
def _fit_zero_baseline(self, side: str, view: pg.ViewBox) -> None:
|
||||||
|
values: list[float] = []
|
||||||
|
for signal_id in self._axis_signal_ids[side]:
|
||||||
|
curve = self.curves.get(signal_id)
|
||||||
|
if curve is None:
|
||||||
|
continue
|
||||||
|
_x, y = curve.getData()
|
||||||
|
if y is not None:
|
||||||
|
values.extend(float(value) for value in y if value is not None and math.isfinite(value))
|
||||||
|
positive_max = max((value for value in values if value > 0), default=0.0)
|
||||||
|
upper = positive_max * 1.05 if positive_max > 0 else 1.0
|
||||||
|
view.setYRange(0.0, upper, padding=0)
|
||||||
|
|
||||||
|
def _fit(self) -> None:
|
||||||
|
self.follow.setChecked(False)
|
||||||
|
self._baseline_auto_ranges["left"] = True
|
||||||
|
self._baseline_auto_ranges["right"] = True
|
||||||
|
self.plot_item.vb.autoRange()
|
||||||
|
if self.right_view is not None:
|
||||||
|
self.right_view.autoRange()
|
||||||
|
self._apply_baseline_limits()
|
||||||
|
|
||||||
|
def set_playback_position(self, position_ns: int | None) -> None:
|
||||||
|
"""Show the Core playback position as an elapsed-time playhead."""
|
||||||
|
if position_ns is None:
|
||||||
|
self.playback_position_ns = None
|
||||||
|
self._sync_playback_cursor()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
position_ns = max(0, int(position_ns))
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
self.playback_position_ns = None
|
||||||
|
self._sync_playback_cursor()
|
||||||
|
return
|
||||||
|
time_range = self._last_snapshot.get("time_range") if self._last_snapshot else None
|
||||||
|
if time_range:
|
||||||
|
duration_ns = max(0, int(time_range[1]) - int(time_range[0]))
|
||||||
|
position_ns = min(position_ns, duration_ns)
|
||||||
|
self.playback_position_ns = position_ns
|
||||||
|
self._sync_playback_cursor()
|
||||||
|
|
||||||
|
def _sync_playback_cursor(self) -> None:
|
||||||
|
"""Keep the playhead above plot items without changing their bounds."""
|
||||||
|
if self.playback_cursor.scene() is not self.plot_item.scene():
|
||||||
|
self.plot_item.addItem(self.playback_cursor, ignoreBounds=True)
|
||||||
|
if self.playback_cursor_label.scene() is not self.plot_item.scene():
|
||||||
|
self.plot_item.addItem(self.playback_cursor_label, ignoreBounds=True)
|
||||||
|
|
||||||
|
snapshot = self._last_snapshot
|
||||||
|
time_range = snapshot.get("time_range") if snapshot else None
|
||||||
|
data = snapshot.get("data", {}) if snapshot else {}
|
||||||
|
has_plot_data = bool(time_range) and any(
|
||||||
|
data.get(signal_id) for signal_id in self.curves
|
||||||
|
)
|
||||||
|
visible = self.playback_position_ns is not None and has_plot_data
|
||||||
|
if not visible:
|
||||||
|
self.playback_cursor.hide()
|
||||||
|
self.playback_cursor_label.hide()
|
||||||
|
return
|
||||||
|
|
||||||
|
duration_ns = max(0, int(time_range[1]) - int(time_range[0]))
|
||||||
|
self.playback_position_ns = min(self.playback_position_ns, duration_ns)
|
||||||
|
position_seconds = self.playback_position_ns / 1_000_000_000
|
||||||
|
self.playback_cursor.setValue(position_seconds)
|
||||||
|
self.playback_cursor_label.setText(f"{position_seconds:.1f} s")
|
||||||
|
x_range, y_range = self.plot_item.vb.viewRange()
|
||||||
|
self.playback_cursor_label.setAnchor((1.0 if position_seconds > sum(x_range) / 2 else 0.0, 0.0))
|
||||||
|
y_span = y_range[1] - y_range[0]
|
||||||
|
self.playback_cursor_label.setPos(position_seconds, y_range[1] - y_span * 0.03)
|
||||||
|
self.playback_cursor.show()
|
||||||
|
self.playback_cursor_label.show()
|
||||||
|
|
||||||
|
def set_signals(self, signals: list[dict[str, Any]]) -> None:
|
||||||
|
self.descriptors = {signal["id"]: signal for signal in signals}
|
||||||
|
signature = tuple(
|
||||||
|
(s["id"], s["name"], s["unit"], s["type"], s["min_value"], s["max_value"])
|
||||||
|
for s in signals
|
||||||
|
) + (("selected", tuple(self.signal_ids)),)
|
||||||
|
if signature == self.signal_signature:
|
||||||
|
return
|
||||||
|
self.signal_signature = signature
|
||||||
|
self.selection.blockSignals(True)
|
||||||
|
self.selection.clear()
|
||||||
|
for signal in signals:
|
||||||
|
unit = f" ({signal['unit']})" if signal["unit"] else ""
|
||||||
|
item = QListWidgetItem(f"{signal['name']}{unit}")
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, signal["id"])
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(
|
||||||
|
Qt.CheckState.Checked if signal["id"] in self.signal_ids else Qt.CheckState.Unchecked
|
||||||
|
)
|
||||||
|
self.selection.addItem(item)
|
||||||
|
for signal_id in self.signal_ids:
|
||||||
|
if signal_id not in self.descriptors:
|
||||||
|
item = QListWidgetItem(f"Unavailable signal ({signal_id})")
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, signal_id)
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(Qt.CheckState.Checked)
|
||||||
|
self.selection.addItem(item)
|
||||||
|
self.selection.blockSignals(False)
|
||||||
|
self._rebuild_curves()
|
||||||
|
|
||||||
|
def _rebuild_curves(self) -> None:
|
||||||
|
while self.legend_layout.count():
|
||||||
|
item = self.legend_layout.takeAt(0)
|
||||||
|
if item.widget() is not None:
|
||||||
|
item.widget().deleteLater()
|
||||||
|
if self.right_view is not None:
|
||||||
|
self.plot_item.scene().removeItem(self.right_view)
|
||||||
|
self.right_view = None
|
||||||
|
self.plot_item.clear()
|
||||||
|
self.plot_item.setLabel("bottom", "Elapsed time", units="s")
|
||||||
|
self.plot_item.setLabel("left", "")
|
||||||
|
self.plot_item.setLabel("right", "")
|
||||||
|
self.plot_item.showGrid(x=True, y=True, alpha=0.2)
|
||||||
|
self._style_axes()
|
||||||
|
self.curves.clear()
|
||||||
|
selected = [i for i in self.signal_ids if i in self.descriptors]
|
||||||
|
dual_axis = len(selected) == 2
|
||||||
|
normalized = len(selected) >= 3
|
||||||
|
self.zero_baseline_check.setEnabled(bool(selected) and not normalized)
|
||||||
|
self.right_zero_baseline_check.setEnabled(dual_axis)
|
||||||
|
self.status.setText("Select available signals to plot" if not selected else "No samples available for the selected signals")
|
||||||
|
if not selected:
|
||||||
|
self._axis_signal_ids = {"left": [], "right": []}
|
||||||
|
self.plot_item.hideAxis("right")
|
||||||
|
self._apply_baseline_limits()
|
||||||
|
self._sync_playback_cursor()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.legend.setVisible(bool(selected))
|
||||||
|
if len(selected) == 1:
|
||||||
|
descriptor = self.descriptors[selected[0]]
|
||||||
|
self.plot_item.setLabel("left", descriptor["name"], units=descriptor["unit"] or "")
|
||||||
|
self.plot_item.hideAxis("right")
|
||||||
|
self.plot_item.setLabel("right", "")
|
||||||
|
elif len(selected) == 2:
|
||||||
|
left, right = (self.descriptors[i] for i in selected)
|
||||||
|
self.plot_item.setLabel("left", left["name"], units=left["unit"] or "")
|
||||||
|
self.plot_item.setLabel("right", right["name"], units=right["unit"] or "")
|
||||||
|
for axis_name, color in (("left", self.COLORS[0]), ("right", self.COLORS[1])):
|
||||||
|
axis = self.plot_item.getAxis(axis_name)
|
||||||
|
axis.setPen(color)
|
||||||
|
axis.setTextPen(color)
|
||||||
|
self.plot_item.showAxis("right")
|
||||||
|
self.right_view = pg.ViewBox()
|
||||||
|
self.right_view.setMouseMode(pg.ViewBox.RectMode)
|
||||||
|
self.right_view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False)
|
||||||
|
self.right_view.sigRangeChangedManually.connect(
|
||||||
|
lambda *_: self._mark_axis_manual("right")
|
||||||
|
)
|
||||||
|
self.plot_item.scene().addItem(self.right_view)
|
||||||
|
self.plot_item.getAxis("right").linkToView(self.right_view)
|
||||||
|
self.right_view.setXLink(self.plot_item.vb)
|
||||||
|
else:
|
||||||
|
self.plot_item.setLabel("left", "Normalized (0–1)")
|
||||||
|
self.plot_item.hideAxis("right")
|
||||||
|
|
||||||
|
self._axis_signal_ids = {
|
||||||
|
"left": selected[:1] if len(selected) == 2 else selected,
|
||||||
|
"right": selected[1:2] if len(selected) == 2 else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, signal_id in enumerate(selected):
|
||||||
|
descriptor = self.descriptors[signal_id]
|
||||||
|
unit = descriptor["unit"] or ""
|
||||||
|
label = descriptor["name"] + (f" ({unit})" if unit else "")
|
||||||
|
color = self.COLORS[index % len(self.COLORS)]
|
||||||
|
if len(selected) == 2 and index == 1:
|
||||||
|
curve = pg.PlotDataItem(pen=color)
|
||||||
|
self.right_view.addItem(curve)
|
||||||
|
else:
|
||||||
|
curve = self.plot_item.plot(pen=color)
|
||||||
|
self.curves[signal_id] = curve
|
||||||
|
legend_label = QLabel(f"● {label}")
|
||||||
|
legend_label.setWordWrap(True)
|
||||||
|
legend_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||||
|
legend_label.setStyleSheet(f"color: {color};")
|
||||||
|
legend_label.setToolTip(label)
|
||||||
|
row, column = divmod(index, 3)
|
||||||
|
self.legend_layout.addWidget(legend_label, row, column)
|
||||||
|
self.legend_layout.setColumnStretch(column, 1)
|
||||||
|
if len(selected) >= 3:
|
||||||
|
bounds = self._descriptor_bounds(descriptor)
|
||||||
|
curve.setToolTip(f"{label}: normalized using {bounds[0]:g} to {bounds[1]:g}" if bounds else f"{label}: normalized using visible data range")
|
||||||
|
self._update_right_view()
|
||||||
|
self._baseline_auto_ranges["left"] = True
|
||||||
|
self._baseline_auto_ranges["right"] = True
|
||||||
|
self._apply_baseline_limits()
|
||||||
|
if len(selected) != 2:
|
||||||
|
self.plot_item.hideAxis("right")
|
||||||
|
self._sync_playback_cursor()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _descriptor_bounds(descriptor: dict[str, Any]) -> tuple[float, float] | None:
|
||||||
|
low, high = descriptor.get("min_value"), descriptor.get("max_value")
|
||||||
|
if low is None or high is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
low, high = float(low), float(high)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return (low, high) if math.isfinite(low) and math.isfinite(high) and high > low else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_series(cls, descriptor: dict[str, Any], values: list[float]) -> list[float]:
|
||||||
|
"""Map finite samples to [0, 1], retaining gaps as NaN."""
|
||||||
|
bounds = cls._descriptor_bounds(descriptor)
|
||||||
|
if bounds is None:
|
||||||
|
finite = [v for v in values if math.isfinite(v)]
|
||||||
|
bounds = (min(finite), max(finite)) if finite else None
|
||||||
|
if bounds is None:
|
||||||
|
return [math.nan for _ in values]
|
||||||
|
low, high = bounds
|
||||||
|
if high == low:
|
||||||
|
return [0.5 if math.isfinite(value) else math.nan for value in values]
|
||||||
|
return [min(1.0, max(0.0, (value - low) / (high - low))) if math.isfinite(value) else math.nan for value in values]
|
||||||
|
|
||||||
|
def _update_right_view(self) -> None:
|
||||||
|
if self.right_view is not None:
|
||||||
|
self.right_view.setGeometry(self.plot_item.vb.sceneBoundingRect())
|
||||||
|
self.right_view.linkedViewChanged(self.plot_item.vb, self.right_view.XAxis)
|
||||||
|
|
||||||
|
def update_plot(self, snapshot: dict[str, Any]) -> None:
|
||||||
|
self._last_snapshot = snapshot
|
||||||
|
time_range = snapshot["time_range"]
|
||||||
|
if not self.signal_ids or not time_range:
|
||||||
|
self.status.setText("No time range or selected signals available")
|
||||||
|
self.status.setVisible(True)
|
||||||
|
for curve in self.curves.values():
|
||||||
|
curve.setData([], [])
|
||||||
|
self._sync_playback_cursor()
|
||||||
|
return
|
||||||
|
origin, end = time_range
|
||||||
|
plotted = False
|
||||||
|
for signal_id, curve in self.curves.items():
|
||||||
|
samples = snapshot["data"].get(signal_id, [])
|
||||||
|
x = [(timestamp - origin) / 1_000_000_000 for timestamp, _ in samples]
|
||||||
|
y = []
|
||||||
|
for _, value in samples:
|
||||||
|
try:
|
||||||
|
y.append(float(value) if value is not None else math.nan)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
y.append(math.nan)
|
||||||
|
if len(self.curves) >= 3:
|
||||||
|
y = self._normalize_series(self.descriptors[signal_id], y)
|
||||||
|
curve.setData(x, y)
|
||||||
|
plotted |= bool(samples)
|
||||||
|
for side, view in (("left", self.plot_item.vb), ("right", self.right_view)):
|
||||||
|
if view is not None and self._baseline_enabled(side) and self._baseline_auto_ranges[side]:
|
||||||
|
view.setLimits(yMin=0)
|
||||||
|
self._fit_zero_baseline(side, view)
|
||||||
|
if self.follow_latest:
|
||||||
|
end_seconds = (end - origin) / 1_000_000_000
|
||||||
|
self.plot_item.setXRange(0, max(0.001, end_seconds), padding=0)
|
||||||
|
self.status.setText("No samples available for the selected signals" if not plotted else "")
|
||||||
|
self.status.setVisible(not plotted)
|
||||||
|
self._sync_playback_cursor()
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from PySide6.QtCore import QTimer, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFileDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QListWidget,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionPanel(QWidget):
|
||||||
|
"""Explicit recording controls and Core-managed recording archive save."""
|
||||||
|
|
||||||
|
busyChanged = Signal(bool)
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._adapter: Any | None = None
|
||||||
|
self._mode = "realtime"
|
||||||
|
self._session_usable = False
|
||||||
|
self._core_state = "stopped"
|
||||||
|
self._recording = False
|
||||||
|
self._recording_available = False
|
||||||
|
self._busy = False
|
||||||
|
self._operation_id: str | None = None
|
||||||
|
self._output_path: Path | None = None
|
||||||
|
|
||||||
|
self.status = QLabel("Start a Core session to control recording")
|
||||||
|
self.status.setObjectName("muted")
|
||||||
|
self.status.setWordWrap(True)
|
||||||
|
self.operation = QLabel("")
|
||||||
|
self.operation.setObjectName("muted")
|
||||||
|
self.operation.setWordWrap(True)
|
||||||
|
self.workers = QLabel("Workers: no session")
|
||||||
|
self.workers.setObjectName("muted")
|
||||||
|
self.workers.setWordWrap(True)
|
||||||
|
self.connectors = QListWidget()
|
||||||
|
self.connectors.setWordWrap(True)
|
||||||
|
self.connectors.addItem("Start a Core session to view connectors")
|
||||||
|
self.start_button = QPushButton("Start recording")
|
||||||
|
self.stop_button = QPushButton("Stop recording")
|
||||||
|
self.save_button = QPushButton("Save recording…")
|
||||||
|
self.start_button.clicked.connect(self._start_recording)
|
||||||
|
self.stop_button.clicked.connect(self._stop_recording)
|
||||||
|
self.save_button.clicked.connect(self._choose_save_path)
|
||||||
|
recording_buttons = QHBoxLayout()
|
||||||
|
recording_buttons.addWidget(self.start_button)
|
||||||
|
recording_buttons.addWidget(self.stop_button)
|
||||||
|
self.progress = QProgressBar()
|
||||||
|
self.progress.setRange(0, 100)
|
||||||
|
self.progress.setValue(0)
|
||||||
|
self.progress.hide()
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.addWidget(self.status)
|
||||||
|
layout.addWidget(QLabel("Connector status"))
|
||||||
|
layout.addWidget(self.connectors, 1)
|
||||||
|
layout.addWidget(self.workers)
|
||||||
|
layout.addLayout(recording_buttons)
|
||||||
|
layout.addWidget(self.save_button)
|
||||||
|
layout.addWidget(self.operation)
|
||||||
|
layout.addWidget(self.progress)
|
||||||
|
|
||||||
|
self._operation_timer = QTimer(self)
|
||||||
|
self._operation_timer.setInterval(300)
|
||||||
|
self._operation_timer.timeout.connect(self._poll_operation)
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recording(self) -> bool:
|
||||||
|
return self._recording
|
||||||
|
|
||||||
|
@property
|
||||||
|
def busy(self) -> bool:
|
||||||
|
return self._busy
|
||||||
|
|
||||||
|
def set_adapter(self, adapter: Any | None) -> None:
|
||||||
|
if adapter is self._adapter:
|
||||||
|
return
|
||||||
|
self._operation_timer.stop()
|
||||||
|
self._adapter = adapter
|
||||||
|
self._mode = "realtime"
|
||||||
|
self._core_state = "stopped"
|
||||||
|
self._recording = False
|
||||||
|
self._recording_available = False
|
||||||
|
self._operation_id = None
|
||||||
|
self._output_path = None
|
||||||
|
self.progress.hide()
|
||||||
|
self.operation.clear()
|
||||||
|
self._set_busy(False)
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def refresh(self, snapshot: dict[str, Any] | None) -> None:
|
||||||
|
self._session_usable = bool(snapshot and snapshot.get("session_usable", snapshot.get("state") == "started"))
|
||||||
|
if snapshot is None:
|
||||||
|
self.workers.setText("Workers: no session")
|
||||||
|
self._core_state = "stopped"
|
||||||
|
self._recording = False
|
||||||
|
self.status.setText("Start a Core session to control recording")
|
||||||
|
self._update_controls()
|
||||||
|
return
|
||||||
|
workers = snapshot.get("workers", [])
|
||||||
|
failures = [worker for worker in workers if worker["state"] in {"failed", "blocked"}]
|
||||||
|
self.workers.setText(
|
||||||
|
"Worker attention: " + "; ".join(f"{w['name']} · {w['state']} · {w.get('error') or 'no details'}" for w in failures)
|
||||||
|
if failures else f"Workers: {len(workers)} · no failures reported"
|
||||||
|
)
|
||||||
|
self.workers.setToolTip("\n".join(
|
||||||
|
f"{w['name']}: {w['state']} · queue {w.get('queue_size', 0)}/{w.get('queue_capacity', 0)} · dropped {w.get('dropped_samples', 0)}"
|
||||||
|
for w in workers
|
||||||
|
))
|
||||||
|
previous_recording = self._recording
|
||||||
|
self._mode = snapshot.get("mode", "")
|
||||||
|
self._core_state = snapshot.get("state", "")
|
||||||
|
self._recording = bool(snapshot.get("recording", False))
|
||||||
|
if "recording_available" in snapshot:
|
||||||
|
self._recording_available = bool(snapshot["recording_available"])
|
||||||
|
elif previous_recording and not self._recording:
|
||||||
|
self._recording_available = True
|
||||||
|
if self._core_state != "started":
|
||||||
|
message = f"Core session state: {self._core_state}"
|
||||||
|
elif self._mode != "realtime":
|
||||||
|
message = f"Recording is available in Realtime mode · current mode: {self._mode}"
|
||||||
|
elif self._recording:
|
||||||
|
message = "Recording in progress"
|
||||||
|
elif self._recording_available:
|
||||||
|
message = "Recording stopped and ready to save"
|
||||||
|
else:
|
||||||
|
message = "Idle · no completed recording to save"
|
||||||
|
self.status.setText(message)
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool) -> None:
|
||||||
|
if self._busy == busy:
|
||||||
|
return
|
||||||
|
self._busy = busy
|
||||||
|
self.busyChanged.emit(busy)
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def _update_controls(self) -> None:
|
||||||
|
running = self._adapter is not None and self._session_usable and self._core_state == "started"
|
||||||
|
realtime = self._mode == "realtime"
|
||||||
|
self.start_button.setEnabled(running and realtime and not self._recording and not self._busy)
|
||||||
|
self.stop_button.setEnabled(running and self._recording and not self._busy)
|
||||||
|
self.save_button.setEnabled(
|
||||||
|
running and self._recording_available and not self._recording and not self._busy
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_recording(self) -> None:
|
||||||
|
if self._adapter is None or self._busy or self._mode != "realtime":
|
||||||
|
return
|
||||||
|
self.operation.setText("Starting recording…")
|
||||||
|
self._set_busy(True)
|
||||||
|
self._adapter.run_async(self._adapter.start_recording, self._on_recording_started)
|
||||||
|
|
||||||
|
def _on_recording_started(self, _result: Any, error: Exception | None) -> None:
|
||||||
|
if error is None:
|
||||||
|
self._recording = True
|
||||||
|
self._recording_available = False
|
||||||
|
self.operation.setText("Recording started")
|
||||||
|
else:
|
||||||
|
self.operation.setText(f"Could not start recording: {error}")
|
||||||
|
self._set_busy(False)
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def _stop_recording(self) -> None:
|
||||||
|
if self._adapter is None or self._busy or not self._recording:
|
||||||
|
return
|
||||||
|
self.operation.setText("Stopping recording…")
|
||||||
|
self._set_busy(True)
|
||||||
|
self._adapter.run_async(self._adapter.stop_recording, self._on_recording_stopped)
|
||||||
|
|
||||||
|
def _on_recording_stopped(self, _result: Any, error: Exception | None) -> None:
|
||||||
|
if error is None:
|
||||||
|
self._recording = False
|
||||||
|
self._recording_available = True
|
||||||
|
self.operation.setText("Recording stopped")
|
||||||
|
else:
|
||||||
|
self.operation.setText(f"Could not stop recording: {error}")
|
||||||
|
self._set_busy(False)
|
||||||
|
self._update_controls()
|
||||||
|
|
||||||
|
def _choose_save_path(self) -> None:
|
||||||
|
if self._adapter is None or self._busy or not self._recording_available:
|
||||||
|
return
|
||||||
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Save recording",
|
||||||
|
"recording.dlpak",
|
||||||
|
"DynaLab archive (*.dlpak)",
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
target = Path(path)
|
||||||
|
if target.suffix.lower() != ".dlpak":
|
||||||
|
target = target.with_name(target.name + ".dlpak")
|
||||||
|
self._output_path = target
|
||||||
|
self.operation.setText(f"Preparing {target.name}…")
|
||||||
|
self.progress.setValue(0)
|
||||||
|
self.progress.show()
|
||||||
|
self._set_busy(True)
|
||||||
|
adapter = self._adapter
|
||||||
|
adapter.run_async(lambda: adapter.write_archive(target, source="recording"), self._on_write_started)
|
||||||
|
|
||||||
|
def _on_write_started(self, operation_id: UUID | str | None, error: Exception | None) -> None:
|
||||||
|
if error is not None:
|
||||||
|
self._finish_write(f"Could not save recording: {error}")
|
||||||
|
return
|
||||||
|
if operation_id is None:
|
||||||
|
self._finish_write("Could not save recording: Core returned no operation ID")
|
||||||
|
return
|
||||||
|
self._operation_id = str(operation_id)
|
||||||
|
self.operation.setText("Saving recording…")
|
||||||
|
self._poll_operation()
|
||||||
|
if self._operation_id is not None:
|
||||||
|
self._operation_timer.start()
|
||||||
|
|
||||||
|
def _poll_operation(self) -> None:
|
||||||
|
if self._adapter is None or self._operation_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
status = self._adapter.operation_status(self._operation_id)
|
||||||
|
except Exception as error:
|
||||||
|
self._finish_write(f"Could not read save status: {error}")
|
||||||
|
return
|
||||||
|
if status is None:
|
||||||
|
self._finish_write("Could not read save status: operation is unavailable")
|
||||||
|
return
|
||||||
|
total = status.get("total", 0)
|
||||||
|
completed = status.get("completed", 0)
|
||||||
|
if total:
|
||||||
|
self.progress.setValue(round(100 * completed / total))
|
||||||
|
message = status.get("message") or "Saving recording…"
|
||||||
|
self.operation.setText(message)
|
||||||
|
if status.get("state") == "completed":
|
||||||
|
self._finish_write(f"Saved recording to {self._output_path}")
|
||||||
|
elif status.get("state") == "failed":
|
||||||
|
detail = status.get("error_message") or status.get("error_code") or "unknown error"
|
||||||
|
self._finish_write(f"Could not save recording: {detail}")
|
||||||
|
|
||||||
|
def _finish_write(self, message: str) -> None:
|
||||||
|
self._operation_timer.stop()
|
||||||
|
self._operation_id = None
|
||||||
|
self.operation.setText(message)
|
||||||
|
self.progress.hide()
|
||||||
|
self._set_busy(False)
|
||||||
|
self._update_controls()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Quiet neutral palette and compact desktop controls."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtGui import QColor, QFont, QPalette
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
|
||||||
|
def apply_theme(app: QApplication) -> None:
|
||||||
|
app.setStyle("Fusion")
|
||||||
|
palette = QPalette()
|
||||||
|
for role, color in {
|
||||||
|
QPalette.ColorRole.Window: "#181a1d",
|
||||||
|
QPalette.ColorRole.WindowText: "#dedfe1",
|
||||||
|
QPalette.ColorRole.Base: "#202225",
|
||||||
|
QPalette.ColorRole.AlternateBase: "#26292d",
|
||||||
|
QPalette.ColorRole.Text: "#dedfe1",
|
||||||
|
QPalette.ColorRole.Button: "#2b2e33",
|
||||||
|
QPalette.ColorRole.ButtonText: "#dedfe1",
|
||||||
|
QPalette.ColorRole.Highlight: "#484e57",
|
||||||
|
QPalette.ColorRole.HighlightedText: "#ffffff",
|
||||||
|
QPalette.ColorRole.ToolTipBase: "#292c31",
|
||||||
|
QPalette.ColorRole.ToolTipText: "#dedfe1",
|
||||||
|
QPalette.ColorRole.Link: "#bcc5d0",
|
||||||
|
}.items():
|
||||||
|
palette.setColor(role, QColor(color))
|
||||||
|
for role in (QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText):
|
||||||
|
palette.setColor(QPalette.ColorGroup.Disabled, role, QColor("#70747c"))
|
||||||
|
app.setPalette(palette)
|
||||||
|
app.setFont(QFont("Sans Serif", 10))
|
||||||
|
app.setStyleSheet("""
|
||||||
|
QWidget { color: #dedfe1; }
|
||||||
|
QMainWindow, QDialog { background: #181a1d; }
|
||||||
|
QToolBar { background: #202225; border: 0; spacing: 8px; padding: 6px 8px; }
|
||||||
|
QMenuBar { background: #181a1d; padding: 0 6px; }
|
||||||
|
QMenuBar::item { padding: 5px 10px; }
|
||||||
|
QMenuBar::item:selected, QMenu::item:selected { background: #34383e; }
|
||||||
|
QMenu { background: #26292d; border: 1px solid #3b3f46; padding: 4px; }
|
||||||
|
QMenu::item { padding: 5px 20px; }
|
||||||
|
QDockWidget { border: 0; font-weight: 400; }
|
||||||
|
QDockWidget::title { padding: 0; margin: 0; border: 0; }
|
||||||
|
QDockWidget > QWidget, QWidget#panelBody { background: #202225; }
|
||||||
|
QLabel { background: transparent; }
|
||||||
|
QLabel#brand { font-size: 14px; font-weight: 600; padding-right: 8px; }
|
||||||
|
QLabel#eyebrow { color: #a0a3a8; font-size: 10px; letter-spacing: 1px; }
|
||||||
|
QLabel#heroTitle { font-size: 30px; font-weight: 500; }
|
||||||
|
QLabel#muted, QLabel#sessionStatus { color: #a0a3a8; }
|
||||||
|
QLabel#readoutValue { color: #dedfe1; }
|
||||||
|
QLabel#sessionStatus { padding: 2px; }
|
||||||
|
QPushButton { background: #2b2e33; border: 1px solid #41454d; border-radius: 3px;
|
||||||
|
padding: 5px 9px; font-weight: 400; }
|
||||||
|
QPushButton:hover { background: #34383e; border-color: #696f79; }
|
||||||
|
QPushButton:pressed { background: #24272b; }
|
||||||
|
QPushButton:disabled { color: #70747c; background: #222428; border-color: #303339; }
|
||||||
|
QPushButton#primary { background: #41474f; border-color: #555d68; }
|
||||||
|
QPushButton#primary:hover { background: #4d545e; }
|
||||||
|
QPushButton#primary:disabled { background: #292c31; color: #70747c; border-color: #303339; }
|
||||||
|
QToolButton { border: 0; border-radius: 2px; padding: 2px; }
|
||||||
|
QToolButton:hover, QToolButton:checked { background: #34383e; }
|
||||||
|
QToolButton#panelSettingsButton { background: transparent; padding: 2px;
|
||||||
|
min-width: 20px; min-height: 20px; }
|
||||||
|
QToolButton#panelSettingsButton:hover, QToolButton#panelSettingsButton:checked { background: #34383e; }
|
||||||
|
QToolButton#panelSettingsButton::menu-indicator { image: none; width: 0; }
|
||||||
|
QLabel#panelHeaderTitle { color: #aeb2b9; font-size: 12px; font-weight: 400; }
|
||||||
|
QLabel#panelSettingsTitle { color: #dedfe1; font-weight: 500; }
|
||||||
|
QLineEdit, QSpinBox, QComboBox, QPlainTextEdit, QTextEdit {
|
||||||
|
background: #181a1d; border: 1px solid #3d424a; border-radius: 3px;
|
||||||
|
padding: 4px 6px; selection-background-color: #484e57; }
|
||||||
|
QLineEdit:focus, QSpinBox:focus, QComboBox:focus, QPlainTextEdit:focus { border-color: #8c939e; }
|
||||||
|
QComboBox QAbstractItemView { background: #26292d; selection-background-color: #484e57; }
|
||||||
|
QListWidget, QTreeWidget, QTableWidget { background: #202225; border: 0;
|
||||||
|
border-radius: 0; padding: 2px; outline: none; }
|
||||||
|
QListWidget::item { padding: 5px; }
|
||||||
|
QListWidget::item:selected { background: #383e46; }
|
||||||
|
QListWidget::item:hover { background: #2a2e34; }
|
||||||
|
QTabWidget::pane { border: 0; background: #202225; }
|
||||||
|
QTabBar::tab { background: #202225; color: #969ba4; padding: 4px 10px;
|
||||||
|
border: 0; border-bottom: 1px solid transparent; }
|
||||||
|
QTabBar::tab:selected { color: #dedfe1; border-bottom-color: #a2aab6; background: #292d33; }
|
||||||
|
QCheckBox { spacing: 5px; }
|
||||||
|
QCheckBox::indicator { width: 13px; height: 13px; }
|
||||||
|
QStatusBar { background: #202225; color: #969ba4; border: 0; }
|
||||||
|
QStatusBar::item { border: 0; }
|
||||||
|
QScrollBar:vertical { background: #181a1d; width: 8px; }
|
||||||
|
QScrollBar:horizontal { background: #181a1d; height: 8px; }
|
||||||
|
QScrollBar::handle { background: #454b54; min-height: 20px; min-width: 20px; border-radius: 2px; }
|
||||||
|
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; }
|
||||||
|
""")
|
||||||
|
arrow = (Path(__file__).parent / "icons" / "chevron.svg").as_posix()
|
||||||
|
app.setStyleSheet(app.styleSheet() + """
|
||||||
|
QComboBox { padding-right: 24px; }
|
||||||
|
QComboBox::drop-down { subcontrol-origin: padding; subcontrol-position: top right;
|
||||||
|
width: 20px; border: 0; }
|
||||||
|
QComboBox::down-arrow { image: url(ARROW_PATH); width: 10px; height: 7px; }
|
||||||
|
""".replace("ARROW_PATH", arrow))
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Small versioned workspace persistence and validation."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
VERSION = 6
|
||||||
|
SYSTEM_PANELS = [
|
||||||
|
{"id": "dashboard", "title": "Dashboard", "type": "dashboard", "config": {}},
|
||||||
|
{"id": "data", "title": "Data / playback", "type": "data", "config": {}},
|
||||||
|
{"id": "signals", "title": "Signal browser", "type": "signal_browser", "config": {}},
|
||||||
|
{"id": "derived", "title": "Derived units", "type": "derive_manager", "config": {}},
|
||||||
|
]
|
||||||
|
VISUALIZATION_TYPES = {"numeric", "gauge", "graph", "numeric_group", "gauge_group"}
|
||||||
|
SYSTEM_TYPES = {panel["type"] for panel in SYSTEM_PANELS}
|
||||||
|
SUPPORTED_PANEL_TYPES = SYSTEM_TYPES | VISUALIZATION_TYPES
|
||||||
|
_SYSTEM_TYPES_BY_ID = {panel["id"]: panel["type"] for panel in SYSTEM_PANELS}
|
||||||
|
_RESERVED_TYPES_BY_ID = {**_SYSTEM_TYPES_BY_ID, "numeric": "numeric", "gauge": "gauge", "graph": "graph"}
|
||||||
|
DEFAULT_PANELS = [
|
||||||
|
*SYSTEM_PANELS,
|
||||||
|
{"id": "numeric", "title": "Numeric readout", "type": "numeric", "config": {"signal_id": None, "decimals": 2}},
|
||||||
|
{"id": "gauge", "title": "Gauge", "type": "gauge", "config": {"signal_id": None, "decimals": 2}},
|
||||||
|
{
|
||||||
|
"id": "graph",
|
||||||
|
"title": "Time graph",
|
||||||
|
"type": "graph",
|
||||||
|
"config": {"signal_ids": [], "follow_latest": True, "zero_baseline": False, "right_zero_baseline": False},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def new_workspace() -> dict[str, Any]:
|
||||||
|
return {"version": VERSION, "panels": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_panels(panels: list[Any]) -> None:
|
||||||
|
panel_ids: set[str] = set()
|
||||||
|
for panel in panels:
|
||||||
|
if (
|
||||||
|
not isinstance(panel, dict)
|
||||||
|
or not all(isinstance(panel.get(key), str) and panel[key] for key in ("id", "title", "type"))
|
||||||
|
or not isinstance(panel.get("config", {}), dict)
|
||||||
|
or ("visible" in panel and not isinstance(panel["visible"], bool))
|
||||||
|
):
|
||||||
|
raise WorkspaceError("Workspace contains an invalid panel definition.")
|
||||||
|
panel_id = panel["id"]
|
||||||
|
panel_type = panel["type"]
|
||||||
|
if panel_id in panel_ids:
|
||||||
|
raise WorkspaceError("Workspace contains duplicate panel IDs.")
|
||||||
|
panel_ids.add(panel_id)
|
||||||
|
if panel_id in _RESERVED_TYPES_BY_ID:
|
||||||
|
if panel_type != _RESERVED_TYPES_BY_ID[panel_id]:
|
||||||
|
raise WorkspaceError(f"Reserved panel ID {panel_id!r} has the wrong type.")
|
||||||
|
elif panel_type not in VISUALIZATION_TYPES:
|
||||||
|
raise WorkspaceError(f"Unsupported panel type {panel_type!r}.")
|
||||||
|
|
||||||
|
config = panel.get("config", {})
|
||||||
|
if panel_type in {"numeric", "gauge"} and config.get("signal_id") is not None and not isinstance(
|
||||||
|
config["signal_id"], str
|
||||||
|
):
|
||||||
|
raise WorkspaceError("Panel signal selections must be IDs or null.")
|
||||||
|
if panel_type in {"numeric", "gauge"} and (
|
||||||
|
type(config.get("decimals", 2)) is not int or not 0 <= config.get("decimals", 2) <= 8
|
||||||
|
):
|
||||||
|
raise WorkspaceError("Display decimal precision must be between 0 and 8.")
|
||||||
|
if panel_type in {"numeric_group", "gauge_group"}:
|
||||||
|
kind = panel_type.removesuffix("_group")
|
||||||
|
items = config.get("items", [])
|
||||||
|
if config.get("kind", kind) != kind or not isinstance(items, list) or any(
|
||||||
|
not isinstance(item, dict)
|
||||||
|
or (item.get("signal_id") is not None and not isinstance(item["signal_id"], str))
|
||||||
|
or type(item.get("decimals", 2)) is not int or not 0 <= item.get("decimals", 2) <= 8
|
||||||
|
for item in items
|
||||||
|
):
|
||||||
|
raise WorkspaceError("Instrument group settings are invalid.")
|
||||||
|
if panel_type == "derive_manager":
|
||||||
|
units = config.get("units", [])
|
||||||
|
if not isinstance(units, list) or any(
|
||||||
|
not isinstance(unit, dict)
|
||||||
|
or not isinstance(unit.get("path"), str)
|
||||||
|
or (unit.get("unit_id") is not None and not isinstance(unit["unit_id"], str))
|
||||||
|
or not isinstance(unit.get("binding_intent", False), bool)
|
||||||
|
for unit in units
|
||||||
|
):
|
||||||
|
raise WorkspaceError("Derived-unit library settings are invalid.")
|
||||||
|
if panel_type == "graph" and (
|
||||||
|
not isinstance(config.get("signal_ids", []), list)
|
||||||
|
or not all(isinstance(signal_id, str) for signal_id in config.get("signal_ids", []))
|
||||||
|
or len(config.get("signal_ids", [])) != len(set(config.get("signal_ids", [])))
|
||||||
|
or not isinstance(config.get("follow_latest", True), bool)
|
||||||
|
or not isinstance(config.get("zero_baseline", False), bool)
|
||||||
|
or not isinstance(config.get("right_zero_baseline", False), bool)
|
||||||
|
):
|
||||||
|
raise WorkspaceError("Graph panel settings are invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_grid(layout: Any, panels: list[dict[str, Any]]) -> None:
|
||||||
|
def valid_rect(rect: Any) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(rect, list) and len(rect) == 4
|
||||||
|
and all(type(value) is int and 0 <= value <= 10000 for value in rect)
|
||||||
|
and rect[2] > 0 and rect[3] > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(layout, dict) or layout.get("version") != 1 or not isinstance(layout.get("panels"), dict):
|
||||||
|
raise WorkspaceError("Workspace grid layout is invalid.")
|
||||||
|
panel_types = {panel["id"]: panel["type"] for panel in panels}
|
||||||
|
if set(layout["panels"]) != set(panel_types):
|
||||||
|
raise WorkspaceError("Workspace grid does not match its panel list.")
|
||||||
|
for panel_id, item in layout["panels"].items():
|
||||||
|
if not isinstance(item, dict) or item.get("type") != panel_types[panel_id] or not valid_rect(item.get("rect")):
|
||||||
|
raise WorkspaceError("Workspace grid panel placement is invalid.")
|
||||||
|
groups = layout.get("groups", [])
|
||||||
|
if not isinstance(groups, list):
|
||||||
|
raise WorkspaceError("Workspace tab groups are invalid.")
|
||||||
|
grouped: set[str] = set()
|
||||||
|
group_ids: set[str] = set()
|
||||||
|
for group in groups:
|
||||||
|
if not isinstance(group, dict):
|
||||||
|
raise WorkspaceError("Workspace tab group is invalid.")
|
||||||
|
members = group.get("panels")
|
||||||
|
group_id = group.get("id")
|
||||||
|
if (
|
||||||
|
not isinstance(group_id, str) or not group_id or group_id in group_ids
|
||||||
|
or not isinstance(members, list) or len(members) < 2
|
||||||
|
or any(not isinstance(member, str) or member not in panel_types or member in grouped for member in members)
|
||||||
|
or len(set(members)) != len(members) or not valid_rect(group.get("rect"))
|
||||||
|
or (group.get("active") is not None and group["active"] not in members)
|
||||||
|
):
|
||||||
|
raise WorkspaceError("Workspace tab group is invalid.")
|
||||||
|
grouped.update(members)
|
||||||
|
group_ids.add(group_id)
|
||||||
|
floating = layout.get("floating", {})
|
||||||
|
if not isinstance(floating, dict) or any(
|
||||||
|
item not in panel_types or item in grouped for item in floating
|
||||||
|
):
|
||||||
|
raise WorkspaceError("Workspace floating panels are invalid.")
|
||||||
|
if any(
|
||||||
|
not isinstance(rect, list) or len(rect) != 4
|
||||||
|
or any(type(value) is not int for value in rect) or rect[2] <= 0 or rect[3] <= 0
|
||||||
|
for rect in floating.values()
|
||||||
|
):
|
||||||
|
raise WorkspaceError("Workspace floating geometry is invalid.")
|
||||||
|
hidden = layout.get("hidden", [])
|
||||||
|
if not isinstance(hidden, list) or any(not isinstance(item, str) or item not in panel_types for item in hidden):
|
||||||
|
raise WorkspaceError("Workspace hidden panels are invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_workspace(data: dict[str, Any]) -> None:
|
||||||
|
"""Validate a current workspace before the window mutates its dock state."""
|
||||||
|
if not isinstance(data, dict) or data.get("version") != VERSION:
|
||||||
|
raise WorkspaceError(f"Unsupported workspace format (expected version {VERSION}).")
|
||||||
|
panels = data.get("panels")
|
||||||
|
if not isinstance(panels, list):
|
||||||
|
raise WorkspaceError("Workspace must contain a panel list.")
|
||||||
|
_validate_panels(panels)
|
||||||
|
if "grid_layout" in data:
|
||||||
|
_validate_grid(data["grid_layout"], panels)
|
||||||
|
for key in ("geometry",):
|
||||||
|
if key in data and data[key] is not None and not isinstance(data[key], str):
|
||||||
|
raise WorkspaceError(f"Workspace field {key!r} must be encoded text.")
|
||||||
|
if data.get(key):
|
||||||
|
try:
|
||||||
|
base64.b64decode(data[key], validate=True)
|
||||||
|
except ValueError as error:
|
||||||
|
raise WorkspaceError(f"Workspace field {key!r} is invalid.") from error
|
||||||
|
|
||||||
|
|
||||||
|
def load_workspace(path: str | Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||||
|
raise WorkspaceError(f"Could not open workspace: {error}") from error
|
||||||
|
|
||||||
|
validate_workspace(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def save_workspace(path: str | Path, data: dict[str, Any]) -> None:
|
||||||
|
validate_workspace(data)
|
||||||
|
target = Path(path)
|
||||||
|
text = json.dumps(data, indent=2) + "\n"
|
||||||
|
temporary: Path | None = None
|
||||||
|
try:
|
||||||
|
with NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
dir=target.parent,
|
||||||
|
prefix=f".{target.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as stream:
|
||||||
|
temporary = Path(stream.name)
|
||||||
|
stream.write(text)
|
||||||
|
temporary.replace(target)
|
||||||
|
finally:
|
||||||
|
if temporary is not None:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
@@ -2,7 +2,395 @@ version = 1
|
|||||||
revision = 5
|
revision = 5
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dynalab-core"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = { git = "https://git.h3cx.dev/Exergie/dynalab-core.git?rev=v0.2.1#db5d509b49c80c362683affab95823c6d80059ab" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "psutil" },
|
||||||
|
{ name = "psutils" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dynalab-py"
|
name = "dynalab-py"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dynalab-core" },
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "pyqtgraph" },
|
||||||
|
{ name = "pyside6" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "dynalab-core", git = "https://git.h3cx.dev/Exergie/dynalab-core.git?rev=v0.2.1" },
|
||||||
|
{ name = "numpy", specifier = ">=2.5.4" },
|
||||||
|
{ name = "pyqtgraph", specifier = ">=0.14.0" },
|
||||||
|
{ name = "pyside6", specifier = ">=6.12.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.5.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psutil"
|
||||||
|
version = "7.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psutils"
|
||||||
|
version = "3.3.17"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "puremagic" },
|
||||||
|
{ name = "pypdf" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a0/6b/735ab226ab8e2ee6b99182312c7346a2e4bd3e825cfdf127f22ce75e853f/psutils-3.3.17.tar.gz", hash = "sha256:3ff76cc9f9fb535ec553d1591de56d6dbaa0d9f4df86778f6a49731ebd764cdf", size = 1255066, upload-time = "2026-09-07T13:15:05.562Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/c6/84c07085ebc0130e1bc7f1957e10ad42ea53f2f65177e107a5c76323b78c/psutils-3.3.17-py3-none-any.whl", hash = "sha256:de0289f2eac5966c2481a02cfa6eed0dc0f8e67115356eb44dbab429610f5970", size = 55328, upload-time = "2026-09-07T13:15:02.882Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "puremagic"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/74/ce5987ab9b8aec4ced06e2723ebb604205c9eb58abdad91453da93166380/puremagic-2.2.0.tar.gz", hash = "sha256:eb4bddf07c177c4b434554b92165b67449f5a51e152b976202d6254498810eef", size = 1189752, upload-time = "2026-04-08T01:39:55.562Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/81/314320aeffd88dadeac553ff9eb10f54507ab41deccd0c69f6221d254a0a/puremagic-2.2.0-py3-none-any.whl", hash = "sha256:c4f7ed7307f056c787199acfda839555921be1df13abba61e8e6db0c787ae1d0", size = 71971, upload-time = "2026-04-08T01:39:54.169Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.14.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.50.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pypdf"
|
||||||
|
version = "6.20.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e2/c1/da25a099164cf4b210d63b957c902ad687139f4b8c12c20aec7953a4a266/pypdf-6.20.1.tar.gz", hash = "sha256:28f5a9d2fdc2749264612d94e6a58de54c11d730d9f0cabf8ad34117c4942b45", size = 7075352, upload-time = "2026-10-12T16:14:24.784Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/f8/4cbd09988b4b158260b7e0df38bf16f19e998bf0e257a18661a8da04280e/pypdf-6.20.1-py3-none-any.whl", hash = "sha256:aa5a55ddcffdc5e5ab291d5decb23f6383f4e56f8e3263dc39af41fff03885ad", size = 402665, upload-time = "2026-10-12T16:14:22.556Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyqtgraph"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6"
|
||||||
|
version = "6.12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyside6-addons" },
|
||||||
|
{ name = "pyside6-essentials" },
|
||||||
|
{ name = "pyside6-pdf" },
|
||||||
|
{ name = "pyside6-webengine" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/db/0722bbb81c3515dd0847fd66a7fb4760b13ad59c89e40b27b362ab5624e2/pyside6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:efea4c2f2aa54403c45de825865a902a1960a1cc9d5aaa92d4ebb8f4c4cd0708", size = 577475, upload-time = "2026-10-08T09:11:15.222Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/53/db0cabed1e70d0bc4666b8cbc2611b72bff2f9d2c4813820182621a8c6ee/pyside6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d13ed4d0053cc20f3e4e0fdbf12790a9b7f958d7e22c0843411ecfa8c799e2a9", size = 577709, upload-time = "2026-10-08T09:11:17.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/8c/dfd4f339285d2e6c31067789b9b7dd2cee26c098ecc78ac7200fb04aa344/pyside6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8920e163f60493e18822d20f1e01c7c4bcf9c1c5cfbc68284788896f692594f8", size = 577708, upload-time = "2026-10-08T09:11:18.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/da/8e8514fc29ea916c3cd71a524480b77409e19ed12d709e8a5d73be089b81/pyside6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:31a8d2893f2f28ab483b1534dde0438f27af6e234593de30425cb4729052d8f8", size = 578958, upload-time = "2026-10-08T09:11:20.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/2e/3ebafaf7f4ccea6f6ea9d017f7fc7d9484b79e6b31d1cb92f5f5262055f0/pyside6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:22e2ec6407f32b61955a28f91ab55676e60017210285c28f0c381bfeed389414", size = 562743, upload-time = "2026-10-08T09:11:21.552Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6-addons"
|
||||||
|
version = "6.12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyside6-essentials" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/16/c401d6455dbf49df37a8b142883e12d446abf51919c3e0ef5d9570e29160/pyside6_addons-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:1d9bcd294ce1c9829194c7d13b01f771a2254b86cb3462cc3399321d2797fdac", size = 90545550, upload-time = "2026-10-08T09:08:14.778Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/3e/0d6c5934de575286ac0699a701d4155048185035b8da0ee78d57a38bc7ef/pyside6_addons-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4ab38c0017f0453671a9313d3a53ca1c0e378924f54b07e596bcea94a9642391", size = 59192702, upload-time = "2026-10-08T09:08:21.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/14/b6c5683614601448c3ea8f19613ca378db75158ba594b662a8a8041e3e5f/pyside6_addons-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:48cd0b639c06c9f79685f340ccae4319614b66d6efcdf06c2ce3836790d8c46e", size = 63646547, upload-time = "2026-10-08T09:08:26.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/41/ba3d52fe2acd942ebf71eb02b3bdf0897f7f6f4ce8f52289cb50228e48d3/pyside6_addons-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:98d8d62010da6605344cee83e5c96fb846170a1f2d924523f10f5d92078b02b0", size = 40522402, upload-time = "2026-10-08T09:08:31.678Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/73/860ab764368cd59b1a09cb891a9a4519425395ad1f6c47d83fde908fe603/pyside6_addons-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:3c079cbaad2ad28bc5bfa92fd6bac4ba28d622b825825e3b4220f0682599b2db", size = 35940682, upload-time = "2026-10-08T09:08:35.424Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6-essentials"
|
||||||
|
version = "6.12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "shiboken6" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/90/d528f8dd3f9f20712bc719e10c698d27c998e1dd76ed3f33b6fb606d0972/pyside6_essentials-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:0867b709a724db28b161227c7d6ecb5ed10cc09226a53542417ac6dee1308239", size = 116002775, upload-time = "2026-10-08T09:09:54.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/3c/7597d8dcb36aa8315427635ec6ec9d850ffbad4ce78b9118ac854889f2d3/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:16f4b5e41daf49235ae048458243da9efe5f3d8040f9cc5aa454ce69893d3bd9", size = 84346433, upload-time = "2026-10-08T09:10:02.825Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/4e/f4fd4c016719be832ec49d3c87940459e87fd666179a0a199a3998d07916/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:08c5841063fc1df69b7fa1ee405f539e66576a83fc48004be3e2c5e35ccbfa69", size = 83810102, upload-time = "2026-10-08T09:10:09.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/61/9a7e32a37b224f68506785bfb0f288d516098ec71681782d72b695a6c95d/pyside6_essentials-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:c9a95102aa23c1f86516a30d5a9be37552d324eee7e567eb7508973f1218cb42", size = 82153795, upload-time = "2026-10-08T09:10:15.104Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/11/43afb3d0e7c25dd09f0eb7e44f1283dadb5fa8d8b517249fe3fde02e6702/pyside6_essentials-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:c76aa689989bf9bb9b45535e0a81c6733294df720b0eed1308fa30d2606b98cc", size = 62073456, upload-time = "2026-10-08T09:10:20.202Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6-pdf"
|
||||||
|
version = "6.12.0.140"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyside6-essentials" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/33/23949c3b48af4b96b618e0502b7313ac52a782615db8dceae5bc2c516831/pyside6_pdf-6.12.0.140-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:74287989dd24a82ac1604e27a2d4530a0ab71ff03cdf878c16fdbcf5144373fb", size = 8644803, upload-time = "2026-10-08T09:07:36.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/f0/9b4f3cb0506641ea53210c7b635703f1c2c1e81c27074438105ff118f8e7/pyside6_pdf-6.12.0.140-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0c94370b6b9d65fbfe54f9d6d25df0677db0cdcfd0a842a63958a3603a42007c", size = 2843877, upload-time = "2026-10-08T09:07:39.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/cb/4a8cf3edb3f1b2396935573c02255be75f5605dcc77e9e3a201a6d6f26d7/pyside6_pdf-6.12.0.140-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:2b63ee1f8ce3d3b2b648be2ebda483dd0fbca8d44c92a6c6f924033ac7fbde8c", size = 2837165, upload-time = "2026-10-08T09:07:40.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/8a/3f5eab9eb9e6486f0334b100322bbd0aff432dd90ad7977a3910979a163f/pyside6_pdf-6.12.0.140-cp310-abi3-win_amd64.whl", hash = "sha256:4971ba1a0ca15e7c29cfd808e9600aeb46b2305dad01456eb09fa3197166268e", size = 2983516, upload-time = "2026-10-08T09:07:42.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/87/5c29bdaf47bf0b0cee7f467e86ffeb07be5bf8aa2afd2ff6f0785d0515b4/pyside6_pdf-6.12.0.140-cp310-abi3-win_arm64.whl", hash = "sha256:ce6e4d858df1eb4e2adcbd8ba212f3611ce60f9978881fe10d417f36013a6ff9", size = 2758117, upload-time = "2026-10-08T09:07:43.936Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyside6-webengine"
|
||||||
|
version = "6.12.0.140"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyside6-addons" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/37/3b50067bdcba5c22e20a8d7a029c919850c6d94f8398175b330c3b8765ad/pyside6_webengine-6.12.0.140-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:9a1de2da360d1817c08e9400a2a2fc5234beb3022a22123e15355169e183074e", size = 249778242, upload-time = "2026-10-08T09:06:03.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/e0/cb8b68b962afc253709ed61555d0a4f297fd6786c0e77921d2e951504210/pyside6_webengine-6.12.0.140-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c041c9921d7c91c9e51a2c027f434d05c3c59baa2c8f17b71dc8194e77791f06", size = 116741921, upload-time = "2026-10-08T09:06:13.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/34/75944ed126293868fcbcc589fa2254e3310016920e5d32d21c29be021524/pyside6_webengine-6.12.0.140-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:66a842ccb7f06fa7e357f6d4d63bc2739e3cbc1f93583f314e38c43024fe5f74", size = 114053128, upload-time = "2026-10-08T09:06:21.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/0d/260c7c286f2d4e8a49c099152a385414a872f022117f937824ca0a3f38e5/pyside6_webengine-6.12.0.140-cp310-abi3-win_amd64.whl", hash = "sha256:f9b61b1d457a591ee590a6b80515f25a3e61e2576a2a0a70566380f9d9de4ab0", size = 131697708, upload-time = "2026-10-08T09:06:29.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/34/7a2d1d7b95c96adf173c77d1862c2587789f7afb8754430fcfc2c9e4e47a/pyside6_webengine-6.12.0.140-cp310-abi3-win_arm64.whl", hash = "sha256:44d1fac2a057a71b8f947fc5177a95fab8a0ea4e2fda068f223a00fd0193a8c7", size = 65723, upload-time = "2026-10-08T09:06:32.559Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shiboken6"
|
||||||
|
version = "6.12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/45/11536fba5f50a57671eb14441c6098096f3e57074e54c9d7d66039a5831e/shiboken6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:47105e05baf57d35453e07d240bbe59cb4b8d9565377ea5134ff9fb37d4f5c02", size = 479653, upload-time = "2026-10-08T09:10:41.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/c2/a04fa003b4360e99d8e3fc2829abacf37396ace63ea1cd616402209ec7c6/shiboken6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bde565bb0890044b5c7f63808adddede5abc241e9395a2bfed61919884d6240", size = 279268, upload-time = "2026-10-08T09:10:42.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/ca/a4415e6218e0dff764fe2b2d30db2fc8f499a08ace07634232ade3f73adc/shiboken6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ff72a72b3277b418902d562b987b33a6f6818bed9fe40a49d634d09bc67f620e", size = 276562, upload-time = "2026-10-08T09:10:44.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/b6/bee97e7a2edeab72aafabb46ee5adc939790a638d107860b4cad7a20e407/shiboken6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:a1906cb8116869178c64b6bab52623bbc8b4ceed7b30bfc3c9aa0f6e67f6438e", size = 1265476, upload-time = "2026-10-08T09:10:45.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/d5/5894d35f8a470ca8acff0d1cd8f15b1c94cbfdfe0ce4a6ec0fcf10b95bd1/shiboken6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:da382e68f0815b31b6dc2bce11a465b148c4f42c37b91546a361de1576567d10", size = 1838851, upload-time = "2026-10-08T09:10:47.123Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||||
|
]
|
||||||
Reference in new issue
Block a user