From 6158a95647605eb0a2ec561c4d41bfbc207928cd Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Wed, 6 May 2026 17:21:33 +0200 Subject: [PATCH 01/32] qt read Param from CLI --- pyx2cscope/gui/__init__.py | 4 +-- pyx2cscope/gui/qt/main_window.py | 58 ++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/pyx2cscope/gui/__init__.py b/pyx2cscope/gui/__init__.py index 8bc5ccd2..d1452636 100644 --- a/pyx2cscope/gui/__init__.py +++ b/pyx2cscope/gui/__init__.py @@ -39,8 +39,8 @@ def execute_qt(*args, **kwargs): qt_args = sys.argv[:1] + args[0] # Initialize a PyQt5 application app = QApplication(qt_args) - # Create an instance of the main window - ex = MainWindow() + # Create an instance of the main window, forwarding CLI kwargs (elf, port, etc.) + ex = MainWindow(**kwargs) # Display the GUI ex.show() # Start the PyQt5 application event loop diff --git a/pyx2cscope/gui/qt/main_window.py b/pyx2cscope/gui/qt/main_window.py index dc3b00cd..a4662512 100644 --- a/pyx2cscope/gui/qt/main_window.py +++ b/pyx2cscope/gui/qt/main_window.py @@ -4,7 +4,7 @@ import os from PyQt5 import QtGui -from PyQt5.QtCore import QSettings, Qt +from PyQt5.QtCore import QSettings, QTimer, Qt from PyQt5.QtWidgets import ( QApplication, QFileDialog, @@ -43,10 +43,22 @@ class MainWindow(QMainWindow): - Configuration save/load """ - def __init__(self, parent=None): - """Initialize the main window.""" + def __init__(self, parent=None, **kwargs): + """Initialize the main window. + + Args: + parent: Optional parent widget. + **kwargs: Optional CLI arguments forwarded from the command line. + Relevant keys: ``elf`` (path to ELF file) and + ``port`` (COM port), which trigger an automatic + connection attempt after the window is displayed. + """ super().__init__(parent) + # Store CLI arguments for deferred auto-connect + self._cli_elf = kwargs.get("elf") + self._cli_port = kwargs.get("port") + # Initialize settings self._settings = QSettings("Microchip", "pyX2Cscope") @@ -70,6 +82,10 @@ def __init__(self, parent=None): # Refresh ports on startup self._refresh_ports() + # If CLI provided elf + port, schedule auto-connect after event loop starts + if self._cli_elf and self._cli_port: + QTimer.singleShot(0, self._auto_connect) + def _setup_ui(self): # noqa: PLR0915 """Set up the user interface.""" QApplication.setStyle(QStyleFactory.create("Fusion")) @@ -236,6 +252,42 @@ def _refresh_ports(self): """Refresh available COM ports.""" self._connection_manager.refresh_ports() + def _auto_connect(self): + """Attempt automatic connection using CLI-supplied elf and port arguments. + + Called once (via QTimer) after the window is shown. On success the + UI switches directly to the Data Views tab so the user can start + monitoring right away. + """ + if not self._cli_elf or not self._cli_port: + return + + # Populate the Setup tab fields so the user can see what was used + self._setup_tab.elf_file_path = self._cli_elf + port_combo = self._setup_tab.port_combo + if self._cli_port in [port_combo.itemText(i) for i in range(port_combo.count())]: + port_combo.setCurrentText(self._cli_port) + + conn_params = self._setup_tab.get_connection_params() + # Override with the CLI port in case it was not found in the combo box + if conn_params.get("interface", "UART") == "UART": + conn_params["port"] = self._cli_port + + QApplication.processEvents() + + connected = self._connection_manager.connect(self._cli_elf, **conn_params) + + if connected: + self._setup_tab.set_connected(True) + self._setup_tab.save_connection_settings() + self._update_device_info() + # Activate WatchView and switch to Data Views tab + self._watch_view_btn.setChecked(True) + self._on_view_toggle_changed() + self._tab_widget.setCurrentIndex(1) + else: + self._setup_tab.set_connected(False) + def _on_ports_refreshed(self, ports: list): """Handle ports refreshed signal.""" self._setup_tab.set_ports(ports) From a1a9b888f8fe8616f1c79f36a0913c6ef8423955 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Wed, 6 May 2026 17:23:59 +0200 Subject: [PATCH 02/32] button for "new script" to load starting template --- pyx2cscope/gui/qt/tabs/scripting_tab.py | 48 +++++++++++++++++ pyx2cscope/gui/resources/script_template.py | 59 +++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 pyx2cscope/gui/resources/script_template.py diff --git a/pyx2cscope/gui/qt/tabs/scripting_tab.py b/pyx2cscope/gui/qt/tabs/scripting_tab.py index d068b853..100b9d2d 100644 --- a/pyx2cscope/gui/qt/tabs/scripting_tab.py +++ b/pyx2cscope/gui/qt/tabs/scripting_tab.py @@ -200,6 +200,13 @@ def _setup_ui(self): # noqa: PLR0915 self._script_path_edit.setPlaceholderText("No script selected") script_layout.addWidget(self._script_path_edit, 1) + # New script button + self._new_btn = QPushButton("New Script") + self._new_btn.setFixedWidth(90) + self._new_btn.clicked.connect(self._on_new_clicked) + self._new_btn.setToolTip("Create a new script from the built-in template") + script_layout.addWidget(self._new_btn) + # Browse button self._browse_btn = QPushButton("Browse...") self._browse_btn.setFixedWidth(80) @@ -409,6 +416,47 @@ def _open_with_system_editor(self, script_path): # Linux: Use xdg-open to respect file associations subprocess.Popen(['xdg-open', script_path]) + def _on_new_clicked(self): + """Create a new script from the built-in template. + + Asks the user where to save the new file, copies the template there, + loads it into the tab, and opens it in the system editor. + """ + import shutil + + from pyx2cscope.gui.resources import get_resource_path + + last_dir = self._settings.value("script_file_dir", "", type=str) + file_path, _ = QFileDialog.getSaveFileName( + self, + "Save New Script", + os.path.join(last_dir, "my_script.py"), + "Python Files (*.py);;All Files (*.*)", + ) + if not file_path: + return + + template_path = get_resource_path("script_template.py") + try: + shutil.copy(template_path, file_path) + except Exception as e: + self._log_message(f"Error creating script: {e}") + return + + # Load the new file into the tab + self._script_path = file_path + self._script_path_edit.setText(file_path) + self._settings.setValue("script_file_dir", os.path.dirname(file_path)) + self._edit_btn.setEnabled(True) + self._execute_btn.setEnabled(True) + self._log_message(f"New script created: {file_path}") + + # Open immediately in the system editor so the user can start writing + try: + self._open_with_system_editor(file_path) + except Exception as e: + self._log_message(f"Could not open editor: {e}") + def _on_browse_clicked(self): """Handle browse button click.""" # Get last directory from settings diff --git a/pyx2cscope/gui/resources/script_template.py b/pyx2cscope/gui/resources/script_template.py new file mode 100644 index 00000000..9c9d46aa --- /dev/null +++ b/pyx2cscope/gui/resources/script_template.py @@ -0,0 +1,59 @@ +"""pyX2Cscope script template. + +When executed from the Scripting tab the variable ``x2cscope`` is +automatically injected into this script's namespace. It holds the live +:class:`~pyx2cscope.x2cscope.X2CScope` instance when the application is +connected, or ``None`` when it is not. + +``stop_requested`` is also injected: a callable that returns ``True`` once +the user presses the *Stop* button. Call it inside every loop iteration so +the script can be interrupted gracefully. +""" + +import time + +# ------------------------------------------------------------------ +# Guard: make sure we are connected before doing anything +# ------------------------------------------------------------------ +if x2cscope is None: + print("No active connection. Please connect to the device first.") +else: + # ------------------------------------------------------------------ + # List available variables (handy during development) + # ------------------------------------------------------------------ + variable_names = x2cscope.get_variable_list() + print(f"Connected – {len(variable_names)} variables available.") + # Uncomment the next line to see all variable names: + # print("\n".join(variable_names)) + + # ------------------------------------------------------------------ + # Example: read a single variable in a loop + # ------------------------------------------------------------------ + # var = x2cscope.get_variable("myModule.mySignal") # adapt to your firmware + + # print("Reading 'myModule.mySignal' every 100 ms (press Stop to cancel):") + # while not stop_requested(): + # value = var.get_value() + # print(f" {value}") + # time.sleep(0.1) + + # ------------------------------------------------------------------ + # Example: capture scope data once + # ------------------------------------------------------------------ + # ch1 = x2cscope.get_variable("myModule.signalA") + # ch2 = x2cscope.get_variable("myModule.signalB") + # x2cscope.add_scope_channel(ch1) + # x2cscope.add_scope_channel(ch2) + # x2cscope.set_sample_time(1) + # + # x2cscope.request_scope_data() + # while not x2cscope.is_scope_data_ready(): + # if stop_requested(): + # print("Stopped.") + # break + # time.sleep(0.05) + # else: + # for channel, data in x2cscope.get_scope_channel_data().items(): + # print(f"Channel {channel}: {len(data)} samples, first={data[0]:.4f}") + + print("Done.") From b8a6bb4760bfd76fa07957e752514a328619b882 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Wed, 6 May 2026 19:06:02 +0200 Subject: [PATCH 03/32] included dropdown with watch view refresh time --- .../gui/qt/controllers/config_manager.py | 10 +++- pyx2cscope/gui/qt/main_window.py | 1 + pyx2cscope/gui/qt/tabs/scope_view_tab.py | 4 +- pyx2cscope/gui/qt/tabs/watch_view_tab.py | 53 ++++++++++++++++--- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/pyx2cscope/gui/qt/controllers/config_manager.py b/pyx2cscope/gui/qt/controllers/config_manager.py index 307d89ef..0f2aea3a 100644 --- a/pyx2cscope/gui/qt/controllers/config_manager.py +++ b/pyx2cscope/gui/qt/controllers/config_manager.py @@ -53,16 +53,19 @@ def save_config(self, config: Dict[str, Any], file_path: Optional[str] = None) - """ try: if not file_path: + last_dir = self._settings.value("config_file_dir", os.path.expanduser("~"), type=str) file_path, _ = QFileDialog.getSaveFileName( self._parent, "Save Configuration", - "", + os.path.join(last_dir, "pyx2cscope_config.json"), "JSON Files (*.json)", ) if not file_path: return False + self._settings.setValue("config_file_dir", os.path.dirname(file_path)) + with open(file_path, "w") as f: json.dump(config, f, indent=4) @@ -87,16 +90,19 @@ def load_config(self, file_path: Optional[str] = None) -> Optional[Dict[str, Any """ try: if not file_path: + last_dir = self._settings.value("config_file_dir", os.path.expanduser("~"), type=str) file_path, _ = QFileDialog.getOpenFileName( self._parent, "Load Configuration", - "", + last_dir, "JSON Files (*.json)", ) if not file_path: return None + self._settings.setValue("config_file_dir", os.path.dirname(file_path)) + with open(file_path, "r") as f: config = json.load(f) diff --git a/pyx2cscope/gui/qt/main_window.py b/pyx2cscope/gui/qt/main_window.py index a4662512..9034d7bb 100644 --- a/pyx2cscope/gui/qt/main_window.py +++ b/pyx2cscope/gui/qt/main_window.py @@ -247,6 +247,7 @@ def _setup_connections(self): # Tab polling control signals -> DataPoller self._scope_view_tab.scope_sampling_changed.connect(self._on_scope_sampling_changed) self._watch_view_tab.live_polling_changed.connect(self._on_live_watch_changed) + self._watch_view_tab.live_interval_changed.connect(self._data_poller.set_live_interval) def _refresh_ports(self): """Refresh available COM ports.""" diff --git a/pyx2cscope/gui/qt/tabs/scope_view_tab.py b/pyx2cscope/gui/qt/tabs/scope_view_tab.py index 02401af4..ab969a73 100644 --- a/pyx2cscope/gui/qt/tabs/scope_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/scope_view_tab.py @@ -501,7 +501,7 @@ def get_config(self) -> dict: "trigger_delay": self._trigger_delay_combo.currentText(), "trigger_edge": self._trigger_edge_combo.currentText(), "trigger_mode": self._trigger_mode_combo.currentText(), - "sample_time_factor": self._sample_time_factor_edit.value(), + "sample_time_factor": self._sample_time_factor_edit.text(), "single_shot": self._single_shot_checkbox.isChecked(), } @@ -543,5 +543,5 @@ def load_config(self, config: dict): self._trigger_delay_combo.setCurrentText(config.get("trigger_delay", "0")) self._trigger_edge_combo.setCurrentText(config.get("trigger_edge", "Rising")) self._trigger_mode_combo.setCurrentText(config.get("trigger_mode", "Disable")) - self._sample_time_factor_edit.setValue(int(config.get("sample_time_factor", 1))) + self._sample_time_factor_edit.setText(str(config.get("sample_time_factor", 1))) self._single_shot_checkbox.setChecked(config.get("single_shot", False)) diff --git a/pyx2cscope/gui/qt/tabs/watch_view_tab.py b/pyx2cscope/gui/qt/tabs/watch_view_tab.py index 45b95faa..e8be28a6 100644 --- a/pyx2cscope/gui/qt/tabs/watch_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/watch_view_tab.py @@ -7,8 +7,10 @@ from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QCheckBox, + QComboBox, QDialog, QGridLayout, + QHBoxLayout, QLabel, QLineEdit, QPushButton, @@ -37,6 +39,8 @@ class WatchViewTab(BaseTab): # Signal emitted when live polling state changes: (index, is_live) live_polling_changed = pyqtSignal(int, bool) + # Signal emitted when the user changes the live update rate: interval in ms + live_interval_changed = pyqtSignal(int) def __init__(self, app_state: "AppState", parent=None): """Initialize the WatchView tab. @@ -61,15 +65,39 @@ def __init__(self, app_state: "AppState", parent=None): self._setup_ui() + # Mapping from combo label to interval in ms + _RATE_OPTIONS = [ + ("1 s", 1000), + ("3 s", 3000), + ("5 s", 5000), + ] + def _setup_ui(self): """Set up the user interface.""" - # Set white background + # Set white background only on this widget, not on child popups. + # Using the class-name selector prevents the rule from being inherited + # by QAbstractItemView popups (e.g. the combo-box dropdown), which would + # otherwise render hover/selection text invisible against the white background. self.setAutoFillBackground(True) - self.setStyleSheet("background-color: white;") + self.setStyleSheet("WatchViewTab { background-color: white; }") main_layout = QVBoxLayout() self.setLayout(main_layout) + # --- Toolbar row: update-rate selector --- + toolbar = QHBoxLayout() + toolbar.setContentsMargins(4, 4, 4, 2) + toolbar.addWidget(QLabel("Live update rate:")) + self._rate_combo = QComboBox() + for label, _ in self._RATE_OPTIONS: + self._rate_combo.addItem(label) + self._rate_combo.setFixedWidth(70) + self._rate_combo.setToolTip("Interval between live variable reads") + self._rate_combo.currentIndexChanged.connect(self._on_rate_changed) + toolbar.addWidget(self._rate_combo) + toolbar.addStretch() + main_layout.addLayout(toolbar) + # Scroll area scroll_area = QScrollArea() scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -138,7 +166,9 @@ def _add_variable_row(self): # Create widgets live_cb = QCheckBox() - live_cb.stateChanged.connect(lambda state, idx=index: self._on_live_changed(idx, state)) + # Use the checkbox widget itself to derive the current index at signal time, + # so that removal+rearrange never causes stale index captures. + live_cb.stateChanged.connect(lambda state, cb=live_cb: self._on_live_changed(cb, state)) var_edit = QLineEdit() var_edit.setPlaceholderText("Search Variable") @@ -269,15 +299,26 @@ def _on_variable_click(self, index: int): self._app_state.update_live_watch_var_field(index, "value", value) self._update_scaled_value(index) - def _on_live_changed(self, index: int, state: int): - """Handle live checkbox state change.""" - if index >= len(self._live_checkboxes): + def _on_live_changed(self, cb: QCheckBox, state: int): + """Handle live checkbox state change. + + The checkbox widget is passed instead of a fixed index so that the + correct row is always found even after rows have been removed and + the list has been rearranged. + """ + if cb not in self._live_checkboxes: return + index = self._live_checkboxes.index(cb) is_live = state == Qt.Checked self._app_state.update_live_watch_var_field(index, "live", is_live) # Emit signal to notify DataPoller self.live_polling_changed.emit(index, is_live) + def _on_rate_changed(self, combo_index: int): + """Handle live update rate combo change.""" + _, interval_ms = self._RATE_OPTIONS[combo_index] + self.live_interval_changed.emit(interval_ms) + def _on_value_changed(self, index: int): """Handle value edit finished - write to device.""" if index >= len(self._variable_edits): From a1156fff8360016e765a78ad15b27a90cab5d499 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Thu, 7 May 2026 18:14:44 +0200 Subject: [PATCH 04/32] adjusted Qt watch view look and feel --- pyx2cscope/gui/qt/tabs/watch_view_tab.py | 25 ++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/pyx2cscope/gui/qt/tabs/watch_view_tab.py b/pyx2cscope/gui/qt/tabs/watch_view_tab.py index e8be28a6..71bdea97 100644 --- a/pyx2cscope/gui/qt/tabs/watch_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/watch_view_tab.py @@ -82,11 +82,12 @@ def _setup_ui(self): self.setStyleSheet("WatchViewTab { background-color: white; }") main_layout = QVBoxLayout() + main_layout.setContentsMargins(8, 8, 8, 8) self.setLayout(main_layout) # --- Toolbar row: update-rate selector --- toolbar = QHBoxLayout() - toolbar.setContentsMargins(4, 4, 4, 2) + toolbar.setContentsMargins(0, 0, 0, 4) toolbar.addWidget(QLabel("Live update rate:")) self._rate_combo = QComboBox() for label, _ in self._RATE_OPTIONS: @@ -109,9 +110,9 @@ def _setup_ui(self): # Grid layout for variable rows self._grid_layout = QGridLayout() - self._grid_layout.setContentsMargins(0, 0, 0, 0) - self._grid_layout.setVerticalSpacing(2) - self._grid_layout.setHorizontalSpacing(5) + self._grid_layout.setContentsMargins(4, 4, 4, 4) + self._grid_layout.setVerticalSpacing(4) + self._grid_layout.setHorizontalSpacing(6) scroll_layout.addLayout(self._grid_layout) # Headers @@ -138,7 +139,7 @@ def _setup_ui(self): # Add stretch to push content to top scroll_layout.addStretch() - scroll_layout.setContentsMargins(0, 0, 0, 0) + scroll_layout.setContentsMargins(4, 4, 4, 4) def on_connection_changed(self, connected: bool): """Handle connection state changes.""" @@ -178,7 +179,7 @@ def _add_variable_row(self): value_edit = QLineEdit() value_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - value_edit.editingFinished.connect(lambda idx=index: self._on_value_changed(idx)) + value_edit.editingFinished.connect(lambda ve=value_edit: self._on_value_changed(ve)) scaling_edit = QLineEdit("1") scaling_edit.editingFinished.connect(lambda idx=index: self._update_scaled_value(idx)) @@ -319,20 +320,24 @@ def _on_rate_changed(self, combo_index: int): _, interval_ms = self._RATE_OPTIONS[combo_index] self.live_interval_changed.emit(interval_ms) - def _on_value_changed(self, index: int): - """Handle value edit finished - write to device.""" - if index >= len(self._variable_edits): + def _on_value_changed(self, ve: QLineEdit): + """Handle value edit finished - write to device and refresh scaled value.""" + if ve not in self._value_edits: return + index = self._value_edits.index(ve) var_name = self._variable_edits[index].text() if var_name and var_name != "None": try: - value = float(self._value_edits[index].text()) + value = float(ve.text()) sfr = self._app_state.get_live_watch_var(index).sfr self._app_state.write_variable(var_name, value, sfr=sfr) except ValueError: pass + # Always update scaled value so offset/gain reflect the new number + self._update_scaled_value(index) + def _update_scaled_value(self, index: int): """Update the scaled value for a variable.""" if index >= len(self._value_edits): From 2fcacaf1eda781e52a734fd8e78712941540e692 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Fri, 8 May 2026 10:42:21 +0200 Subject: [PATCH 05/32] version 0.7.1 --- pyproject.toml | 2 +- pyx2cscope/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 394536fe..e7c63c90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pyx2cscope" -version = "0.7.0" +version = "0.7.1" description = "python implementation of X2Cscope" readme = "README.md" requires-python = ">=3.10,<3.15" diff --git a/pyx2cscope/__init__.py b/pyx2cscope/__init__.py index 79b0e20e..f08cd4a4 100644 --- a/pyx2cscope/__init__.py +++ b/pyx2cscope/__init__.py @@ -1,11 +1,11 @@ """This module contains the pyx2cscope package. -Version: 0.7.0 +Version: 0.7.1 """ import logging -__version__ = "0.7.0" +__version__ = "0.7.1" def set_logger( level: int = logging.ERROR, From 6103a4faf36cbad6e5cea54caa2b2ac8d9b72477 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Fri, 8 May 2026 15:43:50 +0200 Subject: [PATCH 06/32] fixed web gui bug on loading variables on watch/scope/view --- pyx2cscope/gui/web/static/js/watch_view.js | 11 ----------- pyx2cscope/gui/web/views/scope_view.py | 2 ++ pyx2cscope/gui/web/views/watch_view.py | 2 ++ 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/pyx2cscope/gui/web/static/js/watch_view.js b/pyx2cscope/gui/web/static/js/watch_view.js index fb2ee5d3..3cc3c0e7 100644 --- a/pyx2cscope/gui/web/static/js/watch_view.js +++ b/pyx2cscope/gui/web/static/js/watch_view.js @@ -1,4 +1,3 @@ -let parameterCardEnabled = true; let parameterTable; const socket_wv = io("/watch-view"); @@ -44,16 +43,6 @@ function setParameterRefreshInterval(){ }); } -function wv_periodic_update(){ - if(!parameterCardEnabled) return; - update = false; - cbs = $('td:first-child input:checkbox', $('#parameterTableBody')); - for(let cb of cbs) { - update |= cb.checked?1:0; - } - if(update) parameterTable.ajax.reload(); -} - function setParameterTableListeners(){ // delete Row on button click $('#parameterTableBody').on('click', '.remove', function () { diff --git a/pyx2cscope/gui/web/views/scope_view.py b/pyx2cscope/gui/web/views/scope_view.py index 6bba8b71..a12ac84f 100644 --- a/pyx2cscope/gui/web/views/scope_view.py +++ b/pyx2cscope/gui/web/views/scope_view.py @@ -8,6 +8,7 @@ from flask import Blueprint, Response, jsonify, render_template, request from pyx2cscope.gui import web +from pyx2cscope.gui.web.extensions import socketio from pyx2cscope.gui.web.scope import web_scope sv_bp = Blueprint("scope_view", __name__) @@ -70,6 +71,7 @@ def load(): if key in var and key != "variable": var[key] = item[key] if "not available" not in msg: + socketio.emit("scope_table_update", {}, namespace="/scope-view") return jsonify({"status": "success"}) return jsonify({"status": "error", "msg": msg}), 400 diff --git a/pyx2cscope/gui/web/views/watch_view.py b/pyx2cscope/gui/web/views/watch_view.py index ff59fdbc..cbf9b2d3 100644 --- a/pyx2cscope/gui/web/views/watch_view.py +++ b/pyx2cscope/gui/web/views/watch_view.py @@ -8,6 +8,7 @@ from flask import Blueprint, Response, jsonify, render_template, request from pyx2cscope.gui import web +from pyx2cscope.gui.web.extensions import socketio from pyx2cscope.gui.web.scope import web_scope wv_bp = Blueprint("watch_view", __name__) @@ -49,6 +50,7 @@ def load(): if key in var and key != "variable": var[key] = item[key] if "not available" not in msg: + socketio.emit("watch_table_update", {}, namespace="/watch-view") return jsonify({"status": "success"}) return jsonify({"status": "error", "msg": msg}), 400 From 0989755a693f52b28db35280511420bd02710df0 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Fri, 8 May 2026 15:56:10 +0200 Subject: [PATCH 07/32] using common config files for both qt and web gui --- pyx2cscope/gui/qt/main_window.py | 65 +++++++++++++-- pyx2cscope/gui/web/app.py | 83 +++++++++++++++++++ pyx2cscope/gui/web/static/js/scope_view.js | 26 ------ pyx2cscope/gui/web/static/js/script.js | 46 ++++++++++ pyx2cscope/gui/web/static/js/watch_view.js | 25 ------ pyx2cscope/gui/web/templates/navbar.html | 7 ++ .../gui/web/templates/source_config.html | 10 +-- pyx2cscope/gui/web/templates/watch_view.html | 11 +-- 8 files changed, 199 insertions(+), 74 deletions(-) diff --git a/pyx2cscope/gui/qt/main_window.py b/pyx2cscope/gui/qt/main_window.py index 9034d7bb..8433aff2 100644 --- a/pyx2cscope/gui/qt/main_window.py +++ b/pyx2cscope/gui/qt/main_window.py @@ -413,13 +413,44 @@ def _save_config(self): # Get connection parameters conn_params = self._setup_tab.get_connection_params() + scope_qt = self._scope_view_tab.get_config() + watch_qt = self._watch_view_tab.get_config() + + # Build shared list-of-dicts format (compatible with web GUI) + scope_shared = [ + { + "variable": scope_qt["variables"][i], + "sfr": scope_qt["sfr"][i] if i < len(scope_qt.get("sfr", [])) else False, + "trigger": scope_qt["trigger"][i] if i < len(scope_qt.get("trigger", [])) else False, + "enable": scope_qt["show"][i] if i < len(scope_qt.get("show", [])) else True, + "color": scope_qt["color"][i] if i < len(scope_qt.get("color", [])) else "#ff0000", + "gain": scope_qt["scale"][i] if i < len(scope_qt.get("scale", [])) else "1.0", + "offset": scope_qt["offset"][i] if i < len(scope_qt.get("offset", [])) else "0.0", + } + for i, v in enumerate(scope_qt.get("variables", [])) + if v + ] + watch_shared = [ + { + "variable": watch_qt["variables"][i], + "sfr": watch_qt["sfr"][i] if i < len(watch_qt.get("sfr", [])) else False, + "live": watch_qt["live"][i] if i < len(watch_qt.get("live", [])) else False, + "scaling": watch_qt["scaling"][i] if i < len(watch_qt.get("scaling", [])) else "1.0", + "offset": watch_qt["offsets"][i] if i < len(watch_qt.get("offsets", [])) else "0.0", + } + for i, v in enumerate(watch_qt.get("variables", [])) + if v + ] + config = ConfigManager.build_config( elf_file=self._setup_tab.elf_file_path, connection=conn_params, - scope_view=self._scope_view_tab.get_config(), - tab3_view=self._watch_view_tab.get_config(), + scope_view=scope_shared, + tab3_view=watch_shared, view_mode=view_mode, ) + # "watch_view" key for web GUI compatibility (same data as "tab3_view") + config["watch_view"] = watch_shared self._config_manager.save_config(config) def _export_selected_variables(self): @@ -486,9 +517,33 @@ def _load_config(self): # noqa: PLR0912, PLR0915 if self._setup_tab.elf_file_path and not self._app_state.is_connected(): self._on_connect_clicked() - # Load tab configurations - self._scope_view_tab.load_config(config.get("scope_view", {})) - self._watch_view_tab.load_config(config.get("tab3_view", {})) + # Load tab configurations — support both Qt format (dicts-of-arrays) and + # shared web format (list-of-dicts with "watch_view"/"scope_view" keys). + raw_scope = config.get("scope_view", {}) + raw_watch = config.get("tab3_view") or config.get("watch_view", {}) + + # Convert shared list-of-dicts format → Qt dicts-of-arrays format + if isinstance(raw_scope, list): + raw_scope = { + "variables": [v.get("variable", "") for v in raw_scope], + "trigger": [v.get("trigger", False) for v in raw_scope], + "scale": [str(v.get("gain", v.get("scale", 1.0))) for v in raw_scope], + "offset": [str(v.get("offset", 0.0)) for v in raw_scope], + "color": [v.get("color", "#ff0000") for v in raw_scope], + "show": [v.get("enable", True) for v in raw_scope], + "sfr": [v.get("sfr", False) for v in raw_scope], + } + if isinstance(raw_watch, list): + raw_watch = { + "variables": [v.get("variable", "") for v in raw_watch], + "scaling": [str(v.get("scaling", 1.0)) for v in raw_watch], + "offsets": [str(v.get("offset", 0.0)) for v in raw_watch], + "live": [v.get("live", False) for v in raw_watch], + "sfr": [v.get("sfr", False) for v in raw_watch], + } + + self._scope_view_tab.load_config(raw_scope) + self._watch_view_tab.load_config(raw_watch) # Load view mode and set toggle buttons view_mode = config.get("view_mode", "Both") diff --git a/pyx2cscope/gui/web/app.py b/pyx2cscope/gui/web/app.py index 6cfcb2f5..181197f7 100644 --- a/pyx2cscope/gui/web/app.py +++ b/pyx2cscope/gui/web/app.py @@ -3,6 +3,7 @@ This module holds and handles the main url and forward the relative urls to the specific pages (blueprints). """ +import json import logging import os import socket @@ -48,6 +49,8 @@ def create_app(): app.add_url_rule("/variables", view_func=variables_autocomplete, methods=["POST", "GET"]) app.add_url_rule("/variables/all", view_func=get_variables, methods=["POST", "GET"]) app.add_url_rule("/variables/export", view_func=export_variables, methods=["GET"]) + app.add_url_rule("/config/save", view_func=save_config, methods=["GET"]) + app.add_url_rule("/config/load", view_func=load_config, methods=["POST"]) socketio.init_app(app) @@ -301,6 +304,86 @@ def export_variables(): ) +def save_config(): + """Generate and download a unified JSON config with watch and scope variables. + + The file contains only variable data (no connection settings), compatible + with the Qt app's config format for watch_view and scope_view sections. + """ + watch_data = [web_scope.variable_to_json(v) for v in web_scope.watch_vars] + scope_data = [web_scope.variable_to_json(v) for v in web_scope.scope_vars] + config = { + "watch_view": watch_data, + "scope_view": scope_data, + } + return Response( + json.dumps(config, indent=4), + mimetype="application/json", + headers={"Content-disposition": "attachment; filename=pyx2cscope_config.json"}, + ) + + +def load_config(): + """Receive a unified JSON config and restore watch and scope variables. + + Expects a JSON file with ``watch_view`` and/or ``scope_view`` keys, + each containing a list of variable dicts (same format as save_config). + Emits SocketIO events so all connected browsers reload their tables. + """ + from pyx2cscope.gui.web.extensions import socketio as _socketio + + cfg_file = request.files.get("file") + msg = "Invalid config file." + if cfg_file and cfg_file.filename.endswith(".json"): + try: + data = json.loads(cfg_file.read().decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return jsonify({"status": "error", "msg": msg}), 400 + + if not isinstance(data, dict): + return jsonify({"status": "error", "msg": msg}), 400 + + errors = [] + + # Restore watch variables + watch_items = data.get("watch_view", []) + if isinstance(watch_items, list) and watch_items: + web_scope.clear_watch_var() + for item in watch_items: + if not isinstance(item, dict) or "variable" not in item: + continue + var = web_scope.add_watch_var(item["variable"], sfr=item.get("sfr", False)) + if var is None: + errors.append(item["variable"]) + else: + for key, value in item.items(): + if key in var and key != "variable": + var[key] = value + _socketio.emit("watch_table_update", {}, namespace="/watch-view") + + # Restore scope variables + scope_items = data.get("scope_view", []) + if isinstance(scope_items, list) and scope_items: + web_scope.clear_scope_var() + for item in scope_items: + if not isinstance(item, dict) or "variable" not in item: + continue + var = web_scope.add_scope_var(item["variable"], sfr=item.get("sfr", False)) + if var is None: + errors.append(item["variable"]) + else: + for key, value in item.items(): + if key in var and key != "variable": + var[key] = value + _socketio.emit("scope_table_update", {}, namespace="/scope-view") + + if errors: + return jsonify({"status": "error", "msg": "Variables not available: " + ", ".join(errors)}), 400 + return jsonify({"status": "success"}) + + return jsonify({"status": "error", "msg": msg}), 400 + + def open_browser(host="localhost", web_port=5000): """Open a new browser pointing to the Flask server. diff --git a/pyx2cscope/gui/web/static/js/scope_view.js b/pyx2cscope/gui/web/static/js/scope_view.js index 607b1433..6c397c77 100644 --- a/pyx2cscope/gui/web/static/js/scope_view.js +++ b/pyx2cscope/gui/web/static/js/scope_view.js @@ -435,32 +435,6 @@ function initScopeForms(){ $(`label[for="${this.id}"]`).addClass('active'); }); - // Set up Save button click handler - $("#scopeSave").on("click", function() { - window.location.href = '/scope/save'; - }); - - $("#scopeLoad").on("change", function(event) { - var file = event.target.files[0]; - var formData = new FormData(); - formData.append('file', file); - - $.ajax({ - url: '/scope/load', // Replace with your server upload endpoint - type: 'POST', - data: formData, - contentType: false, - processData: false, - success: function(response) { - scopeTable.ajax.reload(); - }, - error: function(jqXHR, textStatus, errorThrown) { - alert(jqXHR.responseJSON.msg); - } - }).always(function() { - $("#scopeLoad").val(""); - }); - }); } $(document).ready(function () { diff --git a/pyx2cscope/gui/web/static/js/script.js b/pyx2cscope/gui/web/static/js/script.js index efc6e6a3..dd196dab 100644 --- a/pyx2cscope/gui/web/static/js/script.js +++ b/pyx2cscope/gui/web/static/js/script.js @@ -50,6 +50,18 @@ function disconnect(){ }); } +function saveConfig() { + const toggle = $('#saveConfigToggle'); + if (toggle.attr('aria-disabled') === 'true') return; + window.location.href = '/config/save'; +} + +function loadConfig() { + const toggle = $('#loadConfigToggle'); + if (toggle.attr('aria-disabled') === 'true') return; + $('#configFileInput').val('').trigger('click'); +} + function exportVariables() { const exportToggle = $('#exportVariablesToggle'); if (exportToggle.attr('aria-disabled') === 'true') { @@ -112,6 +124,8 @@ function setConnectState(status) { $("#btnConnect").prop("disabled", true); $("#btnConnect").html("Disconnect", true); $('#exportVariablesToggle').removeClass('disabled text-white-50').attr('aria-disabled', 'false'); + $('#saveConfigToggle').removeClass('disabled text-white-50').attr('aria-disabled', 'false'); + $('#loadConfigToggle').removeClass('disabled text-white-50').attr('aria-disabled', 'false'); $('#connection-status').html('Connected'); $('#desktopTabs').removeClass('disabled'); $('#mobileTabs').removeClass('disabled'); @@ -130,6 +144,8 @@ function setConnectState(status) { $('#btnConnect').removeClass('btn-danger'); $('#btnConnect').addClass('btn-primary'); $('#exportVariablesToggle').addClass('disabled text-white-50').attr('aria-disabled', 'true'); + $('#saveConfigToggle').addClass('disabled text-white-50').attr('aria-disabled', 'true'); + $('#loadConfigToggle').addClass('disabled text-white-50').attr('aria-disabled', 'true'); $('#setupView').removeClass('disabled'); $('#desktopTabs').addClass('disabled'); $('#mobileTabs').addClass('disabled'); @@ -147,6 +163,36 @@ function initSetupCard(){ e.preventDefault(); exportVariables(); }); + $('#saveConfigToggle').on('click', function(e) { + e.preventDefault(); + saveConfig(); + }); + $('#loadConfigToggle').on('click', function(e) { + e.preventDefault(); + loadConfig(); + }); + $('#configFileInput').on('change', function(event) { + var file = event.target.files[0]; + if (!file) return; + var formData = new FormData(); + formData.append('file', file); + $.ajax({ + url: '/config/load', + type: 'POST', + data: formData, + contentType: false, + processData: false, + success: function(response) { + // Tables are refreshed via SocketIO events from server + }, + error: function(jqXHR) { + var msg = (jqXHR.responseJSON && jqXHR.responseJSON.msg) ? jqXHR.responseJSON.msg : 'Failed to load config.'; + alert(msg); + } + }).always(function() { + $('#configFileInput').val(''); + }); + }); $('#connection-status').on('click', function() { if($('#connection-status').html() === "Disconnected") connect(); diff --git a/pyx2cscope/gui/web/static/js/watch_view.js b/pyx2cscope/gui/web/static/js/watch_view.js index 3cc3c0e7..2b479113 100644 --- a/pyx2cscope/gui/web/static/js/watch_view.js +++ b/pyx2cscope/gui/web/static/js/watch_view.js @@ -69,31 +69,6 @@ function setParameterTableListeners(){ } }); - // Set up Save button click handler - $("#parameterSave").on("click", function() { - window.location.href = '/watch/save'; - }); - $('#parameterLoad').on('change', function(event) { - var file = event.target.files[0]; - var formData = new FormData(); - formData.append('file', file); - - $.ajax({ - url: '/watch/load', // Replace with your server upload endpoint - type: 'POST', - data: formData, - contentType: false, - processData: false, - success: function(response) { - parameterTable.ajax.reload(); - }, - error: function(jqXHR, textStatus, errorThrown) { - alert(jqXHR.responseJSON.msg); - } - }).always(function() { - $('#parameterLoad').val(''); - }); - }); } function initParameterSelect(){ diff --git a/pyx2cscope/gui/web/templates/navbar.html b/pyx2cscope/gui/web/templates/navbar.html index b918107e..bda2b25e 100644 --- a/pyx2cscope/gui/web/templates/navbar.html +++ b/pyx2cscope/gui/web/templates/navbar.html @@ -5,6 +5,13 @@

