diff --git a/doc/cli.rst b/doc/cli.rst new file mode 100644 index 00000000..60e2ec60 --- /dev/null +++ b/doc/cli.rst @@ -0,0 +1,117 @@ +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 + +.. _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. 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/gui_qt.md b/doc/gui_qt.md index e4d803ef..c0f64b36 100644 --- a/doc/gui_qt.md +++ b/doc/gui_qt.md @@ -142,9 +142,27 @@ The **Export Variables** button in the Data Views toolbar allows you to: ### Save and Load Config The **Save Config** and **Load Config** buttons allow you to: -- Save the entire configuration including ELF file path, connection settings, and all variable configurations. +- Save the current WatchView and ScopeView variable lists to a `.json` file. - Load a previously saved configuration to quickly restore your setup. -- When loading, the system automatically attempts to connect using the saved settings. +- The saved file also stores the ELF file path and connection settings so that, when loading, the system automatically attempts to reconnect. + +The config file uses a **shared JSON format** that is cross-compatible with the Web GUI: + +```json +{ + "watch_view": [ + {"variable": "motorSpeedRPM", "sfr": false, "live": true, "scaling": "1.0", "offset": "0.0"} + ], + "scope_view": [ + {"variable": "motorSpeedRPM", "sfr": false, "trigger": false, "enable": true, + "color": "#ff0000", "gain": "1.0", "offset": "0.0"} + ], + "elf_file": "/path/to/firmware.elf", + "connection": { "interface": "UART", "port": "COM3" } +} +``` + +A config file saved from the Web GUI (which omits `elf_file` and `connection`) can be loaded into the Qt app — the variable lists will be restored and the app will skip the reconnect step if no ELF path is present. --- diff --git a/doc/gui_web.md b/doc/gui_web.md index f5192d26..e19b84a9 100644 --- a/doc/gui_web.md +++ b/doc/gui_web.md @@ -102,11 +102,38 @@ Supported formats: `.elf`, `.pkl`, `.yml` ### Export Variables -After connecting, click the export icon in the header to export the variables currently used in +After connecting, click the **export** icon (upload_file) in the header to export the variables currently used in Watch View, Scope View, and Dashboard. Choose either `.yml` or `.pkl`. SFR selections are preserved in the exported file too. +### Save / Load Config + +Two additional icons appear in the header once connected: + +| Icon | Description | +|------|-------------| +| **save** | Download the current Watch View and Scope View variable list as `pyx2cscope_config.json` | +| **folder_open** | Open a file picker to load a previously saved `pyx2cscope_config.json` | + +The config file is a JSON file containing **only variable data** (no connection settings): + +```json +{ + "watch_view": [ + {"variable": "motorSpeedRPM", "sfr": false, "live": true, "scaling": 1, "offset": 0} + ], + "scope_view": [ + {"variable": "motorSpeedRPM", "sfr": false, "trigger": false, "enable": true, + "color": "#FF0000", "gain": 1, "offset": 0} + ] +} +``` + +This format is **shared with the Qt desktop app** — a config file saved from one GUI can be +loaded into the other. When a config is loaded, all connected browser tabs are updated +automatically via the WebSocket connection. + ### Connecting Click the **Connect** button to establish communication with the target device. @@ -151,11 +178,6 @@ To modify a variable value on the target: 1. Enter a new value in the **Value** column 2. Click the **Write** button (pencil icon) in the Actions column -### Save/Load Configuration - -- **Save** - Export the current watch list to a `.cfg` file -- **Load** - Import a previously saved watch list configuration - --- ## Scope View @@ -449,5 +471,7 @@ For advanced users, the Web GUI exposes REST API endpoints: | `/is-connected` | GET | Check connection status | | `/variables` | GET | Get list of available variables | | `/serial-ports` | GET | Get available COM ports | +| `/config/save` | GET | Download Watch & Scope config as `pyx2cscope_config.json` | +| `/config/load` | POST | Upload and apply a `pyx2cscope_config.json` file | Additional information can be found in the API documentation. 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/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, 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__) diff --git a/pyx2cscope/examples/step_response.py b/pyx2cscope/examples/step_response.py new file mode 100644 index 00000000..7d983a7a --- /dev/null +++ b/pyx2cscope/examples/step_response.py @@ -0,0 +1,81 @@ +"""This example can be used to try the scope functionality of pyX2Cscope and save the data acquisition in CSV file.""" + +import logging +import time + +import matplotlib.pyplot as plt + +from pyx2cscope.utils import get_elf_file_path +from pyx2cscope.x2cscope import TriggerConfig, X2CScope + +# 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/__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/controllers/config_manager.py b/pyx2cscope/gui/qt/controllers/config_manager.py index 307d89ef..b214b7d6 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) @@ -165,28 +171,31 @@ def show_file_not_found_warning(self, file_path: str): @staticmethod def build_config( - elf_file: str, - connection: Dict[str, Any], scope_view: Dict[str, Any], - tab3_view: Dict[str, Any], + watch_view: Dict[str, Any], view_mode: str = "Both", + sample_control: Optional[Dict[str, Any]] = None, + trigger_control: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Build a configuration dictionary from component data. Args: - elf_file: Path to the ELF file. - connection: Connection parameters (interface type, port, etc.). scope_view: ScopeView tab configuration. - tab3_view: WatchView tab configuration. + watch_view: WatchView tab configuration. view_mode: Monitor view mode (WatchView, ScopeView, Both, None). + sample_control: Sample control settings (optional). + trigger_control: Trigger control settings (optional). Returns: Complete configuration dictionary. """ - return { - "elf_file": elf_file, - "connection": connection, + config = { "scope_view": scope_view, - "tab3_view": tab3_view, + "watch_view": watch_view, "view_mode": view_mode, } + if sample_control is not None: + config["sample_control"] = sample_control + if trigger_control is not None: + config["trigger_control"] = trigger_control + return config 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/main_window.py b/pyx2cscope/gui/qt/main_window.py index dc3b00cd..a59db3b3 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, Qt, QTimer 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")) @@ -231,11 +247,48 @@ 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.""" 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) @@ -357,15 +410,72 @@ def _save_config(self): else: view_mode = "None" - # 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 = [] + for i, v in enumerate(watch_qt.get("variables", [])): + if not v: + continue + live_var = self._app_state.get_live_watch_var(i) + var_ref = live_var.var_ref if live_var else None + primitive = var_ref.__class__.__name__.lower().replace("variable", "") if var_ref else "" + if not primitive: + primitive = watch_qt["types"][i] if i < len(watch_qt.get("types", [])) else "" + watch_shared.append({ + "variable": v, + "type": primitive, + "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, + "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 + name_to_hex = { + "Red": "#FF0000", "Green": "#00FF00", "Blue": "#0000FF", + "Yellow": "#FFFF00", "Cyan": "#00FFFF", "Magenta": "#FF00FF", + "Purple": "#800080", "White": "#CCCCCC", "Black": "#000000", + } + trigger_edge_map = {"Rising": 1, "Falling": 0} + trigger_mode_map = {"Enable": 1, "Disable": 0} + sample_control = { + "triggerAction": "shot" if scope_qt.get("single_shot") else "off", + "sampleTime": int(scope_qt.get("sample_time_factor", 1) or 1), + "sampleFreq": 20.0, + } + trigger_control = { + "trigger_mode": trigger_mode_map.get(scope_qt.get("trigger_mode", "Disable"), 0), + "trigger_edge": trigger_edge_map.get(scope_qt.get("trigger_edge", "Rising"), 1), + "trigger_level": float(scope_qt.get("trigger_level", 0) or 0), + "trigger_delay": int(scope_qt.get("trigger_delay", 0) or 0), + } + # Convert Qt color names to hex in the scope shared list + for entry in scope_shared: + entry["color"] = name_to_hex.get(entry.get("color", ""), entry.get("color", "#FF0000")) 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, + watch_view=watch_shared, view_mode=view_mode, + sample_control=sample_control, + trigger_control=trigger_control, ) self._config_manager.save_config(config) @@ -433,9 +543,52 @@ 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("watch_view", {}) + + # Convert shared list-of-dicts format → Qt dicts-of-arrays format + if isinstance(raw_scope, list): + sample_ctrl = config.get("sample_control", {}) + trigger_ctrl = config.get("trigger_control", {}) + trigger_edge_map = {0: "Falling", 1: "Rising"} + trigger_mode_map = {0: "Disable", 1: "Enable"} + # Map web hex colors to Qt color names + hex_to_name = { + "#FF0000": "Red", "#00FF00": "Green", "#0000FF": "Blue", + "#FFFF00": "Yellow", "#00FFFF": "Cyan", "#FF00FF": "Magenta", + "#800080": "Purple", "#CCCCCC": "White", "#000000": "Black", + } + raw_scope = { + "variables": [v.get("variable", "") for v in raw_scope], + "trigger": [bool(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": [hex_to_name.get(v.get("color", "").upper(), v.get("color", "Red")) for v in raw_scope], + "show": [bool(v.get("enable", True)) for v in raw_scope], + "sfr": [bool(v.get("sfr", False)) for v in raw_scope], + "sample_time_factor": str(sample_ctrl.get("sampleTime", 1)), + "single_shot": sample_ctrl.get("triggerAction", "off") == "shot", + "trigger_level": str(trigger_ctrl.get("trigger_level", 0)), + "trigger_delay": str(int(trigger_ctrl.get("trigger_delay", 0))), + "trigger_edge": trigger_edge_map.get(int(trigger_ctrl.get("trigger_edge", 1)), "Rising"), + "trigger_mode": trigger_mode_map.get(int(trigger_ctrl.get("trigger_mode", 0)), "Disable"), + } + if isinstance(raw_watch, list): + raw_watch = { + "variables": [v.get("variable", "") for v in raw_watch], + "types": [v.get("type", "") for v in raw_watch], + "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], + } + + 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/qt/models/app_state.py b/pyx2cscope/gui/qt/models/app_state.py index 60f60ca7..e940d9a5 100644 --- a/pyx2cscope/gui/qt/models/app_state.py +++ b/pyx2cscope/gui/qt/models/app_state.py @@ -20,6 +20,7 @@ class WatchVariable: """Represents a watch variable configuration.""" name: str = "" + type: str = "" value: float = 0.0 scaling: float = 1.0 offset: float = 0.0 @@ -162,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 = [] @@ -235,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) @@ -326,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: @@ -348,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: @@ -368,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: @@ -388,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: @@ -431,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: @@ -562,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) @@ -752,11 +751,14 @@ 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: self._live_watch_vars[index].var_ref = var_ref + # Derive and cache the primitive type string + primitive = var_ref.__class__.__name__.lower().replace("variable", "") + self._live_watch_vars[index].type = primitive finally: self._mutex.unlock() diff --git a/pyx2cscope/gui/qt/tabs/scope_view_tab.py b/pyx2cscope/gui/qt/tabs/scope_view_tab.py index 02401af4..d5ebc4a2 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: @@ -465,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(): @@ -501,12 +511,32 @@ 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(), } + 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", []) @@ -521,6 +551,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) @@ -538,10 +571,9 @@ 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")) 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/scripting_tab.py b/pyx2cscope/gui/qt/tabs/scripting_tab.py index d068b853..a71d92f7 100644 --- a/pyx2cscope/gui/qt/tabs/scripting_tab.py +++ b/pyx2cscope/gui/qt/tabs/scripting_tab.py @@ -214,6 +214,13 @@ def _setup_ui(self): # noqa: PLR0915 self._edit_btn.setToolTip("Open script in text editor") script_layout.addWidget(self._edit_btn) + # 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) + # Help button self._help_btn = QPushButton("Help") self._help_btn.setFixedWidth(60) @@ -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/qt/tabs/watch_view_tab.py b/pyx2cscope/gui/qt/tabs/watch_view_tab.py index 45b95faa..42ead600 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. @@ -53,6 +57,7 @@ def __init__(self, app_state: "AppState", parent=None): self._row_widgets: List[Tuple] = [] self._live_checkboxes: List[QCheckBox] = [] self._variable_edits: List[QLineEdit] = [] + self._type_labels: List[QLabel] = [] self._value_edits: List[QLineEdit] = [] self._scaling_edits: List[QLineEdit] = [] self._offset_edits: List[QLineEdit] = [] @@ -61,15 +66,44 @@ 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() + main_layout.setContentsMargins(8, 8, 8, 8) self.setLayout(main_layout) + # --- Toolbar row: update-rate selector --- + toolbar = QHBoxLayout() + toolbar.setContentsMargins(0, 0, 0, 4) + 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() + 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 scroll_area = QScrollArea() scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -81,13 +115,13 @@ 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 - headers = ["Live", "Variable", "Value", "Scaling", "Offset", "Scaled Value", "Unit", "Remove"] + headers = ["Live", "Variable", "Type", "Value", "Scaling", "Offset", "Scaled Value", "Unit", "Remove"] for col, header in enumerate(headers): label = QLabel(header) label.setAlignment(Qt.AlignCenter) @@ -96,11 +130,12 @@ def _setup_ui(self): # Set column stretch self._grid_layout.setColumnStretch(1, 5) # Variable - self._grid_layout.setColumnStretch(2, 2) # Value - self._grid_layout.setColumnStretch(3, 1) # Scaling - self._grid_layout.setColumnStretch(4, 1) # Offset - self._grid_layout.setColumnStretch(5, 1) # Scaled Value - self._grid_layout.setColumnStretch(6, 1) # Unit + self._grid_layout.setColumnStretch(2, 1) # Type + self._grid_layout.setColumnStretch(3, 2) # Value + self._grid_layout.setColumnStretch(4, 1) # Scaling + self._grid_layout.setColumnStretch(5, 1) # Offset + self._grid_layout.setColumnStretch(6, 1) # Scaled Value + self._grid_layout.setColumnStretch(7, 1) # Unit # Add Variable button self._add_button = QPushButton("Add Variable") @@ -110,7 +145,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.""" @@ -131,66 +166,71 @@ 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() - 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") var_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - var_edit.setEnabled(self._app_state.is_connected()) # Enable based on connection state + var_edit.setAlignment(Qt.AlignLeft) + var_edit.setEnabled(self._app_state.is_connected()) var_edit.installEventFilter(self) + type_label = QLabel() + type_label.setAlignment(Qt.AlignCenter) + value_edit = QLineEdit() value_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - value_edit.editingFinished.connect(lambda idx=index: self._on_value_changed(idx)) + value_edit.setAlignment(Qt.AlignLeft) + 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() scaled_value_edit.setReadOnly(True) + 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)) - # Add to grid - self._grid_layout.addWidget(live_cb, row, 0) - self._grid_layout.addWidget(var_edit, row, 1) - self._grid_layout.addWidget(value_edit, row, 2) - self._grid_layout.addWidget(scaling_edit, row, 3) - self._grid_layout.addWidget(offset_edit, row, 4) - self._grid_layout.addWidget(scaled_value_edit, row, 5) - self._grid_layout.addWidget(unit_edit, row, 6) - self._grid_layout.addWidget(remove_btn, row, 7) - - # Track widgets - widgets = (live_cb, var_edit, 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) + self._type_labels.append(type_label) self._value_edits.append(value_edit) self._scaling_edits.append(scaling_edit) self._offset_edits.append(offset_edit) 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): @@ -210,6 +250,7 @@ def _remove_variable_row(self, index: int): self._row_widgets.pop(index) self._live_checkboxes.pop(index) self._variable_edits.pop(index) + self._type_labels.pop(index) self._value_edits.pop(index) self._scaling_edits.pop(index) self._offset_edits.pop(index) @@ -233,7 +274,7 @@ def _rearrange_grid(self): widget.setParent(None) # Re-add headers - headers = ["Live", "Variable", "Value", "Scaling", "Offset", "Scaled Value", "Unit", "Remove"] + headers = ["Live", "Variable", "Type", "Value", "Scaling", "Offset", "Scaled Value", "Unit", "Remove"] for col, header in enumerate(headers): self._grid_layout.addWidget(QLabel(header), 0, col) @@ -244,7 +285,7 @@ def _rearrange_grid(self): # Update remove button connections for i, widgets in enumerate(self._row_widgets): - remove_btn = widgets[7] + remove_btn = widgets[8] remove_btn.clicked.disconnect() remove_btn.clicked.connect(lambda checked, idx=i: self._remove_variable_row(idx)) @@ -262,6 +303,11 @@ def _on_variable_click(self, index: int): self._app_state.update_live_watch_var_field(index, "sfr", sfr) self._app_state.update_live_watch_var_field(index, "name", dialog.selected_variable) + # Read type from app state (set when name was updated) + live_var = self._app_state.get_live_watch_var(index) + primitive = live_var.type if live_var else "" + self._type_labels[index].setText(primitive) + # Read initial value value = self._app_state.read_variable(dialog.selected_variable, sfr=sfr) if value is not None: @@ -269,29 +315,58 @@ 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_value_changed(self, index: int): - """Handle value edit finished - write to device.""" - if index >= len(self._variable_edits): + 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_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: return + 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(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): @@ -303,9 +378,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): @@ -318,6 +395,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): @@ -329,10 +407,12 @@ def get_config(self) -> dict: """Get the current tab configuration.""" return { "variables": [le.text() for le in self._variable_edits], + "types": [lbl.text() for lbl in self._type_labels], "values": [ve.text() for ve in self._value_edits], "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))], } @@ -344,10 +424,11 @@ def load_config(self, config: dict): # Add rows for each variable variables = config.get("variables", []) + types = config.get("types", []) values = config.get("values", []) scalings = config.get("scaling", []) offsets = config.get("offsets", []) - scaled_values = config.get("scaled_values", []) + units = config.get("units", []) lives = config.get("live", []) sfrs = config.get("sfr", []) @@ -355,22 +436,36 @@ 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) - # Update app state with variable name self._app_state.update_live_watch_var_field(i, "name", var) - if i < len(values) and i < len(self._value_edits): - self._value_edits[i].setText(values[i]) + if i < len(types) and i < len(self._type_labels): + self._type_labels[i].setText(types[i]) + 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]) - if i < len(scaled_values) and i < len(self._scaled_value_edits): - self._scaled_value_edits[i].setText(scaled_values[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]) - # Update app state with live state self._app_state.update_live_watch_var_field(i, "live", lives[i]) + # Read current value from device; fall back to config value if not connected + sfr = sfrs[i] if i < len(sfrs) else False + 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 def row_count(self) -> int: 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}") 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 diff --git a/pyx2cscope/gui/resources/script_template.py b/pyx2cscope/gui/resources/script_template.py new file mode 100644 index 00000000..0ce673d1 --- /dev/null +++ b/pyx2cscope/gui/resources/script_template.py @@ -0,0 +1,78 @@ +"""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 + +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) +# ------------------------------------------------------------------ +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)) + +# ------------------------------------------------------------------ +# 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 + +# ------------------------------------------------------------------ +# Example: read a single variable in a loop +# ------------------------------------------------------------------ +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.5) + +# ------------------------------------------------------------------ +# 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: + 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 6cfcb2f5..0465ebee 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) @@ -229,7 +232,11 @@ def disconnect(): call {server_url}/disconnect to execute. """ + from pyx2cscope.gui.web.extensions import socketio as _socketio + web_scope.disconnect() + _socketio.emit("watch_table_update", {}, namespace="/watch-view") + _socketio.emit("scope_table_update", {}, namespace="/scope-view") return jsonify({"status": "success"}) @@ -301,6 +308,154 @@ 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, + "sample_control": web_scope.sample_control, + "trigger_control": web_scope.trigger_control, + } + return Response( + json.dumps(config, indent=4), + mimetype="application/json", + headers={"Content-disposition": "attachment; filename=pyx2cscope_config.json"}, + ) + + +_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. + + 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. + """ + cfg_file = request.files.get("file") + msg = "Invalid config file." + 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": "warning", "msg": "Variables not available: " + ", ".join(errors)}) + return jsonify({"status": "success"}) + + def open_browser(host="localhost", web_port=5000): """Open a new browser pointing to the Flask server. diff --git a/pyx2cscope/gui/web/scope.py b/pyx2cscope/gui/web/scope.py index 237a79a7..601eb497 100644 --- a/pyx2cscope/gui/web/scope.py +++ b/pyx2cscope/gui/web/scope.py @@ -27,6 +27,20 @@ def __init__(self): self.scope_time_sampling = 50e-3 self.variables_file = "" + # Sample control state (mirrors last submitted form values) + self.sample_control = { + "triggerAction": "off", + "sampleTime": 1, + "sampleFreq": 20.0, + } + # Trigger control state (mirrors last submitted form values) + self.trigger_control = { + "trigger_mode": 0, + "trigger_edge": 1, + "trigger_level": 0.0, + "trigger_delay": 0, + } + self.dashboard_vars = {} # {var_name: Variable object} self.dashboard_rate = 1.0 # Fixed at 1 second for dashboard polling self.dashboard_next = time.time() @@ -333,6 +347,7 @@ def scope_set_trigger(self, **kwargs): Args: **kwargs: Trigger configuration parameters. """ + self.trigger_control.update(kwargs) if kwargs["trigger_mode"] == 1: for var in self.scope_vars: if var["trigger"]: @@ -349,6 +364,11 @@ def scope_set_sample(self, trigger_action, sample_time, sample_freq): sample_time (int): Sample time value. sample_freq (float): Sample frequency. """ + self.sample_control = { + "triggerAction": trigger_action, + "sampleTime": sample_time, + "sampleFreq": sample_freq, + } if self.x2c_scope is None: return sample_time = 1 if sample_time < 1 else sample_time @@ -545,10 +565,13 @@ def list_sfr(self): return self.x2c_scope.list_sfr() def disconnect(self): - """Disconnect from X2CScope.""" + """Disconnect from X2CScope and clear all variable lists.""" self.x2c_scope.disconnect() self.x2c_scope = None self.variables_file = "" + self.watch_vars.clear() + self.scope_vars.clear() + self.dashboard_vars.clear() def is_connected(self): """Check if connected to X2CScope. 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 607b1433..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,72 +376,49 @@ 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); - }); - - // 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 $(this).closest('.btn-group').find('.btn').removeClass('active'); - // Add active class to the clicked button's label $(`label[for="${this.id}"]`).addClass('active'); + const enabled = this.value === "1"; + $('#triggerOptions').toggle(enabled); + emitTriggerControl(); }); - // Set up Save button click handler - $("#scopeSave").on("click", function() { - window.location.href = '/scope/save'; + $('input[name="trigger_edge"]').on('change', function() { + $(this).closest('.btn-group').find('.btn').removeClass('active'); + $(`label[for="${this.id}"]`).addClass('active'); }); - $("#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(""); - }); + $('#applyTrigger').on('click', function() { + emitTriggerControl(); }); } diff --git a/pyx2cscope/gui/web/static/js/script.js b/pyx2cscope/gui/web/static/js/script.js index efc6e6a3..c952269a 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,56 @@ 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 + // 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'); + if (response.status === 'warning') { + alert(response.msg); + } + }, + 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 fb2ee5d3..1d1f71aa 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"); @@ -9,6 +8,10 @@ socket_wv.on("connect", () => { socket_wv.on("watch_data_update", (data) => { console.log("Update from server:", data); + // Skip all redraws if any editable cell in the table is being edited + if ($('#parameterTable td[contenteditable="true"]:focus').length > 0) { + return; + } parameterTable.rows().every(function() { const rowData = this.data(); const updatedVar = data.find(item => item.variable === rowData.variable); @@ -44,16 +47,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 () { @@ -80,31 +73,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/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 @@ - - 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/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/scripting.html b/pyx2cscope/gui/web/templates/scripting.html index 4e133ee9..7c2045f5 100644 --- a/pyx2cscope/gui/web/templates/scripting.html +++ b/pyx2cscope/gui/web/templates/scripting.html @@ -7,6 +7,7 @@ + New Script 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 @@
-
- -
-
- -
-
+
- \ No newline at end of file diff --git a/pyx2cscope/gui/web/templates/watch_view.html b/pyx2cscope/gui/web/templates/watch_view.html index 7f4d42b3..a5eca259 100644 --- a/pyx2cscope/gui/web/templates/watch_view.html +++ b/pyx2cscope/gui/web/templates/watch_view.html @@ -1,20 +1,13 @@
-
+
-
- -
-
- - -
-
+
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/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.""" 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 diff --git a/pyx2cscope/gui/web/ws_handlers.py b/pyx2cscope/gui/web/ws_handlers.py index ad44a6ee..1f51c52b 100644 --- a/pyx2cscope/gui/web/ws_handlers.py +++ b/pyx2cscope/gui/web/ws_handlers.py @@ -14,6 +14,9 @@ def background_x2cscope_task(): """Background x2cScope thread.""" while True: + if not web_scope.is_connected(): + socketio.sleep(1) + continue watch_values = web_scope.watch_poll() scope_values, dashboard_scope_data = web_scope.scope_poll() dashboard_values = web_scope.dashboard_poll() 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 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={}, diff --git a/tests/test_spec_imports.py b/tests/test_spec_imports.py index 024b582f..6397385c 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 @@ -19,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 []