{{ title }} {{ version }}

upload_file + + save + + + + folder_open + help_outline diff --git a/pyx2cscope/gui/web/templates/source_config.html b/pyx2cscope/gui/web/templates/source_config.html index b0b0e89d..e722b9b9 100644 --- a/pyx2cscope/gui/web/templates/source_config.html +++ b/pyx2cscope/gui/web/templates/source_config.html @@ -8,15 +8,7 @@
-
- -
-
- -
-
+
-
- - -
-
+
+ New Script diff --git a/pyx2cscope/gui/web/views/script_view.py b/pyx2cscope/gui/web/views/script_view.py index b31742c8..b630dcec 100644 --- a/pyx2cscope/gui/web/views/script_view.py +++ b/pyx2cscope/gui/web/views/script_view.py @@ -1,6 +1,6 @@ """Script View Blueprint - handles scripting-related HTTP endpoints.""" -from flask import Blueprint, jsonify, render_template +from flask import Blueprint, Response, jsonify, render_template from pyx2cscope.gui.resources import get_resource_path @@ -13,6 +13,19 @@ def script_view(): return render_template("index_scripting.html", title="Script View") +@script_bp.route("/template") +def script_template(): + """Download the built-in script template file.""" + template_path = get_resource_path("script_template.py") + with open(template_path, "r", encoding="utf-8") as f: + content = f.read() + return Response( + content, + mimetype="text/x-python", + headers={"Content-Disposition": "attachment; filename=my_script.py"}, + ) + + @script_bp.route("/help") def script_help(): """Return the script help content as markdown.""" From 0f3470f492386eabb714451424ea1c9d2979e318 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Mon, 11 May 2026 18:23:03 +0200 Subject: [PATCH 12/32] fixed trigger documentation --- pyx2cscope/x2cscope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyx2cscope/x2cscope.py b/pyx2cscope/x2cscope.py index d089c26a..d019b572 100644 --- a/pyx2cscope/x2cscope.py +++ b/pyx2cscope/x2cscope.py @@ -58,7 +58,7 @@ class TriggerConfig: trigger_level (int): The trigger level value for a specified channel. trigger_mode (int): 0 Auto, 1 Triggered (default) . trigger_delay (int): The trigger delay (in percentage to the scope size) (default 0). - trigger_edge (int): Rising 0, falling 1. + trigger_edge (int): Falling edge 0, rising edge 1. """ variable: Variable From e5248e6091df849e8708150b84740e3df180fcec Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Tue, 12 May 2026 18:37:34 +0200 Subject: [PATCH 13/32] fixed None at Qt App var list --- .../gui/qt/dialogs/variable_selection.py | 9 +++- pyx2cscope/gui/qt/models/app_state.py | 22 +++++----- pyx2cscope/gui/qt/tabs/scope_view_tab.py | 41 ++++++++++++++++--- pyx2cscope/gui/qt/tabs/watch_view_tab.py | 2 +- pyx2cscope/gui/qt/workers/data_poller.py | 4 +- 5 files changed, 56 insertions(+), 22 deletions(-) diff --git a/pyx2cscope/gui/qt/dialogs/variable_selection.py b/pyx2cscope/gui/qt/dialogs/variable_selection.py index b66f3d29..15ca9c25 100644 --- a/pyx2cscope/gui/qt/dialogs/variable_selection.py +++ b/pyx2cscope/gui/qt/dialogs/variable_selection.py @@ -21,7 +21,7 @@ class VariableSelectionDialog(QDialog): Double-clicking or pressing OK selects the highlighted variable. """ - def __init__(self, variables: List[str], parent=None, sfr_variables: Optional[List[str]] = None): + def __init__(self, variables: List[str], parent=None, sfr_variables: Optional[List[str]] = None, show_clear: bool = False): """Initialize the variable selection dialog. Args: @@ -30,10 +30,13 @@ def __init__(self, variables: List[str], parent=None, sfr_variables: Optional[Li sfr_variables: An optional list of SFR names. When provided the SFR toggle checkbox is enabled and the user can switch between the two namespaces. + show_clear: When True, a "[ Clear ]" entry is shown at the top of the + list to allow removing the current variable selection. """ super().__init__(parent) self._variables = variables self._sfr_variables: List[str] = sfr_variables or [] + self._show_clear = show_clear self._active_list = self._variables self.selected_variable: Optional[str] = None @@ -68,6 +71,8 @@ def _init_ui(self): # Variable list self.variable_list = QListWidget(self) + if self._show_clear: + self.variable_list.addItem("[ Clear ]") self.variable_list.addItems(self._active_list) self.variable_list.itemDoubleClicked.connect(self._accept_selection) layout.addWidget(self.variable_list) @@ -103,6 +108,8 @@ def _filter_variables(self, text: str): text: The input text to filter variables. """ self.variable_list.clear() + if self._show_clear: + self.variable_list.addItem("[ Clear ]") filtered = [var for var in self._active_list if text.lower() in var.lower()] self.variable_list.addItems(filtered) diff --git a/pyx2cscope/gui/qt/models/app_state.py b/pyx2cscope/gui/qt/models/app_state.py index 76903f89..e940d9a5 100644 --- a/pyx2cscope/gui/qt/models/app_state.py +++ b/pyx2cscope/gui/qt/models/app_state.py @@ -163,8 +163,6 @@ def set_x2cscope(self, x2cscope: Optional[X2CScope]): self._x2cscope = x2cscope if x2cscope: self._variable_list = x2cscope.list_variables() or [] - if self._variable_list: - self._variable_list.insert(0, "None") self._connected = True else: self._variable_list = [] @@ -236,9 +234,9 @@ def export_selected_variables(self, filename: str): seen_items = set() selected_vars = [] - selected_vars.extend((var.name, var.sfr) for var in self._watch_vars if var.name and var.name != "None") - selected_vars.extend((var.name, var.sfr) for var in self._live_watch_vars if var.name and var.name != "None") - selected_vars.extend((channel.name, channel.sfr) for channel in self._scope_channels if channel.name and channel.name != "None") + selected_vars.extend((var.name, var.sfr) for var in self._watch_vars if var.name) + selected_vars.extend((var.name, var.sfr) for var in self._live_watch_vars if var.name) + selected_vars.extend((channel.name, channel.sfr) for channel in self._scope_channels if channel.name) for name, sfr in selected_vars: key = (name, sfr) @@ -327,7 +325,7 @@ def read_variable(self, name: str, sfr: bool = False) -> Optional[float]: name: The variable name to read. sfr: When True, look up the name in the SFR register map. """ - if not name or name == "None": + if not name: return None self._mutex.lock() try: @@ -349,7 +347,7 @@ def read_watch_var_value(self, index: int) -> Optional[float]: watch_var = self._watch_vars[index] if watch_var.var_ref is not None: return watch_var.var_ref.get_value() - elif watch_var.name and watch_var.name != "None" and self._x2cscope: + elif watch_var.name and self._x2cscope: # Fallback: get and cache the variable var_ref = self._x2cscope.get_variable(watch_var.name) if var_ref is not None: @@ -369,7 +367,7 @@ def read_live_watch_var_value(self, index: int) -> Optional[float]: live_var = self._live_watch_vars[index] if live_var.var_ref is not None: return live_var.var_ref.get_value() - elif live_var.name and live_var.name != "None" and self._x2cscope: + elif live_var.name and self._x2cscope: # Fallback: get and cache the variable var_ref = self._x2cscope.get_variable(live_var.name) if var_ref is not None: @@ -389,7 +387,7 @@ def write_variable(self, name: str, value: float, sfr: bool = False) -> bool: value: The value to write. sfr: When True, look up the name in the SFR register map. """ - if not name or name == "None": + if not name: return False self._mutex.lock() try: @@ -432,7 +430,7 @@ def update_watch_var_field(self, index: int, field: str, value: Any): if 0 <= index < len(self._watch_vars): setattr(self._watch_vars[index], field, value) # Cache the x2cscope variable reference when name is set - if field == "name" and value and value != "None" and self._x2cscope: + if field == "name" and value and self._x2cscope: sfr = self._watch_vars[index].sfr var_ref = self._x2cscope.get_variable(value, sfr=sfr) if var_ref is not None: @@ -563,7 +561,7 @@ def start_scope(self) -> bool: # Add configured channels for channel in self._scope_channels: - if channel.name and channel.name != "None": + if channel.name: variable = self._x2cscope.get_variable(channel.name, sfr=channel.sfr) if variable is not None: self._x2cscope.add_scope_channel(variable) @@ -753,7 +751,7 @@ def update_live_watch_var_field(self, index: int, field: str, value: Any): if 0 <= index < len(self._live_watch_vars): setattr(self._live_watch_vars[index], field, value) # Cache the x2cscope variable reference when name is set - if field == "name" and value and value != "None" and self._x2cscope: + if field == "name" and value and self._x2cscope: sfr = self._live_watch_vars[index].sfr var_ref = self._x2cscope.get_variable(value, sfr=sfr) if var_ref is not None: diff --git a/pyx2cscope/gui/qt/tabs/scope_view_tab.py b/pyx2cscope/gui/qt/tabs/scope_view_tab.py index ab969a73..8dc43aa8 100644 --- a/pyx2cscope/gui/qt/tabs/scope_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/scope_view_tab.py @@ -287,12 +287,21 @@ def _on_variable_click(self, index: int): return sfr_list = self._app_state.get_sfr_list() - dialog = VariableSelectionDialog(self._variable_list, self, sfr_variables=sfr_list) + slot_populated = bool(self._var_line_edits[index].text() or self._app_state.get_scope_channel(index).name) + dialog = VariableSelectionDialog(self._variable_list, self, sfr_variables=sfr_list, show_clear=slot_populated) if dialog.exec_() == QDialog.Accepted and dialog.selected_variable: - self._var_line_edits[index].setText(dialog.selected_variable) - # Store sfr flag before updating name so sampling uses the right namespace - self._app_state.update_scope_channel_field(index, "sfr", dialog.sfr_selected) - self._app_state.update_scope_channel_field(index, "name", dialog.selected_variable) + if dialog.selected_variable == "[ Clear ]": + # Clear this channel slot + self._var_line_edits[index].setText("") + self._trigger_checkboxes[index].setChecked(False) + self._app_state.update_scope_channel_field(index, "name", "") + self._app_state.update_scope_channel_field(index, "trigger", False) + self._app_state.update_scope_channel_field(index, "sfr", False) + else: + self._var_line_edits[index].setText(dialog.selected_variable) + # Store sfr flag before updating name so sampling uses the right namespace + self._app_state.update_scope_channel_field(index, "sfr", dialog.sfr_selected) + self._app_state.update_scope_channel_field(index, "name", dialog.selected_variable) def _on_trigger_changed(self, index: int, state: int): """Handle trigger checkbox change - only one can be selected.""" @@ -331,7 +340,7 @@ def _start_sampling(self): # Add configured channels for i, line_edit in enumerate(self._var_line_edits): var_name = line_edit.text() - if var_name and var_name != "None": + if var_name: sfr = self._app_state.get_scope_channel(i).sfr variable = x2cscope.get_variable(var_name, sfr=sfr) if variable is not None: @@ -505,8 +514,28 @@ def get_config(self) -> dict: "single_shot": self._single_shot_checkbox.isChecked(), } + def clear_channels(self): + """Clear all scope channel fields to their default state.""" + for le in self._var_line_edits: + le.setText("") + for cb in self._trigger_checkboxes: + cb.setChecked(False) + for sc in self._scaling_edits: + sc.setText("1") + for off in self._offset_edits: + off.setText("0") + for cb in self._color_combos: + cb.setCurrentIndex(0) + for cb in self._visible_checkboxes: + cb.setChecked(True) + for i in range(self.MAX_CHANNELS): + self._app_state.update_scope_channel_field(i, "name", "") + self._app_state.update_scope_channel_field(i, "trigger", False) + self._app_state.update_scope_channel_field(i, "visible", True) + def load_config(self, config: dict): """Load configuration into the tab.""" + self.clear_channels() variables = config.get("variables", []) triggers = config.get("trigger", []) scales = config.get("scale", []) diff --git a/pyx2cscope/gui/qt/tabs/watch_view_tab.py b/pyx2cscope/gui/qt/tabs/watch_view_tab.py index 1fdd52ed..01f59671 100644 --- a/pyx2cscope/gui/qt/tabs/watch_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/watch_view_tab.py @@ -340,7 +340,7 @@ def _on_value_changed(self, ve: QLineEdit): index = self._value_edits.index(ve) var_name = self._variable_edits[index].text() - if var_name and var_name != "None": + if var_name: try: value = float(ve.text()) sfr = self._app_state.get_live_watch_var(index).sfr diff --git a/pyx2cscope/gui/qt/workers/data_poller.py b/pyx2cscope/gui/qt/workers/data_poller.py index 2be2d834..33070cbe 100644 --- a/pyx2cscope/gui/qt/workers/data_poller.py +++ b/pyx2cscope/gui/qt/workers/data_poller.py @@ -157,7 +157,7 @@ def _poll_watch_variables(self): for index in indices: watch_var = self._app_state.get_watch_var(index) logging.debug(f"_poll_watch_variables: index={index}, name='{watch_var.name}'") - if watch_var.name and watch_var.name != "None": + if watch_var.name: # Use cached var_ref for faster polling value = self._app_state.read_watch_var_value(index) logging.debug(f"_poll_watch_variables: read value={value}") @@ -245,7 +245,7 @@ def _poll_live_variables(self): for index in indices: live_var = self._app_state.get_live_watch_var(index) logging.debug(f"_poll_live_variables: index={index}, name='{live_var.name}'") - if live_var.name and live_var.name != "None": + if live_var.name: # Use cached var_ref for faster polling value = self._app_state.read_live_watch_var_value(index) logging.debug(f"_poll_live_variables: read value={value}") From 347faf899c1209a5173252da461a7b5540be6b65 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Mon, 18 May 2026 17:57:45 +0200 Subject: [PATCH 14/32] fix trigger when loading config --- pyx2cscope/examples/step_response.py | 84 ++++++++++++++++++++++++ pyx2cscope/gui/qt/tabs/scope_view_tab.py | 4 +- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 pyx2cscope/examples/step_response.py diff --git a/pyx2cscope/examples/step_response.py b/pyx2cscope/examples/step_response.py new file mode 100644 index 00000000..2a46ebd5 --- /dev/null +++ b/pyx2cscope/examples/step_response.py @@ -0,0 +1,84 @@ +"""This example can be used to try the scope functionality of pyX2Cscope and save the data acquisition in CSV file.""" + +import csv +import logging +import time + +import matplotlib +import matplotlib.pyplot as plt + +from pyx2cscope.utils import get_elf_file_path +from pyx2cscope.x2cscope import X2CScope +from pyx2cscope.x2cscope import TriggerConfig + +# Set up logging +logging.basicConfig( + level=logging.INFO, + filename=__file__ + ".log", +) + +# Check if x2cscope was injected by the Scripting tab, otherwise create our own +if globals().get("x2cscope") is None: + x2cscope = X2CScope(elf_file=get_elf_file_path()) + +# Get stop_requested function if running from Scripting tab, otherwise use a dummy +stop_requested = globals().get("stop_requested", lambda: False) + +motor_speed = x2cscope.get_variable("motorSpeedRPM") +v_ref = x2cscope.get_variable("SpeedReference") +tune = x2cscope.get_variable("RelayTuningEnable") + +x2cscope.clear_all_scope_channel() +x2cscope.add_scope_channel(motor_speed) +x2cscope.add_scope_channel(v_ref) + +trig = TriggerConfig(v_ref) +trig.trigger_level = 600 +trig.trigger_delay = 50 +trig.trigger_edge = 1 +trig.trigger_mode = 1 + +x2cscope.set_scope_trigger(trig) + +print("Connected to x2cscope, channels added, and trigger configured.") +print("Set speed to 300 RPM and wait 3 seconds to settle before capture.") +print(trig) + +tune.set_value(1.0) # enable tuning relay to apply steps to reference +v_ref.set_value(300) +time.sleep(3) # ensure the change is applied before capture + +LIVE_PNG = __file__.replace(".py", ".png") + +fig, ax = plt.subplots() + +print("Capturing data…") +x2cscope.set_sample_time(30) +x2cscope.request_scope_data() + +time.sleep(1) # give it a moment to start the capture before applying the step +print("Applying step to 1000 RPM …") +v_ref.set_value(1000) # set step to 1000 + +while not stop_requested(): + if x2cscope.is_scope_data_ready(): + break + time.sleep(0.1) + +print("Data ready, processing…") +tune.set_value(0) + +ax.clear() +for channel, data in x2cscope.get_scope_channel_data().items(): + time_values = [i * 0.0015 for i in range(len(data))] # ms + ax.plot(time_values, data, label=f"{channel}") + +ax.set_xlabel("Time (ms)") +ax.set_ylabel("Value") +ax.legend() +fig.tight_layout() + +# Overwrite the same file each frame — open it in any image viewer +fig.savefig(LIVE_PNG, dpi=100) +plt.close(fig) +print("finish") diff --git a/pyx2cscope/gui/qt/tabs/scope_view_tab.py b/pyx2cscope/gui/qt/tabs/scope_view_tab.py index 8dc43aa8..f62efe11 100644 --- a/pyx2cscope/gui/qt/tabs/scope_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/scope_view_tab.py @@ -550,6 +550,9 @@ def load_config(self, config: dict): self._app_state.update_scope_channel_field(i, "sfr", sfr) # Update app state with variable name self._app_state.update_scope_channel_field(i, "name", var) + # Set _trigger_variable before restoring checkboxes so that _on_trigger_changed() + # does not overwrite it with an empty string when the signal fires. + self._trigger_variable = config.get("trigger_variable", "") for i, (cb, trigger) in enumerate(zip(self._trigger_checkboxes, triggers)): cb.setChecked(trigger) self._app_state.update_scope_channel_field(i, "trigger", trigger) @@ -567,7 +570,6 @@ def load_config(self, config: dict): cb.setChecked(show) self._app_state.update_scope_channel_field(i, "visible", show) - self._trigger_variable = config.get("trigger_variable", "") self._trigger_level_edit.setText(config.get("trigger_level", "0")) self._trigger_delay_combo.setCurrentText(config.get("trigger_delay", "0")) self._trigger_edge_combo.setCurrentText(config.get("trigger_edge", "Rising")) From b1980d1ac304a2fed45abf1dcdffc8710fda60ef Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Tue, 19 May 2026 09:03:19 +0200 Subject: [PATCH 15/32] Web App enhancements --- pyx2cscope/gui/web/static/css/style.css | 6 ++ pyx2cscope/gui/web/static/js/scope_view.js | 97 ++++++++----------- pyx2cscope/gui/web/static/js/script.js | 17 ++++ .../gui/web/templates/sample_control.html | 86 ++++++++++++---- pyx2cscope/gui/web/templates/scope_view.html | 15 +-- .../gui/web/templates/trigger_control.html | 53 +--------- 6 files changed, 137 insertions(+), 137 deletions(-) diff --git a/pyx2cscope/gui/web/static/css/style.css b/pyx2cscope/gui/web/static/css/style.css index bda4a858..f4d0edef 100644 --- a/pyx2cscope/gui/web/static/css/style.css +++ b/pyx2cscope/gui/web/static/css/style.css @@ -13,9 +13,15 @@ html { font-size: 12px; } +/* Prevent fixed-bottom footer from overlapping page content */ +body { + padding-bottom: 100px; +} + .chart-container { position: relative; width: 100%; + height: 45vh; min-height: 300px; } diff --git a/pyx2cscope/gui/web/static/js/scope_view.js b/pyx2cscope/gui/web/static/js/scope_view.js index 6c397c77..2c366c62 100644 --- a/pyx2cscope/gui/web/static/js/scope_view.js +++ b/pyx2cscope/gui/web/static/js/scope_view.js @@ -37,61 +37,43 @@ socket_sv.on("scope_chart_update", (data) => { socket_sv.on("sample_control_updated", function(response) { if (response.status === "success") { - // Handle triggerAction radio buttons if (response.data.triggerAction) { document.querySelectorAll('input[name="triggerAction"]').forEach(radio => { const isTarget = radio.value === response.data.triggerAction; radio.checked = isTarget; - const label = document.querySelector(`label[for="${radio.id}"]`); - label.classList.toggle('active', isTarget); + document.querySelector(`label[for="${radio.id}"]`).classList.toggle('active', isTarget); }); } - // Handle sampleTime input - if (response.data.sampleTime !== undefined) { - const sampleTimeInput = document.getElementById('sampleTime'); - sampleTimeInput.value = response.data.sampleTime; - } - // Handle sampleFreq input - if (response.data.sampleFreq !== undefined) { - const sampleFreqInput = document.getElementById('sampleFreq'); - sampleFreqInput.value = response.data.sampleFreq; - } + if (response.data.sampleTime !== undefined) + document.getElementById('sampleTime').value = response.data.sampleTime; + if (response.data.sampleFreq !== undefined) + document.getElementById('sampleFreq').value = response.data.sampleFreq; } else { console.error("Failed to update sample control:", response.message); } }); -// Add this handler for the trigger control response socket_sv.on("trigger_control_updated", function(response) { if (response.status === "success" && response.data) { - // Handle trigger_mode radio buttons if (response.data.trigger_mode !== undefined) { + const val = response.data.trigger_mode.toString(); document.querySelectorAll('input[name="trigger_mode"]').forEach(radio => { - const isTarget = radio.value === response.data.trigger_mode.toString(); - radio.checked = isTarget; - const label = document.querySelector(`label[for="${radio.id}"]`); - label.classList.toggle('active', isTarget); + radio.checked = radio.value === val; + document.querySelector(`label[for="${radio.id}"]`).classList.toggle('active', radio.checked); }); + document.getElementById('triggerOptions').style.display = val === "1" ? '' : 'none'; } - // Handle trigger_edge radio buttons if (response.data.trigger_edge !== undefined) { + const val = response.data.trigger_edge.toString(); document.querySelectorAll('input[name="trigger_edge"]').forEach(radio => { - const isTarget = radio.value === response.data.trigger_edge.toString(); - radio.checked = isTarget; - const label = document.querySelector(`label[for="${radio.id}"]`); - label.classList.toggle('active', isTarget); + radio.checked = radio.value === val; + document.querySelector(`label[for="${radio.id}"]`).classList.toggle('active', radio.checked); }); } - // Handle trigger_level input - if (response.data.trigger_level !== undefined) { - const triggerLevelInput = document.getElementById('triggerLevel'); - triggerLevelInput.value = response.data.trigger_level; - } - // Handle trigger_delay input - if (response.data.trigger_delay !== undefined) { - const triggerDelayInput = document.getElementById('triggerDelay'); - triggerDelayInput.value = response.data.trigger_delay; - } + if (response.data.trigger_level !== undefined) + document.getElementById('triggerLevel').value = response.data.trigger_level; + if (response.data.trigger_delay !== undefined) + document.getElementById('triggerDelay').value = response.data.trigger_delay; } else if (response.status !== "success") { console.error("Failed to update trigger control:", response.message); } @@ -394,47 +376,50 @@ function initScopeChart() { $('#chartExport').attr("href", "/scope/export") } -function initScopeForms(){ +function emitTriggerControl() { + const mode = document.querySelector('input[name="trigger_mode"]:checked').value; + const edge = document.querySelector('input[name="trigger_edge"]:checked')?.value ?? '1'; + const data = { + trigger_mode: mode, + trigger_edge: edge, + trigger_level: document.getElementById('triggerLevel').value, + trigger_delay: document.getElementById('triggerDelay').value, + }; + socket_sv.emit("update_trigger_control", new URLSearchParams(data).toString()); +} + +function initScopeForms() { $("#sampleControlForm").submit(function(e) { - e.preventDefault(); // avoid to execute the actual submit of the form. - var formData = $(this).serialize(); - socket_sv.emit("update_sample_control", formData); + e.preventDefault(); + socket_sv.emit("update_sample_control", $(this).serialize()); }); - // Add change event handlers for the sample control radio buttons $('input[name="triggerAction"]').on('change', function() { - // Remove active class from all labels in the same button group $(this).closest('.btn-group').find('.btn').removeClass('active'); - // Add active class to the clicked button's label $(`label[for="${this.id}"]`).addClass('active'); - - // Submit the form $("#sampleControlForm").submit(); }); - // Add change event handlers for sample time and frequency inputs $('#sampleTime, #sampleFreq').on('change', function() { $("#sampleControlForm").submit(); }); - // Initialize the active state of the stop button on page load - $('input[name="triggerAction"][checked]').trigger('change'); - - // Handle trigger control form submission - $("#triggerControlForm").submit(function(e) { - e.preventDefault(); - var formData = $(this).serialize(); - socket_sv.emit("update_trigger_control", formData); + $('input[name="trigger_mode"]').on('change', function() { + $(this).closest('.btn-group').find('.btn').removeClass('active'); + $(`label[for="${this.id}"]`).addClass('active'); + const enabled = this.value === "1"; + $('#triggerOptions').toggle(enabled); + emitTriggerControl(); }); - // Add change event handlers for the radio buttons to update their visual state - $('input[name="trigger_mode"]').on('change', function() { - // Remove active class from all labels in the same button group + $('input[name="trigger_edge"]').on('change', function() { $(this).closest('.btn-group').find('.btn').removeClass('active'); - // Add active class to the clicked button's label $(`label[for="${this.id}"]`).addClass('active'); }); + $('#applyTrigger').on('click', function() { + emitTriggerControl(); + }); } $(document).ready(function () { diff --git a/pyx2cscope/gui/web/static/js/script.js b/pyx2cscope/gui/web/static/js/script.js index dd196dab..4a808639 100644 --- a/pyx2cscope/gui/web/static/js/script.js +++ b/pyx2cscope/gui/web/static/js/script.js @@ -184,6 +184,23 @@ function initSetupCard(){ processData: false, success: function(response) { // Tables are refreshed via SocketIO events from server + // Show Watch and Scope views, hide Dashboard and Script + var tWatch = document.getElementById('toggleWatch'); + var tScope = document.getElementById('toggleScope'); + var tDashboard = document.getElementById('toggleDashboard'); + var tScript = document.getElementById('toggleScript'); + tWatch.checked = true; + tScope.checked = true; + tDashboard.checked = false; + tScript.checked = false; + document.getElementById('watchCol').classList.remove('d-none'); + document.getElementById('scopeCol').classList.remove('d-none'); + document.getElementById('dashboardCol').classList.add('d-none'); + document.getElementById('scriptCol').classList.add('d-none'); + document.querySelector('label[for="toggleWatch"]').classList.add('active'); + document.querySelector('label[for="toggleScope"]').classList.add('active'); + document.querySelector('label[for="toggleDashboard"]').classList.remove('active'); + document.querySelector('label[for="toggleScript"]').classList.remove('active'); }, error: function(jqXHR) { var msg = (jqXHR.responseJSON && jqXHR.responseJSON.msg) ? jqXHR.responseJSON.msg : 'Failed to load config.'; diff --git a/pyx2cscope/gui/web/templates/sample_control.html b/pyx2cscope/gui/web/templates/sample_control.html index 851cfcd9..184553ab 100644 --- a/pyx2cscope/gui/web/templates/sample_control.html +++ b/pyx2cscope/gui/web/templates/sample_control.html @@ -1,29 +1,81 @@
-
+
- - - - - + + + + +
- -
- -
- + +
+
+ + +
+
+ +
+ + kHz +
- -
- -
- - KHz + +
+ +
+ +
+ + + + + +
+
+ + + diff --git a/pyx2cscope/gui/web/templates/scope_view.html b/pyx2cscope/gui/web/templates/scope_view.html index 2268e1dd..3bd9c2e0 100644 --- a/pyx2cscope/gui/web/templates/scope_view.html +++ b/pyx2cscope/gui/web/templates/scope_view.html @@ -1,6 +1,6 @@
- +
@@ -8,23 +8,14 @@
-
Sample Control
+
Capture & Trigger
{% include 'sample_control.html' %}
- -
-
-
Trigger Control
-
-
- {% include 'trigger_control.html' %} -
-
- +
{% include 'source_config.html' %}
diff --git a/pyx2cscope/gui/web/templates/trigger_control.html b/pyx2cscope/gui/web/templates/trigger_control.html index 35deacad..769d23d6 100644 --- a/pyx2cscope/gui/web/templates/trigger_control.html +++ b/pyx2cscope/gui/web/templates/trigger_control.html @@ -1,52 +1 @@ -
-
- -
- - - - - -
-
- -
- -
- - - - - -
-
- -
- -
- - V -
-
- Please enter a valid level -
-
- -
- -
- - % -
-
Range: -50% to +50%
-
- Please enter a value between -50 and 50 -
-
- -
- -
-
\ No newline at end of file +{# Trigger control has been merged into sample_control.html #} From 25cb46b48ab6a72bd12cc0ed5d2d28bbd9563289 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Tue, 19 May 2026 09:43:49 +0200 Subject: [PATCH 16/32] clean web app --- pyx2cscope/gui/web/templates/trigger_control.html | 1 - 1 file changed, 1 deletion(-) delete mode 100644 pyx2cscope/gui/web/templates/trigger_control.html diff --git a/pyx2cscope/gui/web/templates/trigger_control.html b/pyx2cscope/gui/web/templates/trigger_control.html deleted file mode 100644 index 769d23d6..00000000 --- a/pyx2cscope/gui/web/templates/trigger_control.html +++ /dev/null @@ -1 +0,0 @@ -{# Trigger control has been merged into sample_control.html #} From 62d861884f786abd56d6c577c1d567fbb1cea8b8 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Tue, 19 May 2026 10:13:11 +0200 Subject: [PATCH 17/32] ruff format --- pyx2cscope/examples/step_response.py | 5 +- pyx2cscope/gui/qt/main_window.py | 2 +- pyx2cscope/gui/qt/tabs/watch_view_tab.py | 1 - pyx2cscope/gui/resources/script_template.py | 105 ++++++---- pyx2cscope/gui/web/app.py | 210 +++++++++++--------- tests/test_spec_imports.py | 1 - 6 files changed, 180 insertions(+), 144 deletions(-) diff --git a/pyx2cscope/examples/step_response.py b/pyx2cscope/examples/step_response.py index 2a46ebd5..7d983a7a 100644 --- a/pyx2cscope/examples/step_response.py +++ b/pyx2cscope/examples/step_response.py @@ -1,15 +1,12 @@ """This example can be used to try the scope functionality of pyX2Cscope and save the data acquisition in CSV file.""" -import csv import logging import time -import matplotlib import matplotlib.pyplot as plt from pyx2cscope.utils import get_elf_file_path -from pyx2cscope.x2cscope import X2CScope -from pyx2cscope.x2cscope import TriggerConfig +from pyx2cscope.x2cscope import TriggerConfig, X2CScope # Set up logging logging.basicConfig( diff --git a/pyx2cscope/gui/qt/main_window.py b/pyx2cscope/gui/qt/main_window.py index 3d99fb8e..2bfa0fae 100644 --- a/pyx2cscope/gui/qt/main_window.py +++ b/pyx2cscope/gui/qt/main_window.py @@ -4,7 +4,7 @@ import os from PyQt5 import QtGui -from PyQt5.QtCore import QSettings, QTimer, Qt +from PyQt5.QtCore import QSettings, Qt, QTimer from PyQt5.QtWidgets import ( QApplication, QFileDialog, diff --git a/pyx2cscope/gui/qt/tabs/watch_view_tab.py b/pyx2cscope/gui/qt/tabs/watch_view_tab.py index 01f59671..16932639 100644 --- a/pyx2cscope/gui/qt/tabs/watch_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/watch_view_tab.py @@ -408,7 +408,6 @@ def load_config(self, config: dict): values = config.get("values", []) scalings = config.get("scaling", []) offsets = config.get("offsets", []) - scaled_values = config.get("scaled_values", []) lives = config.get("live", []) sfrs = config.get("sfr", []) diff --git a/pyx2cscope/gui/resources/script_template.py b/pyx2cscope/gui/resources/script_template.py index 9c9d46aa..0b1b0150 100644 --- a/pyx2cscope/gui/resources/script_template.py +++ b/pyx2cscope/gui/resources/script_template.py @@ -9,51 +9,70 @@ the user presses the *Stop* button. Call it inside every loop iteration so the script can be interrupted gracefully. """ - import time +from pyx2cscope.utils import get_elf_file_path +from pyx2cscope.x2cscope import X2CScope + +# Check if x2cscope was injected by the Scripting tab, otherwise create our own +if globals().get("x2cscope") is None: + x2cscope = X2CScope(elf_file=get_elf_file_path()) + +# Get stop_requested function if running from Scripting tab, otherwise use a dummy +stop_requested = globals().get("stop_requested", lambda: False) + +# ------------------------------------------------------------------ +# List available variables (handy during development) # ------------------------------------------------------------------ -# Guard: make sure we are connected before doing anything +variable_names = x2cscope.get_variable_list() +print(f"Connected – {len(variable_names)} variables available.") +# Uncomment the next line to see all variable names: +# print("\n".join(variable_names)) + +# ------------------------------------------------------------------ +# Example: read from and write to variables +# ------------------------------------------------------------------ +var = x2cscope.get_variable("myModule.mySignal") # adapt to your firmware +value = var.get_value() # read the current value of the variable +print(f"Current value of {var.name} is {value}") +var.set_value(42) # set the value of the variable + # ------------------------------------------------------------------ -if x2cscope is None: - print("No active connection. Please connect to the device first.") +# Example: read a single variable in a loop +# ------------------------------------------------------------------ +print("Reading 'myModule.mySignal' every 100 ms (press Stop to cancel):") +while not stop_requested(): + value = var.get_value() + print(f" {value}") + time.sleep(0.1) + +# ------------------------------------------------------------------ +# Example: capture scope data once +# ------------------------------------------------------------------ +signal_a = x2cscope.get_variable("myModule.signalA") +signal_b = x2cscope.get_variable("myModule.signalB") + +x2cscope.clear_all_scope_channel() +x2cscope.add_scope_channel(signal_a) +x2cscope.add_scope_channel(signal_b) +x2cscope.set_sample_time(1) # capture every sample, adapt as needed + +# Uncomment and adapt the following lines to set up triggering if needed: +# trigger = TriggerConfig(signal_a) +# trigger.trigger_level = 600 +# trigger.trigger_delay = 50 +# trigger.trigger_edge = 1 # trigger enable +# trigger.trigger_mode = 1 # rising edge +# x2cscope.set_scope_trigger(trigger) + +x2cscope.request_scope_data() +while not x2cscope.is_scope_data_ready(): + if stop_requested(): + print("Stopped.") + break + time.sleep(0.05) else: - # ------------------------------------------------------------------ - # List available variables (handy during development) - # ------------------------------------------------------------------ - variable_names = x2cscope.get_variable_list() - print(f"Connected – {len(variable_names)} variables available.") - # Uncomment the next line to see all variable names: - # print("\n".join(variable_names)) - - # ------------------------------------------------------------------ - # Example: read a single variable in a loop - # ------------------------------------------------------------------ - # var = x2cscope.get_variable("myModule.mySignal") # adapt to your firmware - - # print("Reading 'myModule.mySignal' every 100 ms (press Stop to cancel):") - # while not stop_requested(): - # value = var.get_value() - # print(f" {value}") - # time.sleep(0.1) - - # ------------------------------------------------------------------ - # Example: capture scope data once - # ------------------------------------------------------------------ - # ch1 = x2cscope.get_variable("myModule.signalA") - # ch2 = x2cscope.get_variable("myModule.signalB") - # x2cscope.add_scope_channel(ch1) - # x2cscope.add_scope_channel(ch2) - # x2cscope.set_sample_time(1) - # - # x2cscope.request_scope_data() - # while not x2cscope.is_scope_data_ready(): - # if stop_requested(): - # print("Stopped.") - # break - # time.sleep(0.05) - # else: - # for channel, data in x2cscope.get_scope_channel_data().items(): - # print(f"Channel {channel}: {len(data)} samples, first={data[0]:.4f}") - - print("Done.") + for channel, data in x2cscope.get_scope_channel_data().items(): + print(f"Channel {channel}: {len(data)} samples, first={data[0]:.4f}") + +print("Done.") diff --git a/pyx2cscope/gui/web/app.py b/pyx2cscope/gui/web/app.py index 90f8caf2..8ef89816 100644 --- a/pyx2cscope/gui/web/app.py +++ b/pyx2cscope/gui/web/app.py @@ -329,6 +329,90 @@ def save_config(): ) +_WATCH_FLOAT_FIELDS = {"scaling", "offset", "value", "scaled_value"} +_WATCH_INT_FIELDS = {"live"} +_SCOPE_FLOAT_FIELDS = {"gain", "offset"} +_SCOPE_INT_FIELDS = {"trigger", "enable"} + + +def _restore_watch_vars(items, errors): + """Restore watch variables from a list of config dicts.""" + from pyx2cscope.gui.web.extensions import socketio as _socketio + + web_scope.clear_watch_var() + for item in items: + if not isinstance(item, dict) or "variable" not in item: + continue + var = web_scope.add_watch_var(item["variable"], sfr=item.get("sfr", False)) + if var is None: + errors.append(item["variable"]) + else: + for key, value in item.items(): + if key in var and key != "variable" and key != "value": + if key in _WATCH_FLOAT_FIELDS: + var[key] = float(value) + elif key in _WATCH_INT_FIELDS: + var[key] = int(value) + else: + var[key] = value + # Re-read current value from device (add_watch_var already did one read, + # but we need it after scaling/offset are restored for scaled_value) + web_scope._update_watch_fields(var) + _socketio.emit("watch_table_update", {}, namespace="/watch-view") + + +def _restore_scope_vars(items, errors): + """Restore scope variables from a list of config dicts.""" + from pyx2cscope.gui.web.extensions import socketio as _socketio + + web_scope.clear_scope_var() + for item in items: + if not isinstance(item, dict) or "variable" not in item: + continue + var = web_scope.add_scope_var(item["variable"], sfr=item.get("sfr", False)) + if var is None: + errors.append(item["variable"]) + else: + for key, value in item.items(): + if key in var and key != "variable": + if key in _SCOPE_FLOAT_FIELDS: + var[key] = float(value) + elif key in _SCOPE_INT_FIELDS: + var[key] = int(value) + else: + var[key] = value + _socketio.emit("scope_table_update", {}, namespace="/scope-view") + + +def _restore_sample_control(sample_control): + """Restore sample control settings and notify clients.""" + from pyx2cscope.gui.web.extensions import socketio as _socketio + + trigger_action = sample_control.get("triggerAction", "off") + sample_time = max(int(sample_control.get("sampleTime", 1)), 1) + sample_freq = float(sample_control.get("sampleFreq", 20)) + web_scope.scope_set_sample(trigger_action, sample_time, sample_freq) + _socketio.emit("sample_control_updated", { + "status": "success", + "data": web_scope.sample_control, + }, namespace="/scope-view") + + +def _restore_trigger_control(trigger_control): + """Restore trigger control settings and notify clients.""" + from pyx2cscope.gui.web.extensions import socketio as _socketio + + parsed = { + k: (float(v) if k == "trigger_level" else int(v)) + for k, v in trigger_control.items() + } + web_scope.scope_set_trigger(**parsed) + _socketio.emit("trigger_control_updated", { + "status": "success", + "data": web_scope.trigger_control, + }, namespace="/scope-view") + + def load_config(): """Receive a unified JSON config and restore watch and scope variables. @@ -336,102 +420,40 @@ def load_config(): each containing a list of variable dicts (same format as save_config). Emits SocketIO events so all connected browsers reload their tables. """ - from pyx2cscope.gui.web.extensions import socketio as _socketio - cfg_file = request.files.get("file") msg = "Invalid config file." - if cfg_file and cfg_file.filename.endswith(".json"): - try: - data = json.loads(cfg_file.read().decode("utf-8")) - except (ValueError, UnicodeDecodeError): - return jsonify({"status": "error", "msg": msg}), 400 - - if not isinstance(data, dict): - return jsonify({"status": "error", "msg": msg}), 400 - - errors = [] - - # Fields that must be stored as specific types in the internal dicts - _watch_float_fields = {"scaling", "offset", "value", "scaled_value"} - _watch_int_fields = {"live"} - _scope_float_fields = {"gain", "offset"} - _scope_int_fields = {"trigger", "enable"} - - # Restore watch variables - watch_items = data.get("watch_view", []) - if isinstance(watch_items, list) and watch_items: - web_scope.clear_watch_var() - for item in watch_items: - if not isinstance(item, dict) or "variable" not in item: - continue - var = web_scope.add_watch_var(item["variable"], sfr=item.get("sfr", False)) - if var is None: - errors.append(item["variable"]) - else: - for key, value in item.items(): - if key in var and key != "variable" and key != "value": - if key in _watch_float_fields: - var[key] = float(value) - elif key in _watch_int_fields: - var[key] = int(value) - else: - var[key] = value - # Re-read current value from device (add_watch_var already did one read, - # but we need it after scaling/offset are restored for scaled_value) - web_scope._update_watch_fields(var) - _socketio.emit("watch_table_update", {}, namespace="/watch-view") - - # Restore scope variables - scope_items = data.get("scope_view", []) - if isinstance(scope_items, list) and scope_items: - web_scope.clear_scope_var() - for item in scope_items: - if not isinstance(item, dict) or "variable" not in item: - continue - var = web_scope.add_scope_var(item["variable"], sfr=item.get("sfr", False)) - if var is None: - errors.append(item["variable"]) - else: - for key, value in item.items(): - if key in var and key != "variable": - if key in _scope_float_fields: - var[key] = float(value) - elif key in _scope_int_fields: - var[key] = int(value) - else: - var[key] = value - _socketio.emit("scope_table_update", {}, namespace="/scope-view") - - # Restore sample control settings - sample_control = data.get("sample_control") - if isinstance(sample_control, dict): - trigger_action = sample_control.get("triggerAction", "off") - sample_time = max(int(sample_control.get("sampleTime", 1)), 1) - sample_freq = float(sample_control.get("sampleFreq", 20)) - web_scope.scope_set_sample(trigger_action, sample_time, sample_freq) - _socketio.emit("sample_control_updated", { - "status": "success", - "data": web_scope.sample_control, - }, namespace="/scope-view") - - # Restore trigger control settings - trigger_control = data.get("trigger_control") - if isinstance(trigger_control, dict): - parsed = { - k: (float(v) if k == "trigger_level" else int(v)) - for k, v in trigger_control.items() - } - web_scope.scope_set_trigger(**parsed) - _socketio.emit("trigger_control_updated", { - "status": "success", - "data": web_scope.trigger_control, - }, namespace="/scope-view") - - if errors: - return jsonify({"status": "error", "msg": "Variables not available: " + ", ".join(errors)}), 400 - return jsonify({"status": "success"}) - - return jsonify({"status": "error", "msg": msg}), 400 + if not (cfg_file and cfg_file.filename.endswith(".json")): + return jsonify({"status": "error", "msg": msg}), 400 + + try: + data = json.loads(cfg_file.read().decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return jsonify({"status": "error", "msg": msg}), 400 + + if not isinstance(data, dict): + return jsonify({"status": "error", "msg": msg}), 400 + + errors = [] + + watch_items = data.get("watch_view", []) + if isinstance(watch_items, list) and watch_items: + _restore_watch_vars(watch_items, errors) + + scope_items = data.get("scope_view", []) + if isinstance(scope_items, list) and scope_items: + _restore_scope_vars(scope_items, errors) + + sample_control = data.get("sample_control") + if isinstance(sample_control, dict): + _restore_sample_control(sample_control) + + trigger_control = data.get("trigger_control") + if isinstance(trigger_control, dict): + _restore_trigger_control(trigger_control) + + if errors: + return jsonify({"status": "error", "msg": "Variables not available: " + ", ".join(errors)}), 400 + return jsonify({"status": "success"}) def open_browser(host="localhost", web_port=5000): diff --git a/tests/test_spec_imports.py b/tests/test_spec_imports.py index 024b582f..e2199f4e 100644 --- a/tests/test_spec_imports.py +++ b/tests/test_spec_imports.py @@ -2,7 +2,6 @@ import ast import importlib -import platform import sys from pathlib import Path From 4c4554b29795baaa7514913ecba5f7ead9bd23c2 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Tue, 19 May 2026 10:18:55 +0200 Subject: [PATCH 18/32] Y auto range on Qt --- pyx2cscope/gui/qt/tabs/scope_view_tab.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyx2cscope/gui/qt/tabs/scope_view_tab.py b/pyx2cscope/gui/qt/tabs/scope_view_tab.py index f62efe11..d5ebc4a2 100644 --- a/pyx2cscope/gui/qt/tabs/scope_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/scope_view_tab.py @@ -474,6 +474,7 @@ def on_scope_data_ready(self, data: Dict[str, List[float]]): self._plot_widget.setLabel("left", "Value") self._plot_widget.setLabel("bottom", "Time", units="ms") self._plot_widget.showGrid(x=True, y=True) + self._plot_widget.getViewBox().enableAutoRange(axis=pg.ViewBox.YAxis) # Handle single-shot mode - stop sampling after receiving data if self._single_shot_checkbox.isChecked(): From eadf494b292ca887e40881c9993734e81e52327e Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Tue, 19 May 2026 10:29:30 +0200 Subject: [PATCH 19/32] fix template --- pyx2cscope/gui/resources/script_template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyx2cscope/gui/resources/script_template.py b/pyx2cscope/gui/resources/script_template.py index 0b1b0150..e16ae44c 100644 --- a/pyx2cscope/gui/resources/script_template.py +++ b/pyx2cscope/gui/resources/script_template.py @@ -24,7 +24,7 @@ # ------------------------------------------------------------------ # List available variables (handy during development) # ------------------------------------------------------------------ -variable_names = x2cscope.get_variable_list() +variable_names = x2cscope.list_variables() print(f"Connected – {len(variable_names)} variables available.") # Uncomment the next line to see all variable names: # print("\n".join(variable_names)) From 59840b8734e39f45e116b939339f4a1e18716bd1 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Tue, 19 May 2026 10:41:20 +0200 Subject: [PATCH 20/32] fix test --- tests/test_spec_imports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_spec_imports.py b/tests/test_spec_imports.py index e2199f4e..6397385c 100644 --- a/tests/test_spec_imports.py +++ b/tests/test_spec_imports.py @@ -18,7 +18,7 @@ def _extract_hiddenimports(spec_path: Path) -> list[str]: if isinstance(node, ast.Call): for keyword in node.keywords: if keyword.arg == "hiddenimports" and isinstance(keyword.value, ast.List): - return [elt.s for elt in keyword.value.elts if isinstance(elt, ast.Constant)] + return [elt.value for elt in keyword.value.elts if isinstance(elt, ast.Constant)] return [] From 03f04086379a9082bca29ee503e57fba156d3b17 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Fri, 22 May 2026 09:54:58 +0200 Subject: [PATCH 21/32] including system dependencies on the bundle --- pyx2cscope_win.spec | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pyx2cscope_win.spec b/pyx2cscope_win.spec index 5b96abc0..1e93d3ef 100644 --- a/pyx2cscope_win.spec +++ b/pyx2cscope_win.spec @@ -23,6 +23,31 @@ a = Analysis( 'can.interfaces.vector', 'can.interfaces.virtual', 'serial', + # stdlib modules excluded by PyInstaller but needed by user-installed packages + 'timeit', + 'doctest', + 'pdb', + 'profile', + 'cProfile', + 'pstats', + 'pickle', + 'shelve', + 'dbm', + 'fractions', + 'decimal', + 'statistics', + 'difflib', + 'textwrap', + 'html', + 'html.parser', + 'html.entities', + 'xml', + 'xml.etree', + 'xml.etree.ElementTree', + 'csv', + 'configparser', + 'netrc', + 'calendar', ], hookspath=[], hooksconfig={}, From d237f3d37168464366ed27bdaff9f0a5eb17651d Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Fri, 22 May 2026 09:55:45 +0200 Subject: [PATCH 22/32] including option for install additional libraries on CLI --- pyx2cscope/__main__.py | 128 ++++++++++++++++++++++++++++++++--------- 1 file changed, 102 insertions(+), 26 deletions(-) diff --git a/pyx2cscope/__main__.py b/pyx2cscope/__main__.py index 4d331585..241d8d06 100644 --- a/pyx2cscope/__main__.py +++ b/pyx2cscope/__main__.py @@ -4,6 +4,20 @@ sets up the PyQt5 application, and launches the X2Cscope GUI. """ +import os +import sys + +# When running as a PyInstaller executable, add a 'libs' folder (next to the +# executable) to sys.path so scripts can import packages installed there. +# Append rather than insert so bundled packages always take priority over libs/, +# preventing version conflicts (e.g. a newer NumPy in libs/ shadowing the bundled one). +if getattr(sys, "frozen", False): + import site + _libs_dir = os.path.join(os.path.dirname(sys.executable), "libs") + os.makedirs(_libs_dir, exist_ok=True) + # addsitedir appends to sys.path and processes .pth files. + site.addsitedir(_libs_dir) + import logging logging.basicConfig(level=logging.ERROR) @@ -14,6 +28,83 @@ from pyx2cscope import gui, utils +def _early_install() -> bool: + """Handle --install before argparse so it never reaches _args_check.""" + if "--install" not in sys.argv: + return False + + idx = sys.argv.index("--install") + packages = [p for p in sys.argv[idx + 1:] if p != "--with-deps"] + if not packages: + print("--install requires at least one package name.") + return True + + if not getattr(sys, "frozen", False): + print("--install is intended for the standalone executable.") + print(f"Use: pip install {' '.join(packages)}") + return True + + libs_dir = os.path.join(os.path.dirname(sys.executable), "libs") + os.makedirs(libs_dir, exist_ok=True) + print(f"Installing into: {libs_dir}") + + python = _find_matching_python() + if python is None: + ver = sys.version_info + print(f"ERROR: Could not find Python {ver.major}.{ver.minor} on this system.") + print("Install it from https://www.python.org/downloads/ then retry.") + return True + + import subprocess + print(f"Using Python: {python}") + # --no-deps: skip pulling in packages already bundled in the exe (e.g. numpy). + # Users can override with: pyX2Cscope --install scipy --with-deps + cmd = [python, "-m", "pip", "install", "--target", libs_dir] + if "--with-deps" not in sys.argv: + cmd.append("--no-deps") + cmd += packages + subprocess.run(cmd, check=False) + return True + + +def _find_matching_python() -> str | None: + """Find a system Python executable whose version matches the frozen bundle.""" + import glob + import subprocess + + major, minor = sys.version_info.major, sys.version_info.minor + tag = f"{major}.{minor}" + + # Candidates in order of preference. + candidates = [ + # Versioned names on PATH (most reliable cross-platform). + f"python{tag}", + f"python{major}", + "python", + "python3", + # Common Windows install locations. + *glob.glob( + f"C:/Users/*/AppData/Local/Programs/Python/Python{major}{minor}/python.exe" + ), + *glob.glob(f"C:/Python{major}{minor}/python.exe"), + *glob.glob(f"C:/Program Files/Python{major}{minor}/python.exe"), + ] + + for candidate in candidates: + try: + result = subprocess.run( + [candidate, "-c", + f"import sys; v=sys.version_info; exit(0 if (v.major,v.minor)==({major},{minor}) else 1)"], + capture_output=True, + timeout=5, check=False, + ) + if result.returncode == 0: + return candidate + except (FileNotFoundError, OSError, subprocess.TimeoutExpired): + continue + return None + + def parse_arguments(): """Forward the received arguments to ArgParse and parse them. @@ -81,37 +172,22 @@ def parse_arguments(): def _args_check(k_args: argparse.Namespace): - # if both elf and port are not supplied, check if there is a valid config file - if k_args.elf is None and k_args.port is None: - path = utils.get_elf_file_path() - com_port = utils.get_com_port() - if path and com_port: - k_args.elf = path - k_args.port = com_port - # if we supplied and elf but no port - elif k_args.elf and not k_args.port: - com_port = utils.get_com_port() - if com_port: - k_args.port = com_port - else: - raise ValueError("A communication port must be supplied!") - elif not k_args.elf and k_args.port: + # if elf is not supplied, check if there is a valid config file + if k_args.elf is None: path = utils.get_elf_file_path() if path: k_args.elf = path - else: - raise ValueError("An elf-file path must be supplied!") -known_args, unknown_args = parse_arguments() -# if arguments logic is correct, -_args_check(known_args) +if not _early_install(): + known_args, unknown_args = parse_arguments() + _args_check(known_args) -logging.root.handlers.clear() -pyx2cscope.set_logger(level=known_args.log_level, console=known_args.log_console) + logging.root.handlers.clear() + pyx2cscope.set_logger(level=known_args.log_level, console=known_args.log_console) -if known_args.qt and not known_args.web: - gui.execute_qt(unknown_args, **known_args.__dict__) + if known_args.qt and not known_args.web: + gui.execute_qt(unknown_args, **known_args.__dict__) -if known_args.web: - gui.execute_web(**known_args.__dict__) + if known_args.web: + gui.execute_web(**known_args.__dict__) From 10d2df45e7c7cd4e0d049e5a5f2ceccfa47866e5 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Fri, 22 May 2026 09:56:18 +0200 Subject: [PATCH 23/32] enahnce documentation about CLI, GUIs, scripts and VSCode --- doc/cli.rst | 98 ++++++++++ doc/gui.rst | 46 +++++ doc/index.rst | 3 +- doc/scripting_scripts.rst | 248 ++++++++++++++++++++++++ doc/vscode.rst | 103 ++++++++++ pyx2cscope/gui/resources/script_help.md | 16 ++ 6 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 doc/cli.rst create mode 100644 doc/gui.rst create mode 100644 doc/scripting_scripts.rst create mode 100644 doc/vscode.rst diff --git a/doc/cli.rst b/doc/cli.rst new file mode 100644 index 00000000..37a5dd5d --- /dev/null +++ b/doc/cli.rst @@ -0,0 +1,98 @@ +Command-Line Interface +====================== + +pyX2Cscope can be launched from the terminal with arguments that control which +GUI is started, which ELF file is loaded, and how the connection is +established. Providing ``-e`` and ``-p`` at launch skips all manual input — +the application loads the ELF and connects to the target immediately. + +.. code-block:: console + + python -m pyx2cscope [options] # when installed with pip + pyX2Cscope.exe [options] # standalone executable + +Quick reference +--------------- + +.. list-table:: + :widths: 22 12 66 + :header-rows: 1 + + * - Argument + - Short + - Description + * - ``--elf `` + - ``-e`` + - Path to the ELF file generated by the firmware build. + Required to load variable information. If omitted, pyX2Cscope looks + for a path stored in ``config.ini``. + * - ``--port `` + - ``-p`` + - Serial port to connect to (e.g. ``COM3``, ``/dev/ttyUSB0``). + Use ``AUTO`` to scan for the first available LNet device automatically. + * - ``--web`` + - ``-w`` + - Start the Web GUI instead of the Qt GUI. + * - ``--qt`` + - ``-q`` + - Explicitly start the Qt GUI (default when ``-w`` is not given). + * - ``--web-port `` + - ``-wp`` + - TCP port for the web server (default: ``5000``). + Only used with ``-w``. + * - ``--host `` + - — + - Host address for the web server (default: ``localhost``). + Set to ``0.0.0.0`` to allow connections from other devices on the + network. Only used with ``-w``. + * - ``--log-level `` + - ``-l`` + - Logging verbosity. One of ``DEBUG``, ``INFO``, ``WARNING``, + ``ERROR`` (default), ``CRITICAL``. + * - ``--log-console`` + - ``-c`` + - Print log output to the terminal in addition to the log file. + * - ``--install …`` + - — + - *Standalone executable only.* Install one or more Python packages + into the ``libs/`` folder using the bundled interpreter. + See :ref:`installing-extra-libraries`. + * - ``--version`` + - ``-v`` + - Print the version number and exit. + * - ``--help`` + - ``-h`` + - Print the full help message and exit. + +Examples +-------- + +Start the Qt GUI, load an ELF, and auto-connect: + +.. code-block:: console + + pyX2Cscope.exe -e firmware.elf -p AUTO + +Start the Web GUI on the default port: + +.. code-block:: console + + pyX2Cscope.exe -e firmware.elf -p COM3 -w + +Start the Web GUI and allow access from other devices on the network: + +.. code-block:: console + + pyX2Cscope.exe -e firmware.elf -w --host 0.0.0.0 -wp 8080 + +Run with verbose logging to the terminal: + +.. code-block:: console + + pyX2Cscope.exe -e firmware.elf -l DEBUG -c + +Install an extra library into the standalone executable: + +.. code-block:: console + + pyX2Cscope.exe --install scipy diff --git a/doc/gui.rst b/doc/gui.rst new file mode 100644 index 00000000..f842b3dc --- /dev/null +++ b/doc/gui.rst @@ -0,0 +1,46 @@ +Graphical User Interfaces +========================= + +pyX2Cscope ships with two ready-to-use graphical interfaces: a **Qt desktop +application** and a **Web interface** built on Flask. Both expose the same +core functionality — connecting to a target, reading and writing variables, +recording scope data, and running Python scripts — but they are designed for +different use cases and deployment scenarios. + +.. list-table:: + :widths: 20 40 40 + :header-rows: 1 + + * - + - Qt GUI + - Web GUI + * - **Best for** + - Desktop / laptop, single user + - Remote access, shared setups, headless machines + * - **How to start** + - ``python -m pyx2cscope`` + - ``python -m pyx2cscope -w`` + * - **Access** + - Native window on the local machine + - Any browser, including phones and tablets on the same network + * - **Scripting** + - Built-in editor, output in a dedicated tab + - Browser-based editor, output streamed via WebSocket + * - **Standalone exe** + - ``pyX2Cscope.exe`` (default) + - ``pyX2Cscope.exe -w`` + +Both interfaces are also examples of how to build your own custom GUI on top +of the pyX2Cscope API — the source code is part of the package and can be +used as a starting point. + +See the following sections for details: + +.. toctree:: + :maxdepth: 1 + + cli + gui_qt + gui_web + scripting_scripts + vscode diff --git a/doc/index.rst b/doc/index.rst index 44978dbd..0177b83c 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -36,8 +36,7 @@ See the section examples to check some of the usages you may get by pyX2Cscope. install.rst scripting.rst - gui_qt.md - gui_web.md + gui.rst FW_Support example development.md diff --git a/doc/scripting_scripts.rst b/doc/scripting_scripts.rst new file mode 100644 index 00000000..dfdcf975 --- /dev/null +++ b/doc/scripting_scripts.rst @@ -0,0 +1,248 @@ +Running Scripts in the GUI +========================== + +Both the Qt and Web interfaces include a built-in scripting tab that lets you +load and execute Python scripts without leaving the application. Scripts have +access to the live ``x2cscope`` connection and can read/write firmware variables, +collect scope data, and use any Python library installed on the system. + +.. contents:: On this page + :local: + :depth: 2 + +How scripts are executed +------------------------ + +Scripts run inside the application process via Python's ``exec()`` built-in. +Two variables are automatically injected into every script's namespace: + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Name + - Description + * - ``x2cscope`` + - The active :class:`~pyx2cscope.x2cscope.X2CScope` instance, or ``None`` + if the GUI is not connected to a target. + * - ``stop_requested`` + - A callable that returns ``True`` when the user clicks the **Stop** button. + Use it to exit loops gracefully. + +A minimal script: + +.. code-block:: python + + import time + + while not stop_requested(): + var = x2cscope.get_variable("myVar") + print(f"Value: {var.get_value()}") + time.sleep(0.5) + +Scripts that work both standalone and inside the GUI +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use ``globals().get()`` to detect whether the script is running inside the GUI +or directly from the command line: + +.. code-block:: python + + from pyx2cscope.x2cscope import X2CScope + from pyx2cscope.utils import get_elf_file_path + import time + + # Use the injected connection or create a standalone one + if globals().get("x2cscope") is None: + x2cscope = X2CScope(port="COM3", elf_file=get_elf_file_path()) + + stop_requested = globals().get("stop_requested", lambda: False) + + while not stop_requested(): + var = x2cscope.get_variable("myVar") + print(var.get_value()) + time.sleep(0.5) + +.. note:: + + Creating a new ``X2CScope`` connection while the GUI is already connected to + the same port will cause a conflict. Always check ``globals().get("x2cscope")`` + before opening your own connection. + + +Installing extra libraries (standalone executable only) +------------------------------------------------------- + +When using the **standalone executable** (``pyX2Cscope.exe``), only the packages +that were bundled at build time are available. If a script needs an additional +library — for example ``scipy`` or ``pandas`` — it must be installed into the +``libs/`` folder that lives next to the executable. + +The ``--install`` flag +^^^^^^^^^^^^^^^^^^^^^^ + +The executable ships with a built-in installer that uses the **same Python +version** that is embedded in the executable. This guarantees that binary +packages (C extensions such as ``scipy``, ``numpy``, ``pandas``) are compiled +for the correct interpreter: + +.. code-block:: console + + pyX2Cscope.exe --install scipy + pyX2Cscope.exe --install pandas openpyxl + +After the command completes, the packages are immediately available to all +scripts — no rebuild or restart required. + +.. note:: + + ``--install`` requires that the matching Python version is installed on the + system (e.g. Python 3.12 if the executable was built with Python 3.12). + The installer searches common Windows paths automatically. If it cannot + find a matching interpreter it will print an error with a download link. + +Installing with transitive dependencies +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default ``--install`` passes ``--no-deps`` to pip. This prevents pip from +pulling in packages that are already bundled in the executable (such as +``numpy``), which would otherwise overwrite the bundled version and cause +version-conflict errors. + +If a package requires dependencies that are **not** bundled, add +``--with-deps`` to let pip resolve and install them: + +.. code-block:: console + + pyX2Cscope.exe --install mypackage --with-deps + +Where packages are stored +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +All user-installed packages go into the ``libs/`` folder next to the +executable: + +.. code-block:: text + + pyX2Cscope/ + ├── pyX2Cscope.exe + ├── libs/ + │ ├── scipy/ + │ ├── pandas/ + │ └── ... + └── _internal/ + └── ... + +The ``libs/`` folder is added to ``sys.path`` automatically when the +executable starts, *after* the bundled packages, so bundled versions always +take priority. + +When the executable is not enough +---------------------------------- + +The standalone executable is convenient but it bundles a fixed set of +packages. If you need a complex library environment — many extra packages, +specific version combinations, or packages with heavy native dependencies — +the simplest solution is to **install pyX2Cscope directly with pip** into your +own Python environment and run it from there. This way pip manages all +dependencies normally and every package you install is immediately available +to scripts. + +.. code-block:: console + + pip install pyx2cscope + python -m pyx2cscope -e firmware.elf + +Or, to get a specific interface: + +.. code-block:: console + + python -m pyx2cscope -e firmware.elf # Qt GUI (default) + python -m pyx2cscope -e firmware.elf -w # Web GUI + +With this approach you install extra libraries the normal way: + +.. code-block:: console + + pip install scipy pandas + +And they are available to scripts immediately, with no version-conflict risk. + +.. tip:: + + Using a virtual environment (``python -m venv .venv``) keeps your + pyx2cscope dependencies isolated from other projects on the same machine. + +Troubleshooting +--------------- + +``No module named 'X'`` after installing +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Cause:** The package was installed but a stdlib module it depends on is not +bundled in the executable. + +**Solution:** This is a known limitation. Common missing stdlib modules +(``timeit``, ``html.parser``, ``csv``, ``xml``, ``doctest``, …) are pre-listed +in the build spec and bundled by default. If you encounter one that is not +bundled, please open an issue on the +`pyx2cscope repository `_ with +the full traceback. + +NumPy / binary version conflict +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Symptom:** + +.. code-block:: text + + A module that was compiled using NumPy 1.x cannot be run in NumPy 2.x … + +**Cause:** pip pulled a newer version of NumPy into ``libs/`` which shadows the +bundled one. + +**Solution:** Delete any ``numpy`` folder inside ``libs/`` and reinstall the +offending package using the default ``--no-deps`` flag: + +.. code-block:: console + + pyX2Cscope.exe --install scipy + +If the package genuinely requires a different NumPy version, contact the +pyx2cscope maintainers to request an updated executable build. + +``ERROR: Could not find Python X.Y`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Cause:** The ``--install`` flag needs the matching system Python to invoke +``pip``. It was not found on ``PATH`` or in the common install directories. + +**Solution:** Install the exact Python version shown in the error message from +`python.org `_ and re-run the command. + +Package installs but script still fails to import it +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Check that the package actually landed in ``libs/``: + +.. code-block:: console + + dir pyX2Cscope\libs + +If the folder is empty or missing the package, re-run ``--install``. Also +confirm that no error was printed during installation. + +Pip dependency resolver warning +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Symptom:** + +.. code-block:: text + + ERROR: pip's dependency resolver does not currently take into account all + the packages that are installed … + +**Cause:** This is a non-fatal warning from pip. It appears because the +bundled packages are not visible to pip when it resolves dependencies. The +installation still completes and the package will work correctly as long as +the bundled versions satisfy the requirements. diff --git a/doc/vscode.rst b/doc/vscode.rst new file mode 100644 index 00000000..0beeb54a --- /dev/null +++ b/doc/vscode.rst @@ -0,0 +1,103 @@ +VSCode Integration +================== + +If you develop firmware with MPLAB X and the MPLAB VSCode extension, you can +add a task to ``.vscode/tasks.json`` in your project that launches +pyX2Cscope automatically after a build or flash, pointing directly at the +generated ELF file. + +tasks.json example +------------------ + +Create or edit ``.vscode/tasks.json`` in your firmware project: + +**Single-folder workspace** — use ``${workspaceFolder}`` when the workspace +contains only one project: + +.. code-block:: json + + { + "version": "2.0.0", + "tasks": [ + { + "label": "Run pyX2Cscope", + "type": "process", + "command": "${workspaceFolder}/../pyX2Cscope/pyX2Cscope.exe", + "args": [ + "-p", "AUTO", + "-e", "${workspaceFolder}/out/default.elf" + ], + "options": { + "cwd": "${workspaceFolder}/../pyX2Cscope" + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + } + ] + } + +**Multi-folder workspace** — when the ``.code-workspace`` file contains +several folders (e.g. ``MY_Project1``, ``MY_Project2``), use +``${workspaceFolder:MY_Project1}`` to refer to a specific folder by name: + +.. code-block:: json + + { + "version": "2.0.0", + "tasks": [ + { + "label": "Run pyX2Cscope (MY_Project1)", + "type": "process", + "command": "${workspaceFolder:MY_Project1}/../pyX2Cscope/pyX2Cscope.exe", + "args": [ + "-p", "AUTO", + "-e", "${workspaceFolder:MY_Project1}/out/MY_Project1/default.elf" + ], + "options": { + "cwd": "${workspaceFolder:MY_Project1}/../pyX2Cscope" + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + } + ] + } + +Adapt the ``command`` path to where ``pyX2Cscope.exe`` is located on your +machine, and update the ``-e`` path to match your project's output ELF. +For multi-folder workspaces, replace ``MY_Project1`` with the folder name +as it appears in your ``.code-workspace`` file. + +To launch the Web GUI instead of the Qt GUI, add ``"-w"`` to the ``args`` +list: + +.. code-block:: json + + "args": ["-p", "AUTO", "-e", "${workspaceFolder:MY_Project1}/out/MY_Project1/default.elf", "-w"] + +Running the task +---------------- + +Once configured, run it from **Terminal → Run Task → Run pyX2Cscope** (or +bind it to a keyboard shortcut). pyX2Cscope will open with the correct ELF +loaded and attempt to connect to the device on the first available port — +no manual input needed. + +.. tip:: + + Combine this with a build or flash task using ``dependsOn`` so that + pyX2Cscope launches automatically after every successful firmware flash: + + .. code-block:: json + + { + "label": "Flash and Run pyX2Cscope", + "dependsOn": ["Flash Device", "Run pyX2Cscope"], + "dependsOrder": "sequence", + "problemMatcher": [] + } diff --git a/pyx2cscope/gui/resources/script_help.md b/pyx2cscope/gui/resources/script_help.md index 13b773de..cf8cf160 100644 --- a/pyx2cscope/gui/resources/script_help.md +++ b/pyx2cscope/gui/resources/script_help.md @@ -128,6 +128,22 @@ if x2cscope.is_scope_data_ready(): --- +## Adding Extra Libraries (Executable Only) + +When running the standalone executable, only the bundled packages are available by default. To use additional packages in your scripts, use the built-in `--install` flag — this ensures packages are installed using the **same Python version** that is bundled in the executable: + +``` +pyX2Cscope.exe --install pandas +pyX2Cscope.exe --install pandas scipy numpy +``` + +The packages are installed into a `libs/` folder next to the executable and are immediately importable in scripts — no rebuild required. + +> **Why not use `pip install` directly?** +> The system `pip` may install packages for a different Python version than the one bundled in the executable, causing import errors for packages with C extensions (e.g. `numpy`, `scipy`). The `--install` flag avoids this by always using the bundled interpreter. + +--- + ## Tips 1. Always check if **x2cscope** is available before using it From d01ca45ab53e33f02956fa0f51a4480550f427bb Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Mon, 1 Jun 2026 11:14:50 +0200 Subject: [PATCH 24/32] numbers left aligned on QtApp --- pyx2cscope/gui/qt/tabs/watch_view_tab.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyx2cscope/gui/qt/tabs/watch_view_tab.py b/pyx2cscope/gui/qt/tabs/watch_view_tab.py index 16932639..0c01968e 100644 --- a/pyx2cscope/gui/qt/tabs/watch_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/watch_view_tab.py @@ -184,6 +184,7 @@ def _add_variable_row(self): value_edit = QLineEdit() value_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + value_edit.setAlignment(Qt.AlignLeft) value_edit.editingFinished.connect(lambda ve=value_edit: self._on_value_changed(ve)) scaling_edit = QLineEdit("1") @@ -194,6 +195,7 @@ def _add_variable_row(self): scaled_value_edit = QLineEdit() scaled_value_edit.setReadOnly(True) + scaled_value_edit.setAlignment(Qt.AlignLeft) unit_edit = QLineEdit() @@ -362,9 +364,11 @@ def _update_scaled_value(self, index: int): offset = self.safe_float(self._offset_edits[index].text()) scaled = self.calculate_scaled_value(value, scaling, offset) self._scaled_value_edits[index].setText(f"{scaled:.2f}") + self._scaled_value_edits[index].setCursorPosition(0) except Exception as e: logging.error(f"Error updating scaled value: {e}") self._scaled_value_edits[index].setText("0.00") + self._scaled_value_edits[index].setCursorPosition(0) @pyqtSlot(int, str, float) def on_live_var_updated(self, index: int, name: str, value: float): @@ -377,6 +381,7 @@ def on_live_var_updated(self, index: int, name: str, value: float): """ if index < len(self._value_edits): self._value_edits[index].setText(str(value)) + self._value_edits[index].setCursorPosition(0) self._update_scaled_value(index) def clear_all_rows(self): From 09a048c1de35ffaa2160610fc01dffa7910fe2b5 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Mon, 1 Jun 2026 11:23:46 +0200 Subject: [PATCH 25/32] left align on all text fields after edit --- pyx2cscope/gui/qt/tabs/watch_view_tab.py | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pyx2cscope/gui/qt/tabs/watch_view_tab.py b/pyx2cscope/gui/qt/tabs/watch_view_tab.py index 0c01968e..2e0b592c 100644 --- a/pyx2cscope/gui/qt/tabs/watch_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/watch_view_tab.py @@ -98,6 +98,10 @@ def _setup_ui(self): self._rate_combo.currentIndexChanged.connect(self._on_rate_changed) toolbar.addWidget(self._rate_combo) toolbar.addStretch() + self._read_all_btn = QPushButton("Read All") + self._read_all_btn.setToolTip("Read current value of all variables once") + self._read_all_btn.clicked.connect(self._on_read_all) + toolbar.addWidget(self._read_all_btn) main_layout.addLayout(toolbar) # Scroll area @@ -176,6 +180,7 @@ def _add_variable_row(self): var_edit = QLineEdit() var_edit.setPlaceholderText("Search Variable") var_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + var_edit.setAlignment(Qt.AlignLeft) var_edit.setEnabled(self._app_state.is_connected()) # Enable based on connection state var_edit.installEventFilter(self) @@ -188,9 +193,11 @@ def _add_variable_row(self): value_edit.editingFinished.connect(lambda ve=value_edit: self._on_value_changed(ve)) scaling_edit = QLineEdit("1") + scaling_edit.setAlignment(Qt.AlignLeft) scaling_edit.editingFinished.connect(lambda idx=index: self._update_scaled_value(idx)) offset_edit = QLineEdit("0") + offset_edit.setAlignment(Qt.AlignLeft) offset_edit.editingFinished.connect(lambda idx=index: self._update_scaled_value(idx)) scaled_value_edit = QLineEdit() @@ -198,6 +205,7 @@ def _add_variable_row(self): scaled_value_edit.setAlignment(Qt.AlignLeft) unit_edit = QLineEdit() + unit_edit.setAlignment(Qt.AlignLeft) remove_btn = QPushButton("Remove") remove_btn.clicked.connect(lambda checked, idx=index: self._remove_variable_row(idx)) @@ -335,6 +343,20 @@ def _on_rate_changed(self, combo_index: int): _, interval_ms = self._RATE_OPTIONS[combo_index] self.live_interval_changed.emit(interval_ms) + def _on_read_all(self): + """Read current value of every variable row, regardless of live checkbox.""" + for i, var_edit in enumerate(self._variable_edits): + var_name = var_edit.text().strip() + if not var_name: + continue + live_var = self._app_state.get_live_watch_var(i) + sfr = live_var.sfr if live_var else False + value = self._app_state.read_variable(var_name, sfr=sfr) + if value is not None: + self._value_edits[i].setText(str(value)) + self._value_edits[i].setCursorPosition(0) + self._update_scaled_value(i) + def _on_value_changed(self, ve: QLineEdit): """Handle value edit finished - write to device and refresh scaled value.""" if ve not in self._value_edits: @@ -420,6 +442,7 @@ def load_config(self, config: dict): self._add_variable_row() if i < len(self._variable_edits): self._variable_edits[i].setText(var) + self._variable_edits[i].setCursorPosition(0) sfr = sfrs[i] if i < len(sfrs) else False self._app_state.update_live_watch_var_field(i, "sfr", sfr) self._app_state.update_live_watch_var_field(i, "name", var) @@ -428,8 +451,10 @@ def load_config(self, config: dict): self._app_state.update_live_watch_var_field(i, "type", types[i]) if i < len(scalings) and i < len(self._scaling_edits): self._scaling_edits[i].setText(scalings[i]) + self._scaling_edits[i].setCursorPosition(0) if i < len(offsets) and i < len(self._offset_edits): self._offset_edits[i].setText(offsets[i]) + self._offset_edits[i].setCursorPosition(0) if i < len(lives) and i < len(self._live_checkboxes): self._live_checkboxes[i].setChecked(lives[i]) self._app_state.update_live_watch_var_field(i, "live", lives[i]) @@ -438,9 +463,11 @@ def load_config(self, config: dict): live_value = self._app_state.read_variable(var, sfr=sfr) if live_value is not None: self._value_edits[i].setText(str(live_value)) + self._value_edits[i].setCursorPosition(0) self._app_state.update_live_watch_var_field(i, "value", live_value) elif i < len(values) and i < len(self._value_edits): self._value_edits[i].setText(values[i]) + self._value_edits[i].setCursorPosition(0) self._update_scaled_value(i) @property From 5cde1f95c4d78f158eeb796dfa6d09959c22247f Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Mon, 1 Jun 2026 11:34:42 +0200 Subject: [PATCH 26/32] saving loading units on watch view --- pyx2cscope/gui/qt/main_window.py | 2 ++ pyx2cscope/gui/qt/tabs/watch_view_tab.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/pyx2cscope/gui/qt/main_window.py b/pyx2cscope/gui/qt/main_window.py index 2bfa0fae..a59db3b3 100644 --- a/pyx2cscope/gui/qt/main_window.py +++ b/pyx2cscope/gui/qt/main_window.py @@ -444,6 +444,7 @@ def _save_config(self): "value": float(watch_qt["values"][i]) if i < len(watch_qt.get("values", [])) and watch_qt["values"][i] else 0.0, "scaling": float(watch_qt["scaling"][i]) if i < len(watch_qt.get("scaling", [])) and watch_qt["scaling"][i] else 1.0, "offset": float(watch_qt["offsets"][i]) if i < len(watch_qt.get("offsets", [])) and watch_qt["offsets"][i] else 0.0, + "unit": watch_qt["units"][i] if i < len(watch_qt.get("units", [])) else "", }) # Build sample_control and trigger_control from scope tab Qt format @@ -581,6 +582,7 @@ def _load_config(self): # noqa: PLR0912, PLR0915 "values": [str(v.get("value", "")) for v in raw_watch], "scaling": [str(v.get("scaling", 1.0)) for v in raw_watch], "offsets": [str(v.get("offset", 0.0)) for v in raw_watch], + "units": [v.get("unit", "") for v in raw_watch], "live": [bool(v.get("live", False)) for v in raw_watch], "sfr": [bool(v.get("sfr", False)) for v in raw_watch], } diff --git a/pyx2cscope/gui/qt/tabs/watch_view_tab.py b/pyx2cscope/gui/qt/tabs/watch_view_tab.py index 2e0b592c..8ab1c752 100644 --- a/pyx2cscope/gui/qt/tabs/watch_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/watch_view_tab.py @@ -420,6 +420,7 @@ def get_config(self) -> dict: "scaling": [sc.text() for sc in self._scaling_edits], "offsets": [off.text() for off in self._offset_edits], "scaled_values": [sv.text() for sv in self._scaled_value_edits], + "units": [u.text() for u in self._unit_edits], "live": [cb.isChecked() for cb in self._live_checkboxes], "sfr": [self._app_state.get_live_watch_var(i).sfr for i in range(len(self._variable_edits))], } @@ -435,6 +436,7 @@ def load_config(self, config: dict): values = config.get("values", []) scalings = config.get("scaling", []) offsets = config.get("offsets", []) + units = config.get("units", []) lives = config.get("live", []) sfrs = config.get("sfr", []) @@ -455,6 +457,9 @@ def load_config(self, config: dict): if i < len(offsets) and i < len(self._offset_edits): self._offset_edits[i].setText(offsets[i]) self._offset_edits[i].setCursorPosition(0) + if i < len(units) and i < len(self._unit_edits): + self._unit_edits[i].setText(units[i]) + self._unit_edits[i].setCursorPosition(0) if i < len(lives) and i < len(self._live_checkboxes): self._live_checkboxes[i].setChecked(lives[i]) self._app_state.update_live_watch_var_field(i, "live", lives[i]) From 4e607d64700b773d5770817b6bb95ef7275df323 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Fri, 5 Jun 2026 14:00:06 +0200 Subject: [PATCH 27/32] ruff fix --- pyx2cscope/gui/qt/tabs/watch_view_tab.py | 40 ++++++++++-------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/pyx2cscope/gui/qt/tabs/watch_view_tab.py b/pyx2cscope/gui/qt/tabs/watch_view_tab.py index 8ab1c752..42ead600 100644 --- a/pyx2cscope/gui/qt/tabs/watch_view_tab.py +++ b/pyx2cscope/gui/qt/tabs/watch_view_tab.py @@ -166,12 +166,8 @@ def eventFilter(self, source, event): # noqa: N802 return True # Consume the event after handling return super().eventFilter(source, event) - def _add_variable_row(self): - """Add a new variable row to the grid.""" - row = self._current_row - index = len(self._row_widgets) - - # Create widgets + def _create_row_widgets(self, index: int): + """Create and return all widgets for a variable row.""" live_cb = QCheckBox() # Use the checkbox widget itself to derive the current index at signal time, # so that removal+rearrange never causes stale index captures. @@ -181,7 +177,7 @@ def _add_variable_row(self): var_edit.setPlaceholderText("Search Variable") var_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) var_edit.setAlignment(Qt.AlignLeft) - var_edit.setEnabled(self._app_state.is_connected()) # Enable based on connection state + var_edit.setEnabled(self._app_state.is_connected()) var_edit.installEventFilter(self) type_label = QLabel() @@ -210,19 +206,19 @@ def _add_variable_row(self): remove_btn = QPushButton("Remove") remove_btn.clicked.connect(lambda checked, idx=index: self._remove_variable_row(idx)) - # Add to grid - self._grid_layout.addWidget(live_cb, row, 0) - self._grid_layout.addWidget(var_edit, row, 1) - self._grid_layout.addWidget(type_label, row, 2) - self._grid_layout.addWidget(value_edit, row, 3) - self._grid_layout.addWidget(scaling_edit, row, 4) - self._grid_layout.addWidget(offset_edit, row, 5) - self._grid_layout.addWidget(scaled_value_edit, row, 6) - self._grid_layout.addWidget(unit_edit, row, 7) - self._grid_layout.addWidget(remove_btn, row, 8) - - # Track widgets - widgets = (live_cb, var_edit, type_label, value_edit, scaling_edit, offset_edit, scaled_value_edit, unit_edit, remove_btn) + return (live_cb, var_edit, type_label, value_edit, scaling_edit, offset_edit, scaled_value_edit, unit_edit, remove_btn) + + def _add_variable_row(self): + """Add a new variable row to the grid.""" + row = self._current_row + index = len(self._row_widgets) + + widgets = self._create_row_widgets(index) + live_cb, var_edit, type_label, value_edit, scaling_edit, offset_edit, scaled_value_edit, unit_edit, remove_btn = widgets + + for col, widget in enumerate(widgets): + self._grid_layout.addWidget(widget, row, col) + self._row_widgets.append(widgets) self._live_checkboxes.append(live_cb) self._variable_edits.append(var_edit) @@ -233,12 +229,8 @@ def _add_variable_row(self): self._scaled_value_edits.append(scaled_value_edit) self._unit_edits.append(unit_edit) - # Add to app state self._app_state.add_live_watch_var() - self._current_row += 1 - - # Calculate initial scaled value self._update_scaled_value(index) def _remove_variable_row(self, index: int): From 90f449cadfb0bd8854ad66b3a2d75e174003bc0b Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Mon, 8 Jun 2026 10:27:15 +0200 Subject: [PATCH 28/32] removed duplicate entries of tabs on web app / mobile --- pyx2cscope/gui/web/templates/index.html | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pyx2cscope/gui/web/templates/index.html b/pyx2cscope/gui/web/templates/index.html index 40e5b613..a209e4b7 100644 --- a/pyx2cscope/gui/web/templates/index.html +++ b/pyx2cscope/gui/web/templates/index.html @@ -28,12 +28,6 @@ - - From ee45bdd125febd7466fd95475a3b815991926606 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Mon, 8 Jun 2026 10:27:35 +0200 Subject: [PATCH 29/32] emit warning on web app when var not found --- pyx2cscope/gui/web/app.py | 2 +- pyx2cscope/gui/web/static/js/script.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pyx2cscope/gui/web/app.py b/pyx2cscope/gui/web/app.py index 8ef89816..0465ebee 100644 --- a/pyx2cscope/gui/web/app.py +++ b/pyx2cscope/gui/web/app.py @@ -452,7 +452,7 @@ def load_config(): _restore_trigger_control(trigger_control) if errors: - return jsonify({"status": "error", "msg": "Variables not available: " + ", ".join(errors)}), 400 + return jsonify({"status": "warning", "msg": "Variables not available: " + ", ".join(errors)}) return jsonify({"status": "success"}) diff --git a/pyx2cscope/gui/web/static/js/script.js b/pyx2cscope/gui/web/static/js/script.js index 4a808639..c952269a 100644 --- a/pyx2cscope/gui/web/static/js/script.js +++ b/pyx2cscope/gui/web/static/js/script.js @@ -201,6 +201,9 @@ function initSetupCard(){ document.querySelector('label[for="toggleScope"]').classList.add('active'); document.querySelector('label[for="toggleDashboard"]').classList.remove('active'); document.querySelector('label[for="toggleScript"]').classList.remove('active'); + if (response.status === 'warning') { + alert(response.msg); + } }, error: function(jqXHR) { var msg = (jqXHR.responseJSON && jqXHR.responseJSON.msg) ? jqXHR.responseJSON.msg : 'Failed to load config.'; From 97e108e90b9a2de5c78fbfda68a55a3995164cf7 Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Mon, 8 Jun 2026 10:38:09 +0200 Subject: [PATCH 30/32] Enhance web app help --- pyx2cscope/gui/web/templates/welcome_content.html | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pyx2cscope/gui/web/templates/welcome_content.html b/pyx2cscope/gui/web/templates/welcome_content.html index e14c224e..e4aafee8 100644 --- a/pyx2cscope/gui/web/templates/welcome_content.html +++ b/pyx2cscope/gui/web/templates/welcome_content.html @@ -11,7 +11,17 @@
settings Setup
upload_file Export Variables
-

Use the export icon in the header to save the variables currently used in Watch View, Scope View, and Dashboard as YML or PKL.

+

Use the export icon to save the variables currently used in Watch View, Scope View, and Dashboard as YML or PKL.

+
+ +
+
save Save Config
+

Use the save icon to download a config file containing the current Watch and Scope View variables and Settings.

+
+ +
+
folder_open Load Config
+

Use the load icon to restore a previously saved config.

Available Views
@@ -38,6 +48,8 @@
Tips
  • open_in_new Click open icons to launch views in separate windows
  • help_outline Click the help icon in the header to toggle this card
  • upload_file Click the export icon in the header to download variables used by the active views
  • +
  • save Click the save icon in the header to download the current Watch & Scope configuration as JSON
  • +
  • folder_open Click the load icon in the header to restore a Watch & Scope configuration from a JSON file
  • From 1cd5599c5b8e7d32308102cf3b6da99c0ebacafc Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Mon, 8 Jun 2026 10:43:07 +0200 Subject: [PATCH 31/32] change script template --- pyx2cscope/gui/resources/script_template.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyx2cscope/gui/resources/script_template.py b/pyx2cscope/gui/resources/script_template.py index e16ae44c..0ce673d1 100644 --- a/pyx2cscope/gui/resources/script_template.py +++ b/pyx2cscope/gui/resources/script_template.py @@ -40,11 +40,11 @@ # ------------------------------------------------------------------ # Example: read a single variable in a loop # ------------------------------------------------------------------ -print("Reading 'myModule.mySignal' every 100 ms (press Stop to cancel):") +print("Reading 'myModule.mySignal' every 500 ms (press Stop to cancel):") while not stop_requested(): value = var.get_value() print(f" {value}") - time.sleep(0.1) + time.sleep(0.5) # ------------------------------------------------------------------ # Example: capture scope data once From 79478502c682b38934c40de99dbadc13ba04beeb Mon Sep 17 00:00:00 2001 From: Edras Pacola Date: Tue, 9 Jun 2026 13:09:34 +0200 Subject: [PATCH 32/32] fix documentation --- doc/cli.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/doc/cli.rst b/doc/cli.rst index 37a5dd5d..60e2ec60 100644 --- a/doc/cli.rst +++ b/doc/cli.rst @@ -96,3 +96,22 @@ Install an extra library into the standalone executable: .. code-block:: console pyX2Cscope.exe --install scipy + +.. _installing-extra-libraries: + +Installing extra libraries (standalone executable) +-------------------------------------------------- + +The standalone ``pyX2Cscope.exe`` ships with a bundled Python interpreter and +a fixed set of packages. If your firmware uses a communication layer or +post-processing routine that requires an additional package (e.g. ``scipy``, +``pandas``), you can install it into the ``libs/`` folder that sits next to the +executable: + +.. code-block:: console + + pyX2Cscope.exe --install scipy + pyX2Cscope.exe --install scipy pandas # multiple packages at once + +The packages are installed via the bundled ``pip`` and are isolated from your +system Python. They persist across restarts of the executable.