diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 00000000..2d00e606
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(python -c \"from pyx2cscope.gui.generic_gui.tabs.watch_view_tab import WatchViewTab; from pyx2cscope.gui.generic_gui.tabs.scope_view_tab import ScopeViewTab; from pyx2cscope.gui.generic_gui.tabs.watch_plot_tab import WatchPlotTab; print\\(''All imports successful''\\)\")",
+ "Bash(ruff check .)"
+ ]
+ }
+}
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index 1d8b95bd..1f4906fb 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -53,16 +53,21 @@ jobs:
with:
python-version: "3.10"
-# Since we moved to generic parser, we don't need to install the xc-16 compiler anymore,
-# because generic parser is based on pyelftools.
-#
-# - name: Set up Microchip XC16 v2.10 compiler
-# run: |
-# wget -nv -O /tmp/xc16 https://ww1.microchip.com/downloads/aemDocuments/documents/DEV/ProductDocuments/SoftwareTools/xc16-v2.10-full-install-linux64-installer.run && \
-# chmod +x /tmp/xc16 && \
-# sudo /tmp/xc16 --mode unattended --unattendedmodeui none --netservername localhost --LicenseType FreeMode --prefix /opt/microchip/xc16/v2.10 && \
-# rm /tmp/xc16
-# echo "/opt/microchip/xc16/v2.10/bin" >> $GITHUB_PATH
+ - name: Install system dependencies for Qt
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libxkbcommon-x11-0 \
+ libxcb-icccm4 \
+ libxcb-image0 \
+ libxcb-keysyms1 \
+ libxcb-randr0 \
+ libxcb-render-util0 \
+ libxcb-xinerama0 \
+ libxcb-xfixes0 \
+ libegl1 \
+ libgl1-mesa-glx \
+ xvfb
- name: Install dependencies
run: |
@@ -71,8 +76,13 @@ jobs:
pip install -e .
- name: Run tests
+ env:
+ QT_QPA_PLATFORM: offscreen
+ DISPLAY: ":99.0"
run: |
- pytest
+ Xvfb :99 -screen 0 1024x768x24 &
+ sleep 2
+ pytest -v
docs:
runs-on: ubuntu-latest
diff --git a/doc/development.md b/doc/development.md
index fe8c8446..ac5f110f 100644
--- a/doc/development.md
+++ b/doc/development.md
@@ -61,14 +61,12 @@ sphinx-build -M html doc build --keep-going
pyinstaller --noconfirm .\pyx2cscope_win.spec
```
-## Creating artifacts to upload to github release page
+## Creating artifacts to upload to Github release page
This script will execute the pyinstaller command listed above,
include the script file to start the web interface, zip
the contents of the dist folder and add the whell file
available on pypi in the dist folder.
```bash
-python -m scripts/build.py
+python scripts/build.py
```
-
-## Creating artifacts to upload to GitHu
diff --git a/doc/gui_qt.md b/doc/gui_qt.md
index 60829295..b7a76d61 100644
--- a/doc/gui_qt.md
+++ b/doc/gui_qt.md
@@ -9,43 +9,169 @@ application.
The GUI Qt is currently the default GUI, it runs out-of-the-box when running the command below:
```
-python -m pyx2cscope
-```
+python -m pyx2cscope
+```
-It can also be executed over argument -q
+It can also be executed with the argument -q:
```
python -m pyx2cscope -q
-```
-
-## Getting Started with pyX2Cscope reference GUI
-## Tab: WatchPlot
-
-1. pyX2Cscope-GUI is based on Serial interface.
-2. The Firmware of the microcontroller should have the X2Cscope library/Peripheral enabled.
-3. In Tab WatchPlot, five channels values can be viewed, modified and can be plotted in the plot window.
-4. In COM Port, either select **Auto Connect** or select the appropriate COM Port, Baud Rate from the drop-down menus and the ELF file of the project, the microcontroller programmed with.
-5. Sample time can be changed during run time as well, by default its set to 500 ms.
-6. Press on **Connect**
-7. Once the connection between pyX2Cscope and Microcontroller takes place, the buttons will be enabled.
-8. Information related to the microcontroller will be displayed in the top-left corner.
-
-## Tab: ScopeView
-
-
-1. ScopeView supports up to 8 PWM resolution channels for precise signal control.
-2. You can configure all trigger settings directly within the window. To enable the trigger for a variable, check the corresponding trigger checkbox.
-3. To apply modifications during sampling, first stop the sampling, make the necessary changes, then click Sample again to update and apply the modifications.
-4. From the plot window, User can export the plot in various formats, including CSV, image files, Matplotlib Window, and Scalable Vector Graphics (SVG).
-5. To zoom in on the plot, left-click and drag on the desired area. To return to the original view, right-click and select View All.
-
-## Tab: WatchView
-
-
-1. WatchView lets users add or remove variables as needed. To remove a variable, click the Remove button next to it.
-2. Users can visualize variables in live mode with an update rate of 500 milliseconds. This rate is the default setting and cannot be changed.
-3. Users can select, view, and modify all global variables during runtime, providing real-time control and adjustments.
-
-## Save and Load Config.
-1. The Save and Load buttons, found at the bottom of the GUI, allow users to save or load the entire configuration, including the COM Port, Baud Rate, ELF file path, and all other selected variables across different tabs. This ensures a consistent setup, regardless of which tab is active.
-2. When a pre-saved configuration file is loaded, the system will automatically attempt to load the ELF file and establish a connection. If the ELF file is missing or unavailable at the specified path, user will need to manually select the correct ELF file path.
+```
+
+## Getting Started with pyX2Cscope Reference GUI
+
+The GUI consists of three main tabs: **Setup**, **Data Views**, and **Scripting**.
+
+---
+
+## Tab: Setup
+
+The Setup tab is where you configure the connection to your microcontroller.
+
+### Connection Settings
+
+1. **ELF File**: Click "Select ELF file" to choose the ELF file of the project your microcontroller is programmed with.
+
+2. **Interface**: Select the communication interface:
+ - **UART**: Serial communication
+ - **TCP/IP**: Network communication
+ - **CAN**: CAN bus communication
+
+3. **Connect**: Press to establish the connection. The button changes to "Disconnect" when connected.
+
+### UART Settings
+
+- **Port**: Select the COM port from the dropdown. Use the refresh button to update available ports.
+- **Baud Rate**: Select the baud rate (38400, 115200, 230400, 460800, 921600).
+
+### TCP/IP Settings
+
+- **Host**: Enter the IP address or hostname of the target device.
+- **Port**: Enter the TCP port number (default: 12666).
+
+### CAN Settings
+
+- **Bus Type**: Select USB or LAN.
+- **Channel**: Enter the CAN channel number.
+- **Baudrate**: Select from 125K, 250K, 500K, or 1M.
+- **Mode**: Select Standard or Extended.
+- **Tx-ID (hex)**: Transmit ID in hexadecimal (default: 7F1).
+- **Rx-ID (hex)**: Receive ID in hexadecimal (default: 7F0).
+
+### Device Information
+
+Once connected, device information is displayed on the right side:
+- Processor ID
+- UC Width
+- Date and Time
+- App Version
+- DSP State
+
+> **Note**: All connection settings are automatically saved and restored on the next application start.
+
+---
+
+## Tab: Data Views
+
+The Data Views tab provides two views that can be toggled independently using the buttons at the top:
+
+- **WatchView**: Monitor and modify variable values in real-time
+- **ScopeView**: Capture and visualize variable waveforms
+
+You can enable both views simultaneously for a split-screen layout. You can change the width of each column by dragging the line between them. For this to take effect, adjust the App window size accordingly.
+
+### WatchView
+
+1. Click "Add Variable" to add variables to monitor.
+2. Select variables from the dialog window.
+3. Configure scaling and offset for each variable.
+4. Enable "Live" checkbox to poll values at 500ms intervals.
+5. Enter new values and click "Write" to modify variables on the device.
+6. Click "Remove" to delete a variable row.
+
+### ScopeView
+
+1. ScopeView supports up to 8 channels for precise signal capture.
+2. Select variables for each channel from the dropdown.
+3. Configure trigger settings:
+ - **Mode**: Auto (continuous) or Triggered
+ - **Edge**: Rising or Falling
+ - **Level**: Trigger threshold value
+ - **Delay**: Trigger delay in samples
+4. Check the "Trigger" checkbox on the channel you want to use as trigger source.
+5. Click "Sample" to start capturing, "Single" for one-shot capture, or "Stop" to halt.
+6. Use the plot toolbar to zoom, pan, and export data (CSV, PNG, SVG, or Matplotlib window).
+
+### Special Function Registers (SFR)
+
+Both **WatchView** and **ScopeView** support searching and adding Special Function Registers
+(SFRs) — hardware peripheral registers such as `LATD`, `TMR1`, or `PORTA` — in addition to
+ordinary firmware variables.
+
+When the variable selection dialog opens, an **SFR** checkbox appears next to the search bar:
+
+- When the checkbox is **unchecked** (default) the list shows firmware variables.
+- When the checkbox is **checked** the list switches to SFRs parsed from the ELF file.
+
+The checkbox is disabled (greyed out) if the connected ELF file contains no SFR entries.
+
+Once an SFR is selected and confirmed, it is retrieved with `sfr=True` internally so it is
+mapped to its fixed hardware address. From that point it behaves exactly like any other
+variable — values can be read, polled live (WatchView), or captured as a scope channel
+(ScopeView).
+
+### 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.
+- Load a previously saved configuration to quickly restore your setup.
+- When loading, the system automatically attempts to connect using the saved settings.
+
+---
+
+## Tab: Scripting
+
+The Scripting tab allows you to run Python scripts with direct access to the x2cscope connection.
+
+### Script Selection
+
+1. Click **Browse** to select a Python script (.py file).
+2. Click **Edit (IDLE)** to open the script in Python's IDLE editor.
+3. Click **Help** for documentation on writing scripts.
+
+### Execution Controls
+
+1. Click **Execute** to run the selected script.
+2. Click **Stop** to request the script to stop (scripts must check `stop_requested()` in loops).
+3. Enable **Log output to file** and select a location to save script output.
+
+### Output Tabs
+
+- **Script Output**: Displays the actual output from your script (print statements, errors).
+- **Log**: Displays timestamped system messages (script started, stopped, connection status).
+
+### Available Objects in Scripts
+
+When running from the Scripting tab, your script has access to:
+
+- **x2cscope**: The X2CScope instance (or `None` if not connected via Setup tab)
+- **stop_requested()**: Function that returns `True` when the Stop button is pressed
+
+### Example Script
+
+```python
+# Example: Read and print a variable value
+if globals().get("x2cscope") is not None:
+ var = x2cscope.get_variable("myVariable")
+ print(f"Value: {var.get_value()}")
+
+# Example: Loop with stop support
+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)
+print("Script stopped.")
+```
+
+> **Note**: Scripts run in the same process as the GUI. If connected via the Setup tab, scripts share the same x2cscope connection. Scripts can also create their own connections when running standalone.
diff --git a/doc/gui_web.md b/doc/gui_web.md
index 0043dff9..f57da689 100644
--- a/doc/gui_web.md
+++ b/doc/gui_web.md
@@ -1,24 +1,435 @@
# GUI Web
-The Web Graphic User Interface is implemented using Flask, bootstrap 4, jquery and chart.js
-It is also an example of how to build a custom GUI using pyX2Cscope.
-This interface allows you to use multiple windows or even access functions from smart devices.
-The server runs by default on your local machine and does not allow external access.
-The server has default port 5000 and will be accessible on http://localhost:5000
+The Web Graphic User Interface is implemented using Flask, Bootstrap 5, jQuery, Socket.IO, and Chart.js.
+It serves as both a fully functional interface and an example of how to build a custom GUI using pyX2Cscope.
+This interface allows you to use multiple browser windows or access the application from smart devices on the same network.
+
+The server runs by default on your local machine and does not allow external access.
+The default port is 5000 and the interface will be accessible at http://localhost:5000
## Starting the Web GUI
-The Web GUI starts with the following command below:
+Start the Web GUI with the following command:
-```
+```bash
python -m pyx2cscope -w
-```
+```
-To open the server for external access include the argument --host 0.0.0.0
+To open the server for external access (allowing connections from other devices on your network):
-```
+```bash
python -m pyx2cscope -w --host 0.0.0.0
-```
+```
+
+To use a custom port:
+
+```bash
+python -m pyx2cscope -w -wp 8080
+```
+
+### Command Line Options
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `-w` | Enable web GUI | - |
+| `-wp`, `--web-port` | Web server port | 5000 |
+| `--host` | Host address (use 0.0.0.0 for external access) | localhost |
+
+## Interface Overview
+
+The Web GUI consists of four main views accessible via the navigation bar:
+
+1. **Setup** - Connection configuration
+2. **Watch View** - Variable monitoring and modification
+3. **Scope View** - Oscilloscope-like signal visualization
+4. **Dashboard** - Custom widget-based monitoring
+4. **Scripting** - Allows to run Python scritps with direct access to the x2cscope connection
+
+---
+
+## Setup View
+
+The Setup view is the starting point for establishing a connection to your microcontroller.
+
+### Interface Selection
+
+Select the communication interface type:
+
+- **Serial** - For UART/USB-to-Serial connections
+- **TCP/IP** - For network-based connections
+- **CAN** - For CAN bus connections (coming soon)
+
+### Serial Configuration
+
+When Serial is selected:
+
+1. **UART Dropdown** - Select the COM port from the available ports list
+2. **Refresh Button** - Click to rescan for available COM ports
+
+### TCP/IP Configuration
+
+When TCP/IP is selected:
+
+1. **Host** - Enter the IP address or hostname of the target device (default: localhost)
+2. **Port** - Enter the TCP port number (default: 12666)
+
+### CAN Configuration (Coming Soon)
+
+When CAN is selected:
+
+- **Bus Type** - USB or LAN
+- **Channel** - CAN channel number
+- **Baudrate** - 125K, 250K, 500K, or 1M
+- **Mode** - Standard or Extended
+- **TX ID** - Transmit message ID (hex)
+- **RX ID** - Receive message ID (hex)
+
+### ELF File Selection
+
+Select an ELF file (or PKL/YML import file) containing the variable information from your firmware.
+Supported formats: `.elf`, `.pkl`, `.yml`
+
+### Connecting
+
+Click the **Connect** button to establish communication with the target device.
+Once connected, the other views (Watch View, Scope View, Dashboard) become functional.
+
+---
+
+## Watch View
+
+The Watch View allows you to monitor and modify variables in real-time.
+
+### Adding Variables
+
+1. Use the **search dropdown** to find and select a variable from the firmware
+2. The variable will be added to the watch table
+
+### Variable Table Columns
+
+| Column | Description |
+|--------|-------------|
+| **Live** | Checkbox to enable/disable live updates for this variable |
+| **Variable** | The variable name |
+| **Type** | Data type (int, float, etc.) |
+| **Value** | Current raw value from the target |
+| **Scaling** | Multiplication factor applied to the value |
+| **Offset** | Offset added after scaling |
+| **Scaled Value** | Calculated result: (Value x Scaling) + Offset |
+| **Actions** | Write value and remove buttons |
+
+### Live Updates
+
+- Click **Refresh** to manually update all variable values
+- Use the dropdown menu to set automatic refresh rates:
+ - Live @1s - Update every 1 second
+ - Live @3s - Update every 3 seconds
+ - Live @5s - Update every 5 seconds
+
+### Writing Values
+
+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
+
+The Scope View provides oscilloscope-like functionality for capturing and visualizing fast signals.
+
+### Scope Plot
+
+The main chart displays captured waveforms. Features include:
+
+- **Zoom** - Use mouse scroll or pinch gestures to zoom in/out
+- **Pan** - Click and drag to pan the view
+- **Reset Zoom** - Use the Chart Actions menu to reset the view
+- **Export Data** - Export captured data to a file
+
+### Sample Control
+
+The Sample Control panel manages data acquisition:
+
+| Control | Description |
+|---------|-------------|
+| **Sample** | Start continuous sampling |
+| **Stop** | Stop sampling |
+| **Burst** | Capture a single frame of data |
+| **Sample Time** | Prescaler value for sampling rate (1 = fastest) |
+| **Sampling Frequency** | Display of the calculated sampling frequency |
+
+#### How Sampling Works
+
+1. Click **Sample** to start continuous data acquisition
+2. The firmware collects data points until the buffer is full
+3. Data is transferred to the PC and displayed on the chart
+4. The cycle repeats automatically until **Stop** is pressed
+5. Use **Burst** mode to capture only one frame of data
+
+### Trigger Control
+
+The Trigger Control panel configures when data capture begins:
+
+| Setting | Description |
+|---------|-------------|
+| **Trigger Enable/Disable** | Enable triggers to start capture at a specific condition |
+| **Edge Detection** | Rising edge or Falling edge detection |
+| **Level** | The value threshold that triggers data capture |
+| **Delay** | Pre/post trigger delay (-50% to +50%) |
+
+#### Trigger Modes
+
+**Disabled (Auto Mode)**
+- Sampling starts immediately when requested
+- Useful for continuous signal monitoring
+
+**Enabled (Triggered Mode)**
+- Sampling waits until the trigger condition is met
+- The trigger variable crosses the specified level in the specified direction
+- Pre-trigger delay (negative): Capture data before the trigger event
+- Post-trigger delay (positive): Capture data after the trigger event
+
+#### Setting Up a Trigger
+
+1. Enable the trigger by clicking **Enable**
+2. Select **Rising** or **Falling** edge
+3. Enter the trigger **Level** value
+4. Set the **Delay** percentage:
+ - Negative values (-50 to 0): Pre-trigger - see what happened before the event
+ - Positive values (0 to +50): Post-trigger - see what happens after the event
+5. Click **Update Trigger** to apply settings
+6. In the Source Configuration, select which variable acts as the trigger source
+
+### Source Configuration (Variable Channels)
+
+Configure up to 8 scope channels:
+
+| Column | Description |
+|--------|-------------|
+| **Trigger** | Radio button to select this channel as the trigger source |
+| **Enable** | Checkbox to include this channel in the capture |
+| **Variable** | The variable name being monitored |
+| **Color** | Click to change the waveform color on the chart |
+| **Gain** | Visual scaling factor for display (does not affect raw data) |
+| **Offset** | Visual offset for display (does not affect raw data) |
+| **Remove** | Delete this channel from the scope |
+
+#### Adding Channels
+
+1. Use the search dropdown to find a variable
+2. The variable is automatically added as a new channel
+3. Enable the channel checkbox to include it in captures
+
+#### Tips for Best Results
+
+- Start with **Sample Time = 1** for maximum resolution
+- Increase Sample Time to capture longer time periods
+- Use **Gain** to scale signals for better visualization
+- Use **Offset** to separate overlapping waveforms
+- Set up triggers to capture specific events consistently
+
+---
+
+## Dashboard View
+
+The Dashboard provides a customizable interface with drag-and-drop widgets for monitoring and controlling variables.
+
+### Edit Mode
+
+Click the **Edit** button in the toolbar to enter edit mode:
+
+- A widget palette appears on the left side
+- Existing widgets can be moved and resized
+- New widgets can be added from the palette
+
+### Available Widgets
+
+| Widget Type | Description |
+|-------------|-------------|
+| **Button** | Write values to variables on press/release |
+| **Gauge** | Circular gauge displaying a variable value |
+| **Label** | Text placeholder, no write/read of variables |
+| **Number** | Numeric display of a variable |
+| **Plot Logger** | Plots data continuously as a logger |
+| **Plot Scope** | Plots scope data, use together with Scope Control widget |
+| **Scope Control** | Variable and Trigger configuration for scope functionality |
+| **Slider** | Slider control to write values to a variable |
+| **Switch** | On/Off toggle switch to write values to a variable |
+| **Text** | Display text values of a variable |
+
+### Adding Widgets
+
+1. Enter **Edit Mode**
+2. Click a widget type in the palette
+3. Configure the widget:
+ - Select the target variable
+ - Set widget-specific options (min/max values, labels, etc.)
+4. Click **Add Widget**
+5. Position and resize the widget on the canvas
+
+### Widget Configuration
+
+Each widget can be configured with:
+
+- **Variable Name** - The firmware variable to monitor/control
+- **Update Rate** - How frequently the variable value is read (see below)
+- **Widget-specific settings** - Depending on widget type (ranges, colors, labels)
+
+### Update Rate
+
+The update rate controls how frequently a widget reads its variable value from the target device.
+
+| Setting | Description |
+|---------|-------------|
+| **Off (0)** | No automatic updates - value is read only on manual refresh |
+| **Live** | Update as fast as possible (continuous polling) |
+| **Interval (seconds)** | Update at specified interval (0.5s, 1s, 2s, 5s, etc.) |
+
+**Widgets that support Update Rate:**
+
+| Widget | Update Rate | Reason |
+|--------|:-----------:|--------|
+| Button | Yes | May reflect current variable state |
+| Gauge | Yes | Displays live variable value |
+| Number | Yes | Displays live variable value |
+| Plot Logger | Yes | Continuously logs data points |
+| Slider | Yes | May sync with current value |
+| Switch | Yes | May reflect current state |
+| Text | Yes | Displays live variable value |
+
+**Widgets that do NOT use Update Rate:**
+
+| Widget | Reason |
+|--------|--------|
+| Label | Static text, no variable binding |
+| Plot Scope | Uses scope sampling mechanism (controlled by Scope Control) |
+| Scope Control | Configuration widget, triggers scope sampling |
+
+### Save/Load Layout
+
+- **Save Layout** - Save the current dashboard configuration to a JSON file
+- **Load Layout** - Load a previously saved dashboard layout
+- Layouts include widget positions, sizes, and configurations
+
+### Dashboard Toolbar
+
+| Button | Description |
+|--------|-------------|
+| **Edit** | Toggle edit mode on/off |
+| **Save** | Save current layout |
+| **Load** | Load a saved layout |
+| **Export** | Export the current Dashboard to a file |
+| **Import** | Import a Dashboard from a file |
+| **Clear** | Remove all widgets from the dashboard |
+
+---
+
+## Scripting View
+
+The Scripting view allows you to run Python scripts that interact with the connected device.
+
+### Features
+
+- Load and execute Python scripts
+- Scripts have access to the `x2cscope` object for device communication
+- Real-time output display
+- Stop button to interrupt running scripts
+
+### Script API
+
+Scripts can use the `x2cscope` global variable, you don't need to instantiate it.
+
+```python
+# Read a variable
+value = x2cscope.get_variable("motor_speed").get_value()
+print(f"Motor speed: {value}")
+
+# Write a variable
+x2cscope.get_variable("target_speed").set_value(1000)
+
+# Check if stop was requested
+if stop_requested():
+ print("Script stopped by user")
+```
+
+---
+
+## Special Function Registers (SFR)
+
+The Web GUI exposes SFR access through an **SFR** toggle switch placed inline next to every
+variable search dropdown.
+
+### Watch View and Scope View
+
+Each view has an **SFR** toggle (Bootstrap form-switch) beside the Select2 search bar:
+
+- When the toggle is **off** (default) the dropdown searches firmware variables.
+- When the toggle is **on** the dropdown searches Special Function Registers instead.
+
+Switching the toggle clears the current selection and reinitialises the dropdown so the next
+search already queries the correct namespace. When an SFR is selected and added to the watch
+or scope table it is retrieved with `sfr=True` on the backend, mapping it to its fixed
+hardware address.
+
+### Dashboard
+
+The widget configuration modal includes the same **SFR** toggle above the variable name
+selector. The toggle resets to **off** every time the modal is opened and behaves identically
+to the Watch/Scope toggles described above.
+
+---
+
+## Tips and Best Practices
+
+### Performance
+
+- The web interface uses WebSocket for real-time updates
+- For best performance, limit the number of live-updating variables
+- Use Burst mode in Scope View for one-time captures
+
+### Network Access
+
+- By default, the server only accepts local connections
+- Use `--host 0.0.0.0` to allow network access
+- Consider network security when exposing the interface
+
+### Browser Compatibility
+
+The Web GUI is tested with modern browsers:
+- Chrome (recommended)
+- Firefox
+- Edge
+- Safari
+
+### Troubleshooting
+
+| Issue | Solution |
+|-------|----------|
+| Cannot connect | Check COM port selection, ensure device is powered |
+| No variables shown | Verify ELF file matches the running firmware |
+| Scope not updating | Check that channels are enabled and sampling is started |
+| Scope not updating | Check the trigger level is in range of Variable's values |
+| Dashboard not saving | Ensure browser allows local storage |
+
+---
+
+## API Endpoints
+
+For advanced users, the Web GUI exposes REST API endpoints:
-Additional information you may find on the API documentation.
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/connect` | POST | Establish connection |
+| `/disconnect` | POST | Close connection |
+| `/is-connected` | GET | Check connection status |
+| `/variables` | GET | Get list of available variables |
+| `/serial-ports` | GET | Get available COM ports |
+Additional information can be found in the API documentation.
diff --git a/doc/index.rst b/doc/index.rst
index 9770d39e..4f5229fb 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -8,7 +8,7 @@ This comprehensive package offers developers a powerful toolkit for embedded sof
combining real-time debugging capabilities with advanced data visualization features directly within
the Python environment.
pyx2cscope makes use of lnet protocol to communicate with the embedded hardware via different communication interfaces
-like UART, CAN, LIN, USB, TCP/IP, etc.
+like UART and TCP/IP. CAN support is coming soon.
pyX2Cscope
==========
diff --git a/doc/scripting.rst b/doc/scripting.rst
index 1d068b37..adbbd203 100644
--- a/doc/scripting.rst
+++ b/doc/scripting.rst
@@ -23,20 +23,112 @@ X2CScope class
from pyx2scope import X2CScope
-X2CScope class needs one parameter to be instantiated:
+X2CScope supports multiple communication interfaces: **Serial** and **TCP/IP**. **CAN** support is coming soon.
+
+Communication Interfaces
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+**Serial (UART) Interface**
+
+The most common interface for connecting to microcontrollers. Parameters:
+
+.. list-table::
+ :widths: 20 15 15 50
+ :header-rows: 1
+
+ * - Parameter
+ - Type
+ - Default
+ - Description
+ * - ``port``
+ - str
+ - "COM1"
+ - Serial port name (e.g., "COM3", "/dev/ttyUSB0")
+ * - ``baud_rate``
+ - int
+ - 115200
+ - Communication speed in bits per second
+ * - ``parity``
+ - int
+ - 0
+ - Parity setting (0=None)
+ * - ``stop_bit``
+ - int
+ - 1
+ - Number of stop bits
+ * - ``data_bits``
+ - int
+ - 8
+ - Number of data bits
+
+Example - Serial connection with default baud rate:
-- **port**: The desired communication port name, i.e.:"COM3", "dev/ttyUSB", etc.
+.. code-block:: python
+
+ x2c_scope = X2CScope(port="COM16", elf_file="firmware.elf")
+
+Example - Serial connection with custom baud rate:
+
+.. code-block:: python
+
+ x2c_scope = X2CScope(port="COM16", baud_rate=9600, elf_file="firmware.elf")
+
+**TCP/IP Interface**
+
+For network-based connections to embedded systems with Ethernet capability. Parameters:
+
+.. list-table::
+ :widths: 20 15 15 50
+ :header-rows: 1
+
+ * - Parameter
+ - Type
+ - Default
+ - Description
+ * - ``host``
+ - str
+ - "localhost"
+ - IP address or hostname of the target device
+ * - ``tcp_port``
+ - int
+ - 12666
+ - TCP port number for the connection
+ * - ``timeout``
+ - float
+ - 0.1
+ - Connection timeout in seconds
+
+Example - TCP/IP connection with default tcp_port:
-X2CScope will support multiple communication interfaces. Currently, only **Serial** is supported: CAN, LIN,
-and TCP/IP are coming in near future. For serial, the only parameter needed is the desired port name, by
-default baud rate is set to **115200**. If there's a need to change the baud rate, include the baud_rate
-parameter with your preferred baud rate.
+.. code-block:: python
+
+ x2c_scope = X2CScope(host="192.168.1.100", elf_file="firmware.elf")
-2. Instantiate X2CScope with the serial port number:
+Example - TCP/IP with custom tcp_port:
.. code-block:: python
- x2c_scope = X2CScope(port="COM16")
+ x2c_scope = X2CScope(host="192.168.1.100", tcp_port=12345, elf_file="firmware.elf")
+
+**CAN Interface (Coming Soon)**
+
+CAN bus support is under development. The interface will support parameters such as:
+
+- ``bus``: CAN bus type (e.g., "USB", "TCP")
+- ``channel``: CAN channel identifier
+- ``bitrate``: CAN bus bitrate
+- ``tx_id``: Transmit message ID
+- ``rx_id``: Receive message ID
+
+2. Basic instantiation examples:
+
+.. code-block:: python
+
+ # Serial connection (most common)
+ x2c_scope = X2CScope(port="COM16", elf_file="firmware.elf")
+
+ # TCP/IP connection
+ x2c_scope = X2CScope(host="192.168.1.100", elf_file="firmware.elf")
Load variables
----------------
@@ -54,7 +146,7 @@ the code below:
.. code-block:: python
- x2c_scope.import_variables(r"..\..\tests\data\qspin_foc_same54.elf")
+ x2c_scope.import_variables(r"..\..\tests\data\dsPIC33ak128mc106_foc.elf")
Variable class
--------------
@@ -94,6 +186,73 @@ Writing values
variable.set_value(value)
+Special Function Registers (SFR)
+---------------------------------
+
+In addition to firmware variables, pyX2Cscope can access **Special Function Registers (SFRs)** —
+hardware peripheral registers with fixed addresses defined in the MCU's ELF file (e.g. ``LATD``,
+``TMR1``, ``PORTA``). SFR access uses the same ``Variable`` interface as ordinary variables, so
+``get_value()`` and ``set_value()`` work identically.
+
+Listing available SFRs
+^^^^^^^^^^^^^^^^^^^^^^^
+
+Use ``list_sfr()`` to retrieve a sorted list of all SFR names parsed from the ELF file:
+
+.. code-block:: python
+
+ sfr_names = x2c_scope.list_sfr()
+ print(sfr_names)
+ # ['ADCON1', 'ADCON2', ..., 'LATD', ..., 'TMR1', ...]
+
+This is the SFR counterpart of ``list_variables()``, which lists firmware variables only.
+
+.. list-table::
+ :widths: 20 80
+ :header-rows: 1
+
+ * - Method
+ - Description
+ * - ``list_variables()``
+ - Returns all firmware (DWARF) variable names from the ELF symbol table.
+ * - ``list_sfr()``
+ - Returns all peripheral register (SFR) names from the ELF register map.
+
+Retrieving an SFR variable
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Pass ``sfr=True`` to ``get_variable()`` to look up the name in the SFR register map instead of
+the firmware variable table:
+
+.. code-block:: python
+
+ latd = x2c_scope.get_variable("LATD", sfr=True)
+ tmr1 = x2c_scope.get_variable("TMR1", sfr=True)
+
+The returned object is a standard ``Variable`` instance — read and write it the same way:
+
+.. code-block:: python
+
+ # Read the current register value
+ value = latd.get_value()
+ print(f"LATD = 0x{value:04X}")
+
+ # Write a new value to the register
+ latd.set_value(value | (1 << 12)) # set bit 12 (LATE12)
+
+.. note::
+
+ ``get_variable("NAME")`` and ``get_variable("NAME", sfr=False)`` both search the firmware
+ variable map. ``get_variable("NAME", sfr=True)`` searches the SFR register map. The two
+ namespaces are independent — a name can exist in both without conflict.
+
+Full SFR example
+^^^^^^^^^^^^^^^^
+
+.. literalinclude:: ../pyx2cscope/examples/SFR_Example.py
+ :language: python
+ :linenos:
+
.. _import-and-export-variables:
Import and Export variables
@@ -222,16 +381,112 @@ To set any trigger configuration, you need to pass a TriggerConfig imported from
.. code-block:: python
- trigger_config = TriggerConfig(Variable, trigger_level: int, trigger_mode: int, trigger_delay: int, trigger_edge: int)
+ trigger_config = TriggerConfig(Variable, trigger_level: float, trigger_mode: int, trigger_delay: int, trigger_edge: int)
x2cscope.set_scope_trigger(trigger_config)
TriggerConfig needs some parameters like the variable and some trigger values like:
* Variable: the variable which will be monitored
-* Trigger_Level: at which level the trigger will start executing
+* Trigger_Level: at which level the trigger will start executing (float)
* Trigger_mode: 1 for triggered, 0 for Auto (No trigger)
* Trigger_delay: Value > 0 Pre-trigger, Value < 0 Post trigger
* Trigger_Edge: Rising (1) or Falling (0)
Additional information on how to change triggers, clear and change sample time, may be
found on the API documentation.
+
+Utility Functions
+-----------------
+
+The ``pyx2cscope.utils`` module provides helper functions for managing configuration settings
+used in examples and scripts. These utilities simplify the process of specifying ELF file paths
+and COM ports without hardcoding them into your scripts.
+
+Configuration File (config.ini)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+When any utility function is called for the first time, a ``config.ini`` file is automatically
+generated in the current working directory if it doesn't already exist. This file contains
+default placeholder values that you should update with your actual settings:
+
+.. code-block:: ini
+
+ [ELF_FILE]
+ path = path_to_your_elf_file
+
+ [COM_PORT]
+ com_port = your_com_port, ex:COM3
+
+ [HOST_IP]
+ host_ip = your_host_ip
+
+After the file is created, edit it to specify your actual ELF file path and COM port.
+
+Available Functions
+^^^^^^^^^^^^^^^^^^^
+
+**get_elf_file_path()**
+
+Retrieves the ELF file path from the configuration:
+
+.. code-block:: python
+
+ from pyx2cscope.utils import get_elf_file_path
+
+ elf_path = get_elf_file_path()
+ if elf_path:
+ x2cscope = X2CScope(port="COM8", elf_file=elf_path)
+
+**get_com_port()**
+
+Retrieves the COM port from the configuration:
+
+.. code-block:: python
+
+ from pyx2cscope.utils import get_com_port
+
+ port = get_com_port()
+ if port:
+ x2cscope = X2CScope(port=port, elf_file="firmware.elf")
+
+**get_host_address()**
+
+Retrieves the host IP address for TCP/IP connections:
+
+.. code-block:: python
+
+ from pyx2cscope.utils import get_host_address
+
+ host = get_host_address()
+ if host:
+ x2cscope = X2CScope(host=host, tcp_port=12666, elf_file="firmware.elf")
+
+
+
+Example Usage
+^^^^^^^^^^^^^
+
+The utility functions are particularly useful in example scripts where you want to avoid
+hardcoding paths:
+
+.. code-block:: python
+
+ from pyx2cscope import X2CScope
+ from pyx2cscope.utils import get_elf_file_path, get_com_port
+
+ # Get configuration from config.ini
+ elf_path = get_elf_file_path()
+ port = get_com_port()
+
+ if not elf_path or not port:
+ print("Please configure config.ini with your ELF file path and COM port")
+ exit(1)
+
+ # Initialize X2CScope with configured values
+ x2cscope = X2CScope(port=port, elf_file=elf_path)
+
+.. note::
+
+ The utility functions return an empty string if the configuration contains placeholder
+ values (containing "your"). This allows you to check if the configuration has been
+ properly set up before proceeding.
diff --git a/mchplnet b/mchplnet
index c935a784..3ba85b65 160000
--- a/mchplnet
+++ b/mchplnet
@@ -1 +1 @@
-Subproject commit c935a7842d882d1dcf12929cab053c9b415901c0
+Subproject commit 3ba85b6589118bf50b387f6528d0b86981a73958
diff --git a/pyproject.toml b/pyproject.toml
index 76bfeef0..518156d4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "pyx2cscope"
-version = "0.5.1"
+version = "0.6.0"
description = "python implementation of X2Cscope"
authors = [
"Yash Agarwal",
@@ -33,7 +33,7 @@ numpy = "^1.26.0"
matplotlib = "^3.7.2"
PyQt5 = "^5.15.9"
pyqtgraph = "^0.13.7"
-mchplnet = "0.3.0"
+mchplnet = "0.4.1"
flask = "^3.0.3"
[tool.ruff]
diff --git a/pyx2cscope/__init__.py b/pyx2cscope/__init__.py
index bcf7753c..c1ef5fdf 100644
--- a/pyx2cscope/__init__.py
+++ b/pyx2cscope/__init__.py
@@ -1,11 +1,11 @@
"""This module contains the pyx2cscope package.
-Version: 0.5.1
+Version: 0.6.0
"""
import logging
-__version__ = "0.5.1"
+__version__ = "0.6.0"
def set_logger(
level: int = logging.ERROR,
diff --git a/pyx2cscope/__main__.py b/pyx2cscope/__main__.py
index 0a696935..4d331585 100644
--- a/pyx2cscope/__main__.py
+++ b/pyx2cscope/__main__.py
@@ -49,7 +49,7 @@ def parse_arguments():
"-q",
"--qt",
action="store_false",
- help="Start the Qt user interface, pyx2cscope.gui.generic_gui.generic_gui.X2Cscope",
+ help="Start the Qt user interface, pyx2cscope.gui.qt.main_window.MainWindow",
)
parser.add_argument(
"-w",
diff --git a/pyx2cscope/examples/SFR_Example.py b/pyx2cscope/examples/SFR_Example.py
index cf118cb4..bb576996 100644
--- a/pyx2cscope/examples/SFR_Example.py
+++ b/pyx2cscope/examples/SFR_Example.py
@@ -1,109 +1,38 @@
-"""Example to change LED states by modifying the bit value on a dspic33ck256mp508 using Special Function Register."""
+"""Example to read LATD and TMR1 registers using Special Function Register (SFR) access."""
import logging
import time
-from variable.variable import VariableInfo
-
from pyx2cscope.utils import get_com_port, get_elf_file_path
from pyx2cscope.x2cscope import X2CScope
# Configuration for serial port communication
-serial_port = get_com_port() # Select COM port
-baud_rate = 115200
+port = get_com_port() # Select COM port
elf_file = get_elf_file_path()
# Initialize the X2CScope with the specified serial port and baud rate
-x2cscope = X2CScope(port=serial_port, baud_rate=baud_rate, elf_file=elf_file)
-
-# Constants for LED bit positions in the Special Function Register
-LED1_BIT = 12 # LATE12
-LED2_BIT = 13 # LATE13
-
-
-def set_led_state(value, bit_position, state):
- """Set or clear the specified bit in the value based on the state.
-
- Args:
- value (int): The current value of the register.
- bit_position (int): The bit position to modify.
- state (bool): True to set the bit, False to clear the bit.
-
- Returns:
- int: The modified register value.
- """
- if state:
- value |= 1 << bit_position # Set the bit to 1 (OR operation)
- else:
- value &= ~(1 << bit_position) # Clear the bit to 0 (AND operation)
- return value
-
-
-def set_high(value, bit_position):
- """Set a specific bit to high (1).
-
- Args:
- value (int): The current value of the register.
- bit_position (int): The bit position to set high.
-
- Returns:
- int: The modified register value with the bit set to high.
- """
- return set_led_state(value, bit_position, True)
-
-
-def set_low(value, bit_position):
- """Set a specific bit to low (0).
-
- Args:
- value (int): The current value of the register.
- bit_position (int): The bit position to set low.
-
- Returns:
- int: The modified register value with the bit set to low.
- """
- return set_led_state(value, bit_position, False)
+x2cscope = X2CScope(port=port, elf_file=elf_file)
+# Get the LATD register using the sfr parameter
+latd_register = x2cscope.get_variable("LATD", sfr=True)
-def example():
- """Main function to demonstrate LED state changes using SFR."""
- try:
- # Initialize the variable for the Special Function Register (SFR) controlling the LEDs
- variable_info = VariableInfo("my_led", "int", 1, 0, 0, 3702, 0, {})
- sfr_led = x2cscope.get_variable_raw(variable_info) # LATE address from data sheet 3702
+# Get the TMR1 register using the sfr parameter
+tmr1_register = x2cscope.get_variable("TMR1", sfr=True)
- # Get the initial LED state from the SFR
- initial_led_state = sfr_led.get_value()
- logging.debug("initial value: %s", initial_led_state)
+print("Reading LATD and TMR1 registers...")
+print("Press Ctrl+C to stop\n")
- while True:
- #########################
- # SET LED1 and LED2 High
- ##########################
- led1_high_value = set_high(initial_led_state, LED1_BIT)
- sfr_led.set_value(led1_high_value)
- initial_led_state = sfr_led.get_value()
- led2_high_value = set_high(initial_led_state, LED2_BIT)
- sfr_led.set_value(led2_high_value)
+# Read the current value of LATD register
+latd_value = latd_register.get_value()
- #########################
- # SET LED1 and LED2 LOW
- ##########################
- time.sleep(1)
- initial_led_state = sfr_led.get_value()
- led1_low_value = set_low(initial_led_state, LED1_BIT)
- sfr_led.set_value(led1_low_value)
+# Read the current value of TMR1 register
+tmr1_value = tmr1_register.get_value()
- initial_led_state = sfr_led.get_value()
- led2_low_value = set_low(initial_led_state, LED2_BIT)
- sfr_led.set_value(led2_low_value)
- time.sleep(1)
+# Print the register values
+print(f"LATD: 0x{latd_value:04X} ({latd_value})")
+print(f"TMR1: 0x{tmr1_value:04X} ({tmr1_value})")
+print("-" * 40)
- except Exception as e:
- # Handle any other exceptions
- logging.error("Error occurred: {}".format(e))
-if __name__ == "__main__":
- example()
diff --git a/pyx2cscope/examples/SFR_Example_raw.py b/pyx2cscope/examples/SFR_Example_raw.py
new file mode 100644
index 00000000..cf118cb4
--- /dev/null
+++ b/pyx2cscope/examples/SFR_Example_raw.py
@@ -0,0 +1,109 @@
+"""Example to change LED states by modifying the bit value on a dspic33ck256mp508 using Special Function Register."""
+
+import logging
+import time
+
+from variable.variable import VariableInfo
+
+from pyx2cscope.utils import get_com_port, get_elf_file_path
+from pyx2cscope.x2cscope import X2CScope
+
+# Configuration for serial port communication
+serial_port = get_com_port() # Select COM port
+baud_rate = 115200
+elf_file = get_elf_file_path()
+
+# Initialize the X2CScope with the specified serial port and baud rate
+x2cscope = X2CScope(port=serial_port, baud_rate=baud_rate, elf_file=elf_file)
+
+# Constants for LED bit positions in the Special Function Register
+LED1_BIT = 12 # LATE12
+LED2_BIT = 13 # LATE13
+
+
+def set_led_state(value, bit_position, state):
+ """Set or clear the specified bit in the value based on the state.
+
+ Args:
+ value (int): The current value of the register.
+ bit_position (int): The bit position to modify.
+ state (bool): True to set the bit, False to clear the bit.
+
+ Returns:
+ int: The modified register value.
+ """
+ if state:
+ value |= 1 << bit_position # Set the bit to 1 (OR operation)
+ else:
+ value &= ~(1 << bit_position) # Clear the bit to 0 (AND operation)
+ return value
+
+
+def set_high(value, bit_position):
+ """Set a specific bit to high (1).
+
+ Args:
+ value (int): The current value of the register.
+ bit_position (int): The bit position to set high.
+
+ Returns:
+ int: The modified register value with the bit set to high.
+ """
+ return set_led_state(value, bit_position, True)
+
+
+def set_low(value, bit_position):
+ """Set a specific bit to low (0).
+
+ Args:
+ value (int): The current value of the register.
+ bit_position (int): The bit position to set low.
+
+ Returns:
+ int: The modified register value with the bit set to low.
+ """
+ return set_led_state(value, bit_position, False)
+
+
+def example():
+ """Main function to demonstrate LED state changes using SFR."""
+ try:
+ # Initialize the variable for the Special Function Register (SFR) controlling the LEDs
+ variable_info = VariableInfo("my_led", "int", 1, 0, 0, 3702, 0, {})
+ sfr_led = x2cscope.get_variable_raw(variable_info) # LATE address from data sheet 3702
+
+ # Get the initial LED state from the SFR
+ initial_led_state = sfr_led.get_value()
+ logging.debug("initial value: %s", initial_led_state)
+
+ while True:
+ #########################
+ # SET LED1 and LED2 High
+ ##########################
+ led1_high_value = set_high(initial_led_state, LED1_BIT)
+ sfr_led.set_value(led1_high_value)
+
+ initial_led_state = sfr_led.get_value()
+ led2_high_value = set_high(initial_led_state, LED2_BIT)
+ sfr_led.set_value(led2_high_value)
+
+ #########################
+ # SET LED1 and LED2 LOW
+ ##########################
+ time.sleep(1)
+ initial_led_state = sfr_led.get_value()
+ led1_low_value = set_low(initial_led_state, LED1_BIT)
+ sfr_led.set_value(led1_low_value)
+
+ initial_led_state = sfr_led.get_value()
+ led2_low_value = set_low(initial_led_state, LED2_BIT)
+ sfr_led.set_value(led2_low_value)
+ time.sleep(1)
+
+ except Exception as e:
+ # Handle any other exceptions
+ logging.error("Error occurred: {}".format(e))
+
+
+if __name__ == "__main__":
+ example()
diff --git a/pyx2cscope/examples/tcp_demo.py b/pyx2cscope/examples/tcp_demo.py
new file mode 100644
index 00000000..86c6034c
--- /dev/null
+++ b/pyx2cscope/examples/tcp_demo.py
@@ -0,0 +1,26 @@
+"""Demo scripting for user to get started with TCP-IP."""
+import time
+
+from pyx2cscope.utils import get_elf_file_path, get_host_address
+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(host=get_host_address(), 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)
+
+phase_current = x2cscope.get_variable("my_counter")
+
+x2cscope.add_scope_channel(phase_current)
+
+x2cscope.request_scope_data()
+
+while not stop_requested():
+ if x2cscope.is_scope_data_ready():
+ print(x2cscope.get_scope_channel_data())
+ x2cscope.request_scope_data()
+ time.sleep(0.1)
+
+print("Script stopped.")
diff --git a/pyx2cscope/gui/__init__.py b/pyx2cscope/gui/__init__.py
index 74d0ed94..8bc5ccd2 100644
--- a/pyx2cscope/gui/__init__.py
+++ b/pyx2cscope/gui/__init__.py
@@ -33,14 +33,14 @@ def execute_qt(*args, **kwargs):
from PyQt5.QtWidgets import QApplication
- from pyx2cscope.gui.generic_gui.generic_gui import X2cscopeGui
+ from pyx2cscope.gui.qt.main_window import MainWindow
# QApplication expects the first argument to be the program name.
qt_args = sys.argv[:1] + args[0]
# Initialize a PyQt5 application
app = QApplication(qt_args)
- # Create an instance of the X2Cscope_GUI
- ex = X2cscopeGui(*args, **kwargs)
+ # Create an instance of the main window
+ ex = MainWindow()
# Display the GUI
ex.show()
# Start the PyQt5 application event loop
diff --git a/pyx2cscope/gui/generic_gui/__init__.py b/pyx2cscope/gui/generic_gui/__init__.py
deleted file mode 100644
index 1d624f3c..00000000
--- a/pyx2cscope/gui/generic_gui/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""FOC Gui: a general gui for motor control."""
diff --git a/pyx2cscope/gui/generic_gui/detachable_gui.py b/pyx2cscope/gui/generic_gui/detachable_gui.py
deleted file mode 100644
index 5d8a9f60..00000000
--- a/pyx2cscope/gui/generic_gui/detachable_gui.py
+++ /dev/null
@@ -1,1414 +0,0 @@
-"""Detachable genenric GUI for X2Cscope."""
-
-import logging
-import os
-import sys
-import time
-from collections import deque
-from datetime import datetime
-
-import matplotlib
-import numpy as np
-import pyqtgraph as pg
-import serial.tools.list_ports
-from PyQt5 import QtGui
-from PyQt5.QtCore import QFileInfo, QMutex, QRegExp, QSettings, Qt, QTimer, pyqtSlot
-from PyQt5.QtGui import QIcon, QRegExpValidator
-from PyQt5.QtWidgets import (
- QApplication,
- QCheckBox,
- QComboBox,
- QDockWidget,
- QFileDialog,
- QGridLayout,
- QGroupBox,
- QHBoxLayout,
- QLabel,
- QLineEdit,
- QMainWindow,
- QMessageBox,
- QPushButton,
- QSizePolicy,
- QSlider,
- QStyleFactory,
- QVBoxLayout,
- QWidget,
-)
-
-from pyx2cscope.gui import img as img_src
-from pyx2cscope.x2cscope import TriggerConfig, X2CScope
-
-logging.basicConfig(level=logging.ERROR)
-
-matplotlib.use("QtAgg")
-
-
-class X2cscopeGui(QMainWindow):
- """Main GUI class for the pyX2Cscope application."""
-
- def __init__(self):
- """Initializing all the elements required."""
- super().__init__()
-
- self.triggerVariable = None
- self.initialize_variables()
- self.init_ui()
-
- def initialize_variables(self):
- """Initialize instance variables."""
- self.sampling_active = False
- self.offset_boxes = None
- self.plot_checkboxes = None
- self.scaled_value_boxes = None
- self.scaling_boxes = None
- self.Value_var_boxes = None
- self.combo_boxes = None
- self.live_checkboxes = None
- self.timer_list = None
- self.VariableList = []
- self.old_Variable_list = []
- self.var_factory = None
- self.ser = None
- self.timerValue = 500
- self.port_combo = QComboBox()
- self.layout = None
- self.slider_var1 = QSlider(Qt.Horizontal)
- self.plot_button = QPushButton("Plot")
- self.mutex = QMutex()
- self.grid_layout = QGridLayout()
- self.box_layout = QHBoxLayout()
- self.timer1 = QTimer()
- self.timer2 = QTimer()
- self.timer3 = QTimer()
- self.timer4 = QTimer()
- self.timer5 = QTimer()
- self.plot_update_timer = QTimer() # Timer for continuous plot update
- self.timer()
- self.offset_var()
- self.plot_var_check()
- self.scaling_var()
- self.value_var()
- self.live_var()
- self.scaled_value()
- self.combo_box()
- self.sampletime = QLineEdit()
- self.unit_var()
- self.Connect_button = QPushButton("Connect")
- self.baud_combo = QComboBox()
- self.select_file_button = QPushButton("Select elf file")
- self.error_shown = False
- self.plot_window_open = False
- self.settings = QSettings("MyCompany", "MyApp")
- self.file_path: str = self.settings.value("file_path", "", type=str)
- self.init_variables()
-
- def init_variables(self):
- """Some extra variables define."""
- self.selected_var_indices = [
- 0,
- 0,
- 0,
- 0,
- 0,
- ] # List to store selected variable indices
- self.selected_variables = [] # List to store selected variables
- decimal_regex = QRegExp("-?[0-9]+(\\.[0-9]+)?")
- self.decimal_validator = QRegExpValidator(decimal_regex)
-
- self.plot_data = deque(maxlen=250) # Store plot data for all variables
- self.plot_colors = [
- "b",
- "g",
- "r",
- "c",
- "m",
- "y",
- "k",
- ] # colours for different plot
- # Add self.labels on top
- self.labels = [
- "Live",
- "Variable",
- "Value",
- "Scaling",
- "Offset",
- "Scaled Value",
- "Unit",
- "Plot",
- ]
-
- def init_ui(self):
- """Initialize the user interface."""
- self.setup_application_style()
- self.create_central_widget()
- self.create_dockable_tabs()
- self.setup_window_properties()
- self.refresh_ports()
-
- def setup_application_style(self):
- """Set the application style."""
- QApplication.setStyle(QStyleFactory.create("Fusion"))
-
- def create_central_widget(self):
- """Create the central widget."""
- central_widget = QWidget(self)
- self.setCentralWidget(central_widget)
- self.layout = QVBoxLayout(central_widget)
-
- def create_dockable_tabs(self):
- """Create dockable tabs for the main window."""
- self.watch_view_dock = QDockWidget("WatchView", self)
- self.scope_view_dock = QDockWidget("ScopeView", self)
-
- self.tab1 = QWidget()
- self.tab2 = QWidget()
-
- self.watch_view_dock.setWidget(self.tab1)
- self.scope_view_dock.setWidget(self.tab2)
-
- self.addDockWidget(Qt.LeftDockWidgetArea, self.watch_view_dock)
- self.addDockWidget(Qt.RightDockWidgetArea, self.scope_view_dock)
-
- self.setup_tab1()
- self.setup_tab2()
-
- def setup_window_properties(self):
- """Set up the main window properties."""
- self.setWindowTitle("pyX2Cscope")
- mchp_img = os.path.join(os.path.dirname(img_src.__file__), "pyx2cscope.jpg")
- self.setWindowIcon(QtGui.QIcon(mchp_img))
-
- def combo_box(self):
- """Initializing combo boxes."""
- self.combo_box5 = QComboBox()
- self.combo_box4 = QComboBox()
- self.combo_box3 = QComboBox()
- self.combo_box2 = QComboBox()
- self.combo_box1 = QComboBox()
-
- def scaled_value(self):
- """Initializing Scaled variable."""
- self.ScaledValue_var1 = QLineEdit(self)
- self.ScaledValue_var2 = QLineEdit(self)
- self.ScaledValue_var3 = QLineEdit(self)
- self.ScaledValue_var4 = QLineEdit(self)
- self.ScaledValue_var5 = QLineEdit(self)
-
- def live_var(self):
- """Initializing live variable."""
- self.Live_var1 = QCheckBox(self)
- self.Live_var2 = QCheckBox(self)
- self.Live_var3 = QCheckBox(self)
- self.Live_var4 = QCheckBox(self)
- self.Live_var5 = QCheckBox(self)
-
- def value_var(self):
- """Initializing value variable."""
- self.Value_var1 = QLineEdit(self)
- self.Value_var2 = QLineEdit(self)
- self.Value_var3 = QLineEdit(self)
- self.Value_var4 = QLineEdit(self)
- self.Value_var5 = QLineEdit(self)
-
- def timer(self):
- """Initializing timer."""
- self.timer5 = QTimer()
- self.timer4 = QTimer()
- self.timer3 = QTimer()
- self.timer2 = QTimer()
- self.timer1 = QTimer()
-
- def offset_var(self):
- """Initializing Offset Variable."""
- self.offset_var1 = QLineEdit()
- self.offset_var2 = QLineEdit()
- self.offset_var3 = QLineEdit()
- self.offset_var4 = QLineEdit()
- self.offset_var5 = QLineEdit()
-
- def plot_var_check(self):
- """Initializing plot variable check boxes."""
- self.plot_var5_checkbox = QCheckBox()
- self.plot_var2_checkbox = QCheckBox()
- self.plot_var4_checkbox = QCheckBox()
- self.plot_var3_checkbox = QCheckBox()
- self.plot_var1_checkbox = QCheckBox()
-
- def scaling_var(self):
- """Initializing Scaling variable."""
- self.Scaling_var1 = QLineEdit(self)
- self.Scaling_var2 = QLineEdit(self)
- self.Scaling_var3 = QLineEdit(self)
- self.Scaling_var4 = QLineEdit(self)
- self.Scaling_var5 = QLineEdit(self)
-
- def unit_var(self):
- """Initializing unit variable."""
- self.Unit_var1 = QLineEdit(self)
- self.Unit_var2 = QLineEdit(self)
- self.Unit_var3 = QLineEdit(self)
- self.Unit_var4 = QLineEdit(self)
- self.Unit_var5 = QLineEdit(self)
-
- def setup_tab1(self):
- """Set up the first tab with the original functionality."""
- self.tab1.layout = QVBoxLayout()
- self.tab1.setLayout(self.tab1.layout)
-
- grid_layout = QGridLayout()
- self.tab1.layout.addLayout(grid_layout)
-
- self.setup_port_layout(grid_layout)
- self.setup_baud_layout(grid_layout)
- self.setup_sampletime_layout(grid_layout)
- self.setup_variable_layout(grid_layout)
- self.setup_connections()
-
- def setup_tab2(self):
- """Set up the second tab with the scope functionality."""
- self.tab2.layout = QVBoxLayout()
- self.tab2.setLayout(self.tab2.layout)
-
- main_grid_layout = QGridLayout()
- self.tab2.layout.addLayout(main_grid_layout)
-
- # Set up individual components
- trigger_group = self.create_trigger_configuration_group()
- variable_group = self.create_variable_selection_group()
- self.scope_plot_widget = self.create_scope_plot_widget()
- button_layout = self.create_save_load_buttons()
-
- # Add the group boxes to the main layout with stretch factors
- main_grid_layout.addWidget(trigger_group, 0, 0)
- main_grid_layout.addWidget(variable_group, 0, 1)
-
- # Set the column stretch factors to make the variable group larger
- main_grid_layout.setColumnStretch(0, 1) # Trigger configuration box
- main_grid_layout.setColumnStretch(1, 3) # Variable selection box
-
- # Add the plot widget for scope view
- self.tab2.layout.addWidget(self.scope_plot_widget)
-
- # Add Save and Load buttons
- self.tab2.layout.addLayout(button_layout)
-
- def create_trigger_configuration_group(self):
- """Create the trigger configuration group box."""
- trigger_group = QGroupBox("Trigger Configuration")
- trigger_layout = QVBoxLayout()
- trigger_group.setLayout(trigger_layout)
-
- grid_layout_trigger = QGridLayout()
- trigger_layout.addLayout(grid_layout_trigger)
-
- self.single_shot_checkbox = QCheckBox("Single Shot")
- self.sample_time_factor = QLineEdit("1")
- self.sample_time_factor.setValidator(self.decimal_validator)
- self.trigger_mode_combo = QComboBox()
- self.trigger_mode_combo.addItems(["Auto", "Triggered"])
- self.trigger_edge_combo = QComboBox()
- self.trigger_edge_combo.addItems(["Rising", "Falling"])
- self.trigger_level_edit = QLineEdit("0")
- self.trigger_level_edit.setValidator(self.decimal_validator)
- self.trigger_delay_edit = QLineEdit("0")
- self.trigger_delay_edit.setValidator(self.decimal_validator)
-
- self.scope_sampletime_edit = QLineEdit(
- "50"
- ) # Default sample time in microseconds
- self.scope_sampletime_edit.setValidator(self.decimal_validator)
-
- # Total Time
- self.total_time_label = QLabel("Total Time (ms):")
- self.total_time_value = QLineEdit("0")
- self.total_time_value.setReadOnly(True)
-
- self.scope_sample_button = QPushButton("Sample")
- self.scope_sample_button.setFixedSize(100, 30)
- self.scope_sample_button.clicked.connect(self.start_sampling)
-
- # Arrange widgets in grid layout
- grid_layout_trigger.addWidget(self.single_shot_checkbox, 0, 0, 1, 2)
- grid_layout_trigger.addWidget(QLabel("Sample Time Factor"), 1, 0)
- grid_layout_trigger.addWidget(self.sample_time_factor, 1, 1)
- grid_layout_trigger.addWidget(QLabel("Scope Sample Time (µs):"), 2, 0)
- grid_layout_trigger.addWidget(self.scope_sampletime_edit, 2, 1)
- grid_layout_trigger.addWidget(self.total_time_label, 3, 0)
- grid_layout_trigger.addWidget(self.total_time_value, 3, 1)
- grid_layout_trigger.addWidget(QLabel("Trigger Mode:"), 4, 0)
- grid_layout_trigger.addWidget(self.trigger_mode_combo, 4, 1)
- grid_layout_trigger.addWidget(QLabel("Trigger Edge:"), 5, 0)
- grid_layout_trigger.addWidget(self.trigger_edge_combo, 5, 1)
- grid_layout_trigger.addWidget(QLabel("Trigger Level:"), 6, 0)
- grid_layout_trigger.addWidget(self.trigger_level_edit, 6, 1)
- grid_layout_trigger.addWidget(QLabel("Trigger Delay:"), 7, 0)
- grid_layout_trigger.addWidget(self.trigger_delay_edit, 7, 1)
- grid_layout_trigger.addWidget(self.scope_sample_button, 8, 0, 1, 2)
-
- return trigger_group
-
- def create_variable_selection_group(self):
- """Create the variable selection group box."""
- variable_group = QGroupBox("Variable Selection")
- variable_layout = QVBoxLayout()
- variable_group.setLayout(variable_layout)
-
- grid_layout_variable = QGridLayout()
- variable_layout.addLayout(grid_layout_variable)
- number_of_variables = 8
-
- self.scope_var_lines = [QLineEdit() for _ in range(number_of_variables)]
- self.trigger_var_checkbox = [QCheckBox() for _ in range(number_of_variables)]
- self.scope_channel_checkboxes = [QCheckBox() for _ in range(number_of_variables)]
- self.scope_scaling_boxes = [QLineEdit("1") for _ in range(number_of_variables)]
-
- for checkbox in self.scope_channel_checkboxes:
- checkbox.setChecked(True)
-
- for line_edit in self.scope_var_lines:
- line_edit.setReadOnly(True)
- line_edit.setPlaceholderText("Search Variable")
- line_edit.installEventFilter(self)
-
- # Add "Search Variable" label
- grid_layout_variable.addWidget(QLabel("Search Variable"), 0, 1)
- grid_layout_variable.addWidget(QLabel("Trigger"), 0, 0)
- grid_layout_variable.addWidget(QLabel("Gain"), 0, 2)
- grid_layout_variable.addWidget(QLabel("Visible"), 0, 3)
-
- for i, (line_edit, trigger_checkbox, scale_box, show_checkbox) in enumerate(
- zip(
- self.scope_var_lines,
- self.trigger_var_checkbox,
- self.scope_scaling_boxes,
- self.scope_channel_checkboxes,
- )
- ):
- line_edit.setMinimumHeight(20)
- trigger_checkbox.setMinimumHeight(20)
- show_checkbox.setMinimumHeight(20)
- scale_box.setMinimumHeight(20)
- scale_box.setFixedSize(50, 20)
- scale_box.setValidator(self.decimal_validator)
-
- line_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
- grid_layout_variable.addWidget(trigger_checkbox, i + 1, 0)
- grid_layout_variable.addWidget(line_edit, i + 1, 1)
- grid_layout_variable.addWidget(scale_box, i + 1, 2)
- grid_layout_variable.addWidget(show_checkbox, i + 1, 3)
-
- trigger_checkbox.stateChanged.connect(
- lambda state, x=i: self.handle_scope_checkbox_change(state, x)
- )
- scale_box.editingFinished.connect(self.update_scope_plot)
- show_checkbox.stateChanged.connect(self.update_scope_plot)
-
- return variable_group
-
- def create_scope_plot_widget(self):
- """Create the scope plot widget."""
- scope_plot_widget = pg.PlotWidget(title="Scope Plot")
- scope_plot_widget.setBackground("w")
- scope_plot_widget.addLegend()
- scope_plot_widget.showGrid(x=True, y=True)
- scope_plot_widget.getViewBox().setMouseMode(pg.ViewBox.RectMode)
-
- return scope_plot_widget
-
- def create_save_load_buttons(self):
- """Create the save and load buttons."""
- self.save_button_scope = QPushButton("Save Config")
- self.load_button_scope = QPushButton("Load Config")
- self.save_button_scope.setFixedSize(100, 30)
- self.load_button_scope.setFixedSize(100, 30)
-
- button_layout = QHBoxLayout()
- button_layout.addWidget(self.save_button_scope)
- button_layout.addWidget(self.load_button_scope)
-
- return button_layout
-
- def handle_scope_checkbox_change(self, state, index):
- """Handle the change in the state of the scope view checkboxes."""
- if state == Qt.Checked:
- for i, checkbox in enumerate(self.trigger_var_checkbox):
- if i != index:
- checkbox.setChecked(False)
- self.triggerVariable = self.scope_var_combos[index].currentText()
- logging.debug(f"Checked variable: {self.scope_var_combos[index].currentText()}")
- else:
- self.triggerVariable = None
-
- def setup_port_layout(self, layout):
- """Set up the port selection layout."""
- port_layout = QGridLayout()
- port_label = QLabel("Select Port:")
-
- refresh_button = QPushButton()
- refresh_button.setFixedSize(25, 25)
- refresh_button.clicked.connect(self.refresh_ports)
- refresh_img = os.path.join(os.path.dirname(img_src.__file__), "refresh.png")
- refresh_button.setIcon(QIcon(refresh_img))
-
- self.select_file_button.setEnabled(True)
- self.select_file_button.clicked.connect(self.select_elf_file)
-
- port_layout.addWidget(port_label, 0, 0)
- port_layout.addWidget(self.port_combo, 0, 1)
- port_layout.addWidget(refresh_button, 0, 2)
-
- layout.addLayout(port_layout, 1, 0)
-
- def setup_baud_layout(self, layout):
- """Set up the baud rate selection layout."""
- baud_layout = QGridLayout()
- baud_label = QLabel("Select Baud Rate:")
- baud_layout.addWidget(baud_label, 0, 0)
- baud_layout.addWidget(self.baud_combo, 0, 1)
-
- self.baud_combo.addItems(["38400", "115200", "230400", "460800", "921600"])
- default_baud_rate = "115200"
- index = self.baud_combo.findText(default_baud_rate, Qt.MatchFixedString)
- if index >= 0:
- self.baud_combo.setCurrentIndex(index)
-
- layout.addLayout(baud_layout, 2, 0)
-
- def setup_sampletime_layout(self, layout):
- """Set up the sample time layout."""
- self.Connect_button.clicked.connect(self.toggle_connection)
- self.Connect_button.setFixedSize(100, 30)
- self.Connect_button.setMinimumHeight(30)
-
- self.sampletime.setText("500")
- self.sampletime.setValidator(self.decimal_validator)
- self.sampletime.editingFinished.connect(self.sampletime_edit)
- self.sampletime.setFixedSize(50, 20)
-
- sampletime_layout = QHBoxLayout()
- sampletime_layout.addWidget(QLabel("Sampletime"), alignment=Qt.AlignLeft)
- sampletime_layout.addWidget(self.sampletime, alignment=Qt.AlignLeft)
- sampletime_layout.addWidget(QLabel("ms"), alignment=Qt.AlignLeft)
- sampletime_layout.addStretch(1)
- sampletime_layout.addWidget(self.Connect_button, alignment=Qt.AlignRight)
-
- layout.addLayout(sampletime_layout, 3, 0)
- layout.addWidget(self.select_file_button, 4, 0)
-
- def setup_variable_layout(self, layout):
- """Set up the variable selection layout."""
- self.timer_list = [
- self.timer1,
- self.timer2,
- self.timer3,
- self.timer4,
- self.timer5,
- ]
-
- for col, label in enumerate(self.labels):
- self.grid_layout.addWidget(QLabel(label), 0, col)
-
- self.live_checkboxes = [
- self.Live_var1,
- self.Live_var2,
- self.Live_var3,
- self.Live_var4,
- self.Live_var5,
- ]
- self.combo_boxes = [
- self.combo_box1,
- self.combo_box2,
- self.combo_box3,
- self.combo_box4,
- self.combo_box5,
- ]
- self.Value_var_boxes = [
- self.Value_var1,
- self.Value_var2,
- self.Value_var3,
- self.Value_var4,
- self.Value_var5,
- ]
- self.scaling_boxes = [
- self.Scaling_var1,
- self.Scaling_var2,
- self.Scaling_var3,
- self.Scaling_var4,
- self.Scaling_var5,
- ]
- self.scaled_value_boxes = [
- self.ScaledValue_var1,
- self.ScaledValue_var2,
- self.ScaledValue_var3,
- self.ScaledValue_var4,
- self.ScaledValue_var5,
- ]
- unit_boxes = [
- self.Unit_var1,
- self.Unit_var2,
- self.Unit_var3,
- self.Unit_var4,
- self.Unit_var5,
- ]
- self.plot_checkboxes = [
- self.plot_var1_checkbox,
- self.plot_var2_checkbox,
- self.plot_var3_checkbox,
- self.plot_var4_checkbox,
- self.plot_var5_checkbox,
- ]
- self.offset_boxes = [
- self.offset_var1,
- self.offset_var2,
- self.offset_var3,
- self.offset_var4,
- self.offset_var5,
- ]
-
- for row_index, (
- live_var,
- combo_box,
- value_var,
- scaling_var,
- offset_var,
- scaled_value_var,
- unit_var,
- plot_checkbox,
- ) in enumerate(
- zip(
- self.live_checkboxes,
- self.combo_boxes,
- self.Value_var_boxes,
- self.scaling_boxes,
- self.offset_boxes,
- self.scaled_value_boxes,
- unit_boxes,
- self.plot_checkboxes,
- ),
- 1,
- ):
- live_var.setEnabled(False)
- combo_box.setEnabled(False)
- combo_box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
- value_var.setText("0")
- value_var.setValidator(self.decimal_validator)
- scaling_var.setText("1")
- offset_var.setText("0")
- offset_var.setValidator(self.decimal_validator)
- scaled_value_var.setText("0")
- scaled_value_var.setValidator(self.decimal_validator)
- display_row = row_index # Use a different variable name for the assignment
- if display_row > 1:
- display_row += 1
- self.grid_layout.addWidget(live_var, display_row, 0)
- self.grid_layout.addWidget(combo_box, display_row, 1)
- if display_row == 1:
- self.grid_layout.addWidget(self.slider_var1, display_row + 1, 0, 1, 7)
-
- self.grid_layout.addWidget(value_var, display_row, 2)
- self.grid_layout.addWidget(scaling_var, display_row, 3)
- self.grid_layout.addWidget(offset_var, display_row, 4)
- self.grid_layout.addWidget(scaled_value_var, display_row, 5)
- self.grid_layout.addWidget(unit_var, display_row, 6)
- self.grid_layout.addWidget(plot_checkbox, display_row, 7)
- plot_checkbox.stateChanged.connect(
- lambda state, x=row_index - 1: self.update_watch_plot()
- )
-
- layout.addLayout(self.grid_layout, 5, 0)
-
- # Add the plot widget for watch view
- self.watch_plot_widget = pg.PlotWidget(title="Watch Plot")
- self.watch_plot_widget.setBackground("w")
- self.watch_plot_widget.addLegend() # Add legend to the plot widget
- self.watch_plot_widget.showGrid(x=True, y=True) # Enable grid lines
- self.tab1.layout.addWidget(self.watch_plot_widget)
-
- def setup_connections(self):
- """Set up connections for various widgets."""
- self.plot_button.clicked.connect(self.plot_data_plot)
-
- for timer, combo_box, value_var in zip(
- self.timer_list, self.combo_boxes, self.Value_var_boxes
- ):
- timer.timeout.connect(
- lambda cb=combo_box, v_var=value_var: self.handle_var_update(
- cb.currentText(), v_var
- )
- )
-
- for combo_box, value_var in zip(self.combo_boxes, self.Value_var_boxes):
- combo_box.currentIndexChanged.connect(
- lambda cb=combo_box, v_var=value_var: self.handle_variable_getram(
- self.VariableList[cb], v_var
- )
- )
- for combo_box, value_var in zip(self.combo_boxes, self.Value_var_boxes):
- value_var.editingFinished.connect(
- lambda cb=combo_box, v_var=value_var: self.handle_variable_putram(
- cb.currentText(), v_var
- )
- )
-
- self.connect_editing_finished()
-
- for timer, live_var in zip(self.timer_list, self.live_checkboxes):
- live_var.stateChanged.connect(
- lambda state, lv=live_var, tm=timer: self.var_live(lv, tm)
- )
-
- self.slider_var1.setMinimum(-32768)
- self.slider_var1.setMaximum(32767)
- self.slider_var1.setEnabled(False)
- self.slider_var1.valueChanged.connect(self.slider_var1_changed)
-
- self.plot_update_timer.timeout.connect(
- self.update_watch_plot
- ) # Connect the QTimer to the update method
-
- def connect_editing_finished(self):
- """Connect editingFinished signals for value and scaling inputs."""
- for (
- scaling,
- value_var,
- scaled_value,
- offset,
- ) in zip(
- self.scaling_boxes,
- self.Value_var_boxes,
- self.scaled_value_boxes,
- self.offset_boxes,
- ):
-
- def connect_editing_finished(
- sc_edit=scaling,
- v_edit=value_var,
- scd_edit=scaled_value,
- off_edit=offset,
- ):
- def on_editing_finished():
- self.update_scaled_value(sc_edit, v_edit, scd_edit, off_edit)
-
- return on_editing_finished
-
- value_var.editingFinished.connect(connect_editing_finished())
-
- for (
- scaling,
- value_var,
- scaled_value,
- offset,
- ) in zip(
- self.scaling_boxes,
- self.Value_var_boxes,
- self.scaled_value_boxes,
- self.offset_boxes,
- ):
-
- def connect_editing_finished(
- sc_edit=scaling,
- v_edit=value_var,
- scd_edit=scaled_value,
- off_edit=offset,
- ):
- def on_editing_finished():
- self.update_scaled_value(sc_edit, v_edit, scd_edit, off_edit)
-
- return on_editing_finished
-
- scaling.editingFinished.connect(connect_editing_finished())
-
- for (
- scaling,
- value_var,
- scaled_value,
- offset,
- ) in zip(
- self.scaling_boxes,
- self.Value_var_boxes,
- self.scaled_value_boxes,
- self.offset_boxes,
- ):
-
- def connect_text_changed(
- sc_edit=scaling,
- v_edit=value_var,
- scd_edit=scaled_value,
- off_edit=offset,
- ):
- def on_text_changed():
- self.update_scaled_value(sc_edit, v_edit, scd_edit, off_edit)
-
- return on_text_changed
-
- value_var.textChanged.connect(connect_text_changed())
-
- for (
- scaling,
- value_var,
- scaled_value,
- offset,
- ) in zip(
- self.scaling_boxes,
- self.Value_var_boxes,
- self.scaled_value_boxes,
- self.offset_boxes,
- ):
-
- def connect_text_changed(
- sc_edit=scaling,
- v_edit=value_var,
- scd_edit=scaled_value,
- off_edit=offset,
- ):
- def on_text_changed():
- self.update_scaled_value(sc_edit, v_edit, scd_edit, off_edit)
-
- return on_text_changed
-
- offset.editingFinished.connect(connect_text_changed())
-
- @pyqtSlot()
- def var_live(self, live_var, timer):
- """Handles the state change of live variable checkboxes.
-
- Args:
- live_var (QCheckBox): The checkbox representing a live variable.
- timer (QTimer): The timer associated with the live variable.
- """
- try:
- if live_var.isChecked():
- if not timer.isActive():
- timer.start(self.timerValue)
- elif timer.isActive():
- timer.stop()
- except Exception as e:
- logging.error(e)
- self.handle_error(f"Live Variable: {e}")
-
- @pyqtSlot()
- def update_scaled_value(self, scaling_var, value_var, scaled_value_var, offset_var):
- """Updates the scaled value based on the provided scaling factor and offset.
-
- Args:
- scaling_var : Input field for the scaling factor.
- value_var : Input field for the raw value.
- scaled_value_var : Input field for the scaled value.
- offset_var : Input field for the offset.
- """
- scaling_text = scaling_var.text()
- value_text = value_var.text()
- offset_text = offset_var.text()
- try:
- value = float(value_text)
- if offset_text.startswith("-"):
- float_offset = float(offset_text.lstrip("-"))
- offset = -1 * float_offset
- else:
- offset = float(offset_text)
- if scaling_text.startswith("-"):
- float_scaling = float(scaling_text.lstrip("-"))
- scaling = -1 * float_scaling
- else:
- scaling = float(scaling_text)
- scaled_value = (scaling * value) + offset
- scaled_value_var.setText("{:.2f}".format(scaled_value))
- except Exception as e:
- logging.error(e)
- self.handle_error(f"Error update Scaled Value: {e}")
-
- def plot_data_update(self):
- """Updates the data for plotting."""
- try:
- timestamp = datetime.now()
- if len(self.plot_data) > 0:
- last_timestamp = self.plot_data[-1][0]
- time_diff = (
- timestamp - last_timestamp
- ).total_seconds() * 1000 # to convert time in ms.
- else:
- time_diff = 0
-
- def safe_float(value):
- try:
- return float(value)
- except ValueError:
- return 0.0
-
- self.plot_data.append(
- (
- timestamp,
- time_diff,
- safe_float(self.ScaledValue_var1.text()),
- safe_float(self.ScaledValue_var2.text()),
- safe_float(self.ScaledValue_var3.text()),
- safe_float(self.ScaledValue_var4.text()),
- safe_float(self.ScaledValue_var5.text()),
- )
- )
- except Exception as e:
- logging.error(e)
-
- def update_watch_plot(self):
- """Updates the plot in the WatchView tab with new data."""
- try:
- if not self.plot_data:
- return
-
- data = np.array(self.plot_data, dtype=object).T
- time_diffs = np.array(data[1], dtype=float)
- values = [np.array(data[i], dtype=float) for i in range(2, 7)]
-
- # Keep the last plot lines to avoid clearing and recreate them
- plot_lines = {}
- for item in self.watch_plot_widget.plotItem.items:
- if isinstance(item, pg.PlotDataItem):
- plot_lines[item.name()] = item
-
- for i, (value, combo_box, plot_var) in enumerate(
- zip(values, self.combo_boxes, self.plot_checkboxes)
- ):
- if plot_var.isChecked() and combo_box.currentIndex() != 0:
- if combo_box.currentText() in plot_lines:
- plot_line = plot_lines[combo_box.currentText()]
- plot_line.setData(np.cumsum(time_diffs), value)
- else:
- self.watch_plot_widget.plot(
- np.cumsum(time_diffs),
- value,
- pen=pg.mkPen(color=self.plot_colors[i], width=2),
- # Thicker plot line
- name=combo_box.currentText(),
- )
-
- self.watch_plot_widget.setLabel("left", "Value")
- self.watch_plot_widget.setLabel("bottom", "Time", units="ms")
- self.watch_plot_widget.showGrid(x=True, y=True) # Enable grid lines
- except Exception as e:
- logging.error(e)
-
- def plot_data_plot(self):
- """Initializes and starts data plotting."""
- try:
- if not self.plot_data:
- return
-
- self.update_watch_plot()
- self.update_scope_plot()
-
- if not self.plot_window_open:
- self.plot_window_open = True
- except Exception as e:
- logging.error(e)
-
- def update_scope_plot(self):
- """Updates the plot in the ScopeView tab with new data and scaling."""
- try:
- if not self.sampling_active:
- return
-
- if not self.x2cscope.is_scope_data_ready():
- return
-
- data_storage = {}
- for channel, data in self.x2cscope.get_scope_channel_data().items():
- data_storage[channel] = data
-
- self.scope_plot_widget.clear()
-
- for i, (channel, data) in enumerate(data_storage.items()):
- checkbox_state = self.scope_channel_checkboxes[i].isChecked()
- logging.debug(
- f"Channel {channel}: Checkbox is {'checked' if checkbox_state else 'unchecked'}"
- )
- if checkbox_state: # Check if the checkbox is checked
- scale_factor = float(
- self.scope_scaling_boxes[i].text()
- ) # Get the scaling factor
- # time_values = self.real_sampletime # Generate time values in ms
- # start = self.real_sampletime / len(data)
- start = 0
- time_values = np.linspace(start, self.real_sampletime, len(data))
- data_scaled = (
- np.array(data, dtype=float) * scale_factor
- ) # Apply the scaling factor
- self.scope_plot_widget.plot(
- time_values,
- data_scaled,
- pen=pg.mkPen(color=self.plot_colors[i], width=2),
- name=f"Channel {channel}",
- )
- logging.debug(
- f"Plotting channel {channel} with color {self.plot_colors[i]}"
- )
- else:
- logging.debug(f"Not plotting channel {channel}")
- self.scope_plot_widget.setLabel("left", "Value")
- self.scope_plot_widget.setLabel("bottom", "Time", units="ms")
- self.scope_plot_widget.showGrid(x=True, y=True)
- except Exception as e:
- error_message = f"Error updating scope plot: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def handle_error(self, error_message: str):
- """Displays an error message in a message box.
-
- Args:
- error_message (str): The error message to display.
- """
- msg_box = QMessageBox(self)
- msg_box.setWindowTitle("Error")
- msg_box.setText(error_message)
- msg_box.setStandardButtons(QMessageBox.Ok)
- msg_box.exec_()
-
- def sampletime_edit(self):
- """Handles the editing of the sample time value."""
- try:
- new_sample_time = int(self.sampletime.text())
- if new_sample_time != self.timerValue:
- self.timerValue = new_sample_time
- for timer in self.timer_list:
- if timer.isActive():
- timer.start(self.timerValue)
- except ValueError as e:
- logging.error(e)
- self.handle_error(f"Invalid sample time: {e}")
-
- @pyqtSlot()
- def handle_var_update(self, counter, value_var):
- """Handles the update of variable values from the microcontroller.
-
- Args:
- counter: The variable to update.
- value_var (QLineEdit): The input field to display the updated value.
- """
- try:
- if counter is not None:
- counter = self.x2cscope.get_variable(counter)
- value = counter.get_value()
- value_var.setText(str(value))
- if value_var == self.Value_var1:
- self.slider_var1.setValue(int(value))
- self.plot_data_update()
- except Exception as e:
- error_message = f"Error: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def slider_var1_changed(self, value):
- """Handles the change in slider value for Variable 1.
-
- Args:
- value (int): The new value of the slider.
- """
- if self.combo_box1.currentIndex() == 0:
- self.handle_error("Select Variable")
- else:
- self.Value_var1.setText(str(value))
- self.update_scaled_value(
- self.Scaling_var1,
- self.Value_var1,
- self.ScaledValue_var1,
- self.offset_var1,
- )
- self.handle_variable_putram(self.combo_box1.currentText(), self.Value_var1)
-
- @pyqtSlot()
- def handle_variable_getram(self, variable, value_var):
- """Handle the retrieval of values from RAM for the specified variable.
-
- Args:
- variable: The variable to retrieve the value for.
- value_var: The QLineEdit widget to display the retrieved value.
- """
- try:
- current_variable = variable
-
- for index, combo_box in enumerate(self.combo_boxes):
- if combo_box.currentText() == current_variable:
- self.selected_var_indices[index] = combo_box.currentIndex()
-
- if current_variable and current_variable != "None":
- counter = self.x2cscope.get_variable(current_variable)
- value = counter.get_value()
- value_var.setText(str(value))
- if value_var == self.Value_var1:
- self.slider_var1.setValue(int(value))
-
- if current_variable not in self.selected_variables:
- self.selected_variables.append(current_variable)
-
- except Exception as e:
- error_message = f"Error: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- @pyqtSlot()
- def handle_variable_putram(self, variable, value_var):
- """Handle the writing of values to RAM for the specified variable.
-
- Args:
- variable: The variable to write the value to.
- value_var: The QLineEdit widget to get the value from.
- """
- try:
- current_variable = variable
- value = float(value_var.text())
-
- if current_variable and current_variable != "None":
- counter = self.x2cscope.get_variable(current_variable)
- counter.set_value(value)
-
- except Exception as e:
- error_message = f"Error: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- @pyqtSlot()
- def select_elf_file(self):
- """Open a file dialog to select an ELF file.
-
- This method opens a file dialog for the user to select an ELF file.
- The selected file path is then stored in settings for later use.
- """
- file_dialog = QFileDialog()
- file_dialog.setNameFilter("ELF Files (*.elf)")
- file_dialog.setFileMode(QFileDialog.ExistingFile)
- fileinfo = QFileInfo(self.file_path)
- self.select_file_button.setText(fileinfo.fileName())
-
- if self.file_path:
- file_dialog.setDirectory(self.file_path)
- if file_dialog.exec_():
- selected_files = file_dialog.selectedFiles()
- if selected_files:
- self.file_path = selected_files[0]
- self.settings.setValue("file_path", self.file_path)
- self.select_file_button.setText(QFileInfo(self.file_path).fileName())
-
- def refresh_combo_box(self):
- """Refresh the contents of the variable selection combo boxes.
-
- This method repopulates the combo boxes used for variable selection
- with the updated list of variables.
- """
- if self.VariableList is not None:
- for index, combo_box in enumerate(self.combo_boxes):
- selected_index = self.selected_var_indices[index]
- current_selected_text = combo_box.currentText()
-
- combo_box.clear()
- combo_box.addItems(self.VariableList)
-
- if current_selected_text in self.VariableList:
- combo_box.setCurrentIndex(combo_box.findText(current_selected_text))
- else:
- combo_box.setCurrentIndex(selected_index)
-
- for combo in self.scope_var_combos:
- combo.clear()
- combo.addItems(self.VariableList)
- else:
- logging.warning("VariableList is None. Unable to refresh combo boxes.")
-
- def refresh_ports(self):
- """Refresh the list of available serial ports.
-
- This method updates the combo box containing the list of available
- serial ports to reflect the current state of the system.
- """
- available_ports = [port.device for port in serial.tools.list_ports.comports()]
- self.port_combo.clear()
- self.port_combo.addItems(available_ports)
-
- @pyqtSlot()
- def toggle_connection(self):
- """Handle the connection or disconnection of the serial port.
-
- This method establishes or terminates the serial connection based on
- the current state of the connection.
- """
- if self.file_path == "":
- QMessageBox.warning(self, "Error", "Please select an ELF file.")
- self.select_elf_file()
- return
-
- if self.ser is None or not self.ser.is_open:
- for timer in self.timer_list:
- if timer.isActive():
- timer.stop()
- self.plot_data.clear()
- self.connect_serial()
- else:
- self.disconnect_serial()
-
- def disconnect_serial(self):
- """Disconnect the current serial connection.
-
- This method safely terminates the existing serial connection, if any,
- and updates the UI to reflect the disconnection.
- """
- try:
- if self.ser is not None and self.ser.is_open:
- self.ser.stop()
- self.ser = None
-
- self.Connect_button.setText("Connect")
- self.Connect_button.setEnabled(True)
- self.select_file_button.setEnabled(True)
- widget_list = [self.port_combo, self.baud_combo]
-
- for widget in widget_list:
- widget.setEnabled(True)
-
- for combo_box in self.combo_boxes:
- combo_box.setEnabled(False)
-
- for live_var in self.live_checkboxes:
- live_var.setEnabled(False)
-
- self.slider_var1.setEnabled(False)
- for timer in self.timer_list:
- if timer.isActive():
- timer.stop()
-
- self.plot_update_timer.stop() # Stop the continuous plot update
-
- except Exception as e:
- error_message = f"Error while disconnecting: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def connect_serial(self):
- """Establish a serial connection based on the current UI settings.
-
- This method sets up a serial connection using the selected port and
- baud rate. It also initializes the variable factory and updates the
- UI to reflect the connection state.
- """
- try:
- if self.ser is not None and self.ser.is_open:
- self.disconnect_serial()
-
- port = self.port_combo.currentText()
- baud_rate = int(self.baud_combo.currentText())
-
- self.x2cscope = X2CScope(
- port=port, elf_file=self.file_path, baud_rate=baud_rate
- )
- self.ser = self.x2cscope.interface
- self.VariableList = self.x2cscope.list_variables()
- if self.VariableList:
- self.VariableList.insert(0, "None")
- else:
- return
- self.refresh_combo_box()
- logging.info("Serial Port Configuration:")
- logging.info(f"Port: {port}")
- logging.info(f"Baud Rate: {baud_rate}")
-
- self.Connect_button.setText("Disconnect")
- self.Connect_button.setEnabled(True)
-
- widget_list = [self.port_combo, self.baud_combo, self.select_file_button]
-
- for widget in widget_list:
- widget.setEnabled(False)
-
- for combo_box in self.combo_boxes:
- combo_box.setEnabled(True)
- self.slider_var1.setEnabled(True)
-
- for live_var in self.live_checkboxes:
- live_var.setEnabled(True)
-
- timer_list = []
- for i in range(len(self.live_checkboxes)):
- timer_list.append((self.live_checkboxes[i], self.timer_list[i]))
-
- for live_var, timer in timer_list:
- if live_var.isChecked():
- timer.start(self.timerValue)
-
- self.plot_update_timer.start(
- self.timerValue
- ) # Start the continuous plot update
-
- except Exception as e:
- error_message = f"Error while connecting: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def close_plot_window(self):
- """Close the plot window if it is open.
-
- This method stops the animation and closes the plot window, if it is open.
- """
- self.plot_window_open = False
-
- def close_event(self, event):
- """Handle the event when the main window is closed.
-
- Args:
- event: The close event.
-
- This method ensures that all resources are properly released and the
- application is closed cleanly.
- """
- if self.sampling_active:
- self.sampling_active = False
- if self.ser:
- self.disconnect_serial()
- event.accept()
-
- def start_sampling(self):
- """Start the sampling process."""
- try:
- if self.sampling_active:
- self.sampling_active = False
- self.scope_sample_button.setText("Sample")
- logging.info("Stopped sampling.")
- else:
- self.x2cscope.clear_all_scope_channel()
- for combo in self.scope_var_combos:
- variable_name = combo.currentText()
- if variable_name and variable_name != "None":
- variable = self.x2cscope.get_variable(variable_name)
- self.x2cscope.add_scope_channel(variable)
-
- self.x2cscope.set_sample_time(
- int(self.sample_time_factor.text())
- ) # set sample time factor
- self.sampling_active = True
- self.configure_trigger()
- self.scope_sample_button.setText("Stop")
- logging.info("Started sampling.")
- self.x2cscope.request_scope_data()
- self.sample_scope_data(
- single_shot=self.single_shot_checkbox.isChecked()
- )
- except Exception as e:
- error_message = f"Error starting sampling: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def configure_trigger(self):
- """Configure the trigger settings."""
- try:
- if self.triggerVariable is not None:
- variable_name = self.triggerVariable
- variable = self.x2cscope.get_variable(variable_name)
-
- # Handle empty string for trigger level and delay
- trigger_level_text = self.trigger_level_edit.text().strip()
- trigger_delay_text = self.trigger_delay_edit.text().strip()
-
- if not trigger_level_text:
- trigger_level = 0
- else:
- try:
- trigger_level = float(trigger_level_text)
- logging.debug(trigger_level)
- except ValueError:
- logging.error(
- f"Invalid trigger level value: {trigger_level_text}"
- )
- self.handle_error(
- f"Invalid trigger level value: {trigger_level_text}"
- )
- return
-
- if not trigger_delay_text:
- trigger_delay = 0
- else:
- try:
- trigger_delay = int(trigger_delay_text)
- except ValueError:
- logging.error(
- f"Invalid trigger delay value: {trigger_delay_text}"
- )
- self.handle_error(
- f"Invalid trigger delay value: {trigger_delay_text}"
- )
- return
-
- trigger_edge = (
- 0 if self.trigger_edge_combo.currentText() == "Rising" else 1
- )
- trigger_mode = (
- 0 if self.trigger_mode_combo.currentText() == "Auto" else 1
- )
-
- trigger_config = TriggerConfig(
- variable=variable,
- trigger_level=trigger_level,
- trigger_mode=trigger_mode,
- trigger_delay=trigger_delay,
- trigger_edge=trigger_edge,
- )
- self.x2cscope.set_scope_trigger(trigger_config)
- logging.info("Trigger configured.")
- except Exception as e:
- error_message = f"Error configuring trigger: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def sample_scope_data(self, single_shot=False):
- """Sample the scope data."""
- try:
- while self.sampling_active:
- if self.x2cscope.is_scope_data_ready():
- logging.info("Scope data is ready.")
-
- data_storage = {}
- for channel, data in self.x2cscope.get_scope_channel_data(
- valid_data=False
- ).items():
- data_storage[channel] = data
-
- self.scope_plot_widget.clear()
- for i, (channel, data) in enumerate(data_storage.items()):
- if self.scope_channel_checkboxes[
- i
- ].isChecked(): # Check if the channel is enabled
- time_values = np.array(
- [j * 0.001 for j in range(len(data))], dtype=float
- ) # milliseconds
- data_scaled = np.array(data, dtype=float)
- self.scope_plot_widget.plot(
- time_values,
- data_scaled,
- pen=pg.mkPen(color=self.plot_colors[i], width=2),
- name=f"Channel {channel}",
- )
-
- self.scope_plot_widget.setLabel("left", "Value")
- self.scope_plot_widget.setLabel("bottom", "Time", units="ms")
- self.scope_plot_widget.showGrid(x=True, y=True) # Enable grid lines
-
- if single_shot:
- break
-
- self.x2cscope.request_scope_data()
- logging.debug("Requested next scope data.")
-
- QApplication.processEvents() # Keep the GUI responsive
-
- time.sleep(0.1)
-
- self.sampling_active = False
- self.scope_sample_button.setText("Sample")
- logging.info("Data collection complete.")
- except Exception as e:
- error_message = f"Error sampling scope data: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
-
-if __name__ == "__main__":
- app = QApplication(sys.argv)
- ex = X2cscopeGui()
- ex.show()
- sys.exit(app.exec_())
diff --git a/pyx2cscope/gui/generic_gui/generic_gui.py b/pyx2cscope/gui/generic_gui/generic_gui.py
deleted file mode 100644
index 47ef675c..00000000
--- a/pyx2cscope/gui/generic_gui/generic_gui.py
+++ /dev/null
@@ -1,2345 +0,0 @@
-"""replicate of X2Cscpope."""
-
-import logging
-
-logging.basicConfig(level=logging.ERROR)
-import json
-import os
-import sys
-import time
-from collections import deque
-from datetime import datetime
-
-import matplotlib
-import numpy as np
-import pyqtgraph as pg # Added pyqtgraph for interactive plotting
-import serial.tools.list_ports # Import the serial module to fix the NameError
-from PyQt5 import QtCore, QtGui
-from PyQt5.QtCore import QFileInfo, QMutex, QRegExp, QSettings, Qt, QTimer, pyqtSlot
-from PyQt5.QtGui import QIcon, QRegExpValidator
-from PyQt5.QtWidgets import (
- QApplication,
- QCheckBox,
- QComboBox,
- QDialog,
- QDialogButtonBox,
- QFileDialog,
- QGridLayout,
- QGroupBox,
- QHBoxLayout,
- QLabel,
- QLineEdit,
- QListWidget,
- QMainWindow,
- QMessageBox,
- QPushButton,
- QScrollArea,
- QSizePolicy,
- QSlider,
- QStyleFactory,
- QTabWidget,
- QVBoxLayout,
- QWidget,
-)
-
-from pyx2cscope.gui import img as img_src
-from pyx2cscope.x2cscope import TriggerConfig, X2CScope
-
-logging.basicConfig(level=logging.DEBUG)
-
-matplotlib.use("QtAgg") # This sets the backend to Qt for Matplotlib
-
-
-class VariableSelectionDialog(QDialog):
- """Initialize the variable selection dialog.
-
- Args:
- variables (list): A list of available variables to select from.
- parent (QWidget): The parent widget.
- """
-
- def __init__(self, variables, parent=None):
- """Set up the user interface components for the variable selection dialog."""
- super().__init__(parent)
- self.variables = variables
- self.selected_variable = None
-
- self.init_ui()
-
- def init_ui(self):
- """Initializing UI component."""
- self.setWindowTitle("Search Variable")
- self.setMinimumSize(300, 400)
-
- self.layout = QVBoxLayout()
-
- self.search_bar = QLineEdit(self)
- self.search_bar.setPlaceholderText("Search...")
- self.search_bar.textChanged.connect(self.filter_variables)
- self.layout.addWidget(self.search_bar)
-
- self.variable_list = QListWidget(self)
- self.variable_list.addItems(self.variables)
- self.variable_list.itemDoubleClicked.connect(self.accept_selection)
- self.layout.addWidget(self.variable_list)
-
- self.button_box = QDialogButtonBox(
- QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self
- )
- self.button_box.accepted.connect(self.accept_selection)
- self.button_box.rejected.connect(self.reject)
- self.layout.addWidget(self.button_box)
-
- self.setLayout(self.layout)
-
- def filter_variables(self, text):
- """Filter the variables based on user input in the search bar.
-
- Args:
- text (str): The input text to filter variables.
- """
- self.variable_list.clear()
- filtered_variables = [
- var for var in self.variables if text.lower() in var.lower()
- ]
- self.variable_list.addItems(filtered_variables)
-
- def accept_selection(self):
- """Accept the selection when a variable is chosen from the list.
-
- The selected variable is set as the `selected_variable` and the dialog is accepted.
- """
- selected_items = self.variable_list.selectedItems()
- if selected_items:
- self.selected_variable = selected_items[0].text()
- self.accept()
-
-
-class X2cscopeGui(QMainWindow):
- """Main GUI class for the pyX2Cscope application."""
-
- def __init__(self, *args, **kwargs):
- """Initializing all the elements required."""
- super().__init__()
- self.x2cscope = None # Ensures it is initialized to None at start
- self.x2cscope_initialized = (
- False # Flag to ensure error message is shown only once
- )
- self.last_error_time = (
- None # Attribute to track the last time an error was shown
- )
- self.triggerVariable = None
- self.elf_file_loaded = False
- self.config_file_loaded = False
- self.device_info_labels = {} # Dictionary to hold the device information labels
- self.initialize_variables()
- self.init_ui()
-
- def initialize_variables(self):
- """Initialize instance variables."""
- self.timeout = 5
- self.sampling_active = False
- self.scaling_edits_tab3 = [] # Track scaling fields for Tab 3
- self.offset_edits_tab3 = [] # Track offset fields for Tab 3
- self.scaled_value_edits_tab3 = [] # Track scaled value fields for Tab 3
- self.offset_boxes = None
- self.plot_checkboxes = None
- self.scaled_value_boxes = None
- self.scaling_boxes = None
- self.Value_var_boxes = None
- self.line_edit_boxes = None
- self.live_checkboxes = None
- self.timer_list = None
- self.VariableList = []
- self.old_Variable_list = []
- self.var_factory = None
- self.ser = None
- self.timerValue = 500
- self.port_combo = QComboBox()
- self.layout = None
- self.slider_var1 = QSlider(Qt.Horizontal)
- self.plot_button = QPushButton("Plot")
- self.mutex = QMutex()
- self.grid_layout = QGridLayout()
- self.box_layout = QHBoxLayout()
- self.timer1 = QTimer()
- self.timer2 = QTimer()
- self.timer3 = QTimer()
- self.timer4 = QTimer()
- self.timer5 = QTimer()
- self.plot_update_timer = QTimer() # Timer for continuous plot update
- self.timer()
- self.offset_var()
- self.plot_var_check()
- self.scaling_var()
- self.value_var()
- self.live_var()
- self.scaled_value()
- self.line_edit()
- self.sampletime = QLineEdit()
- self.unit_var()
- self.Connect_button = QPushButton("Connect")
- self.baud_combo = QComboBox()
- self.select_file_button = QPushButton("Select elf file")
- self.error_shown = False
- self.plot_window_open = False
- self.settings = QSettings("MyCompany", "MyApp")
- self.file_path: str = self.settings.value("file_path", "", type=str)
- self.initi_variables()
-
- def initi_variables(self):
- """Some variable initialisation."""
- self.device_info_labels = {
- "processor_id": QLabel("Loading Processor ID ..."),
- "uc_width": QLabel("Loading UC Width..."),
- "date": QLabel("Loading Date..."),
- "time": QLabel("Loading Time..."),
- "appVer": QLabel("Loading App Version..."),
- "dsp_state": QLabel("Loading DSP State..."),
- }
- self.selected_var_indices = [
- 0,
- 0,
- 0,
- 0,
- 0,
- ] # List to store selected variable indices
- self.selected_variables = [] # List to store selected variables
- self.previous_selected_variables = {} # Dictionary to store previous selections
- decimal_regex = QRegExp("-?[0-9]+(\\.[0-9]+)?")
- self.decimal_validator = QRegExpValidator(decimal_regex)
-
- self.plot_data = deque(maxlen=250) # Store plot data for all variables
- self.plot_colors = [
- "b",
- "g",
- "r",
- "c",
- "m",
- "y",
- "k",
- ] # colours for different plot
- # Add self.labels on top
- self.labels = [
- "Live",
- "Variable",
- "Value",
- "Scaling",
- "Offset",
- "Scaled Value",
- "Unit",
- "Plot",
- ]
-
- def init_ui(self):
- """Initialize the user interface."""
- self.setup_application_style()
- self.create_central_widget()
- self.create_tabs()
- self.setup_tabs()
- self.setup_window_properties()
- # self.setup_device_info_ui() # Set up the device information section
- self.refresh_ports()
-
- def setup_application_style(self):
- """Set the application style."""
- QApplication.setStyle(QStyleFactory.create("Fusion"))
-
- def create_central_widget(self):
- """Create the central widget."""
- central_widget = QWidget(self)
- self.setCentralWidget(central_widget)
- self.layout = QVBoxLayout(central_widget)
- self.tab_widget = QTabWidget()
- self.layout.addWidget(self.tab_widget)
-
- def update_device_info(self):
- """Fetch device information from the connected device and update the labels."""
- try:
- device_info = self.x2cscope.get_device_info()
- self.device_info_labels["processor_id"].setText(
- f" {device_info['processor_id']}"
- )
- self.device_info_labels["uc_width"].setText(f"{device_info['uc_width']}")
- self.device_info_labels["date"].setText(f"{device_info['date']}")
- self.device_info_labels["time"].setText(f"{device_info['time']}")
- self.device_info_labels["appVer"].setText(f"{device_info['AppVer']}")
- self.device_info_labels["dsp_state"].setText(f"{device_info['dsp_state']}")
- except Exception as e:
- self.handle_error(f"Error fetching device info: {e}")
-
- def create_tabs(self):
- """Create tabs for the main window."""
- self.tab1 = QWidget()
- self.tab2 = QWidget()
- self.tab3 = QWidget() # New tab for WatchView Only
- self.tab_widget.addTab(self.tab1, "WatchPlot")
- self.tab_widget.addTab(self.tab2, "ScopeView")
- self.tab_widget.addTab(self.tab3, "WatchView") # Add third tab
-
- def setup_tabs(self):
- """Set up the contents of each tab."""
- self.setup_tab1()
- self.setup_tab2()
- self.setup_tab3() # Setup for the third tab
-
- def setup_window_properties(self):
- """Set up the main window properties."""
- self.setWindowTitle("pyX2Cscope")
- mchp_img = os.path.join(os.path.dirname(img_src.__file__), "pyx2cscope.jpg")
- self.setWindowIcon(QtGui.QIcon(mchp_img))
-
- def line_edit(self):
- """Initializing line edits."""
- self.line_edit5 = QLineEdit()
- self.line_edit4 = QLineEdit()
- self.line_edit3 = QLineEdit()
- self.line_edit2 = QLineEdit()
- self.line_edit1 = QLineEdit()
-
- for line_edit in [
- self.line_edit1,
- self.line_edit2,
- self.line_edit3,
- self.line_edit4,
- self.line_edit5,
- ]:
- line_edit.setReadOnly(True)
- line_edit.setPlaceholderText("Search Variable")
- line_edit.installEventFilter(self)
-
- def scaled_value(self):
- """Initializing Scaled variable."""
- self.ScaledValue_var1 = QLineEdit(self)
- self.ScaledValue_var2 = QLineEdit(self)
- self.ScaledValue_var3 = QLineEdit(self)
- self.ScaledValue_var4 = QLineEdit(self)
- self.ScaledValue_var5 = QLineEdit(self)
-
- def live_var(self):
- """Initializing live variable."""
- self.Live_var1 = QCheckBox(self)
- self.Live_var2 = QCheckBox(self)
- self.Live_var3 = QCheckBox(self)
- self.Live_var4 = QCheckBox(self)
- self.Live_var5 = QCheckBox(self)
-
- def value_var(self):
- """Initializing value variable."""
- self.Value_var1 = QLineEdit(self)
- self.Value_var2 = QLineEdit(self)
- self.Value_var3 = QLineEdit(self)
- self.Value_var4 = QLineEdit(self)
- self.Value_var5 = QLineEdit(self)
-
- def timer(self):
- """Initializing timers."""
- self.timers = [QTimer() for _ in range(5)]
-
- def offset_var(self):
- """Initializing Offset Variable."""
- self.offset_var1 = QLineEdit()
- self.offset_var2 = QLineEdit()
- self.offset_var3 = QLineEdit()
- self.offset_var4 = QLineEdit()
- self.offset_var5 = QLineEdit()
-
- def plot_var_check(self):
- """Initializing plot variable check boxes."""
- self.plot_var5_checkbox = QCheckBox()
- self.plot_var2_checkbox = QCheckBox()
- self.plot_var4_checkbox = QCheckBox()
- self.plot_var3_checkbox = QCheckBox()
- self.plot_var1_checkbox = QCheckBox()
-
- def scaling_var(self):
- """Initializing Scaling variable."""
- self.Scaling_var1 = QLineEdit(self)
- self.Scaling_var2 = QLineEdit(self)
- self.Scaling_var3 = QLineEdit(self)
- self.Scaling_var4 = QLineEdit(self)
- self.Scaling_var5 = QLineEdit(self)
-
- def unit_var(self):
- """Initializing unit variable."""
- self.Unit_var1 = QLineEdit(self)
- self.Unit_var2 = QLineEdit(self)
- self.Unit_var3 = QLineEdit(self)
- self.Unit_var4 = QLineEdit(self)
- self.Unit_var5 = QLineEdit(self)
-
- # noinspection PyUnresolvedReferences
- def setup_tab1(self):
- """Set up the first tab with the original functionality."""
- self.tab1.layout = QVBoxLayout()
- self.tab1.setLayout(self.tab1.layout)
-
- grid_layout = QGridLayout()
- self.tab1.layout.addLayout(grid_layout)
-
- self.setup_port_layout(grid_layout)
- # self.setup_baud_layout(grid_layout)
- self.setup_sampletime_layout(grid_layout)
- self.setup_variable_layout(grid_layout)
- self.setup_connections()
-
- # Add Save and Load buttons
- self.save_button_watch = QPushButton("Save Config")
- self.load_button_watch = QPushButton("Load Config")
- self.save_button_watch.setFixedSize(100, 30)
- self.load_button_watch.setFixedSize(100, 30)
- self.save_button_watch.clicked.connect(self.save_config)
- self.load_button_watch.clicked.connect(self.load_config)
-
- button_layout = QHBoxLayout()
- button_layout.addWidget(self.save_button_watch)
- button_layout.addWidget(self.load_button_watch)
- self.tab1.layout.addLayout(button_layout)
-
- def setup_tab2(self):
- """Set up the second tab with the scope functionality."""
- self.tab2.layout = QVBoxLayout()
- self.tab2.setLayout(self.tab2.layout)
-
- main_grid_layout = QGridLayout()
- self.tab2.layout.addLayout(main_grid_layout)
-
- # Set up individual components
- trigger_group = self.create_trigger_configuration_group()
- variable_group = self.create_variable_selection_group()
- self.scope_plot_widget = self.create_scope_plot_widget()
- button_layout = self.create_save_load_buttons()
-
- # Add the group boxes to the main layout with stretch factors
- main_grid_layout.addWidget(trigger_group, 0, 0)
- main_grid_layout.addWidget(variable_group, 0, 1)
-
- # Set the column stretch factors to make the variable group larger
- main_grid_layout.setColumnStretch(0, 1) # Trigger configuration box
- main_grid_layout.setColumnStretch(1, 3) # Variable selection box
-
- # Add the plot widget for scope view
- self.tab2.layout.addWidget(self.scope_plot_widget)
-
- # Add Save and Load buttons
- self.tab2.layout.addLayout(button_layout)
-
- def create_trigger_configuration_group(self):
- """Create the trigger configuration group box."""
- trigger_group = QGroupBox("Trigger Configuration")
- trigger_layout = QVBoxLayout()
- trigger_group.setLayout(trigger_layout)
-
- grid_layout_trigger = QGridLayout()
- trigger_layout.addLayout(grid_layout_trigger)
-
- self.single_shot_checkbox = QCheckBox("Single Shot")
- self.sample_time_factor = QLineEdit("1")
- self.sample_time_factor.setValidator(self.decimal_validator)
- self.trigger_mode_combo = QComboBox()
- self.trigger_mode_combo.addItems(["Auto", "Triggered"])
- self.trigger_edge_combo = QComboBox()
- self.trigger_edge_combo.addItems(["Rising", "Falling"])
- self.trigger_level_edit = QLineEdit("0")
- self.trigger_level_edit.setValidator(self.decimal_validator)
- self.trigger_delay_edit = QLineEdit("0")
- self.trigger_delay_edit.setValidator(self.decimal_validator)
-
- self.scope_sampletime_edit = QLineEdit("50") # Default sample time in microseconds
- self.scope_sampletime_edit.setValidator(self.decimal_validator)
-
- # Total Time
- self.total_time_label = QLabel("Total Time (ms):")
- self.total_time_value = QLineEdit("0")
- self.total_time_value.setReadOnly(True)
-
- self.scope_sample_button = QPushButton("Sample")
- self.scope_sample_button.setFixedSize(100, 30)
- self.scope_sample_button.clicked.connect(self.start_sampling)
-
- # Arrange widgets in grid layout
- grid_layout_trigger.addWidget(self.single_shot_checkbox, 0, 0, 1, 2)
- grid_layout_trigger.addWidget(QLabel("Sample Time Factor"), 1, 0)
- grid_layout_trigger.addWidget(self.sample_time_factor, 1, 1)
- grid_layout_trigger.addWidget(QLabel("Scope Sample Time (µs):"), 2, 0)
- grid_layout_trigger.addWidget(self.scope_sampletime_edit, 2, 1)
- grid_layout_trigger.addWidget(self.total_time_label, 3, 0)
- grid_layout_trigger.addWidget(self.total_time_value, 3, 1)
- grid_layout_trigger.addWidget(QLabel("Trigger Mode:"), 4, 0)
- grid_layout_trigger.addWidget(self.trigger_mode_combo, 4, 1)
- grid_layout_trigger.addWidget(QLabel("Trigger Edge:"), 5, 0)
- grid_layout_trigger.addWidget(self.trigger_edge_combo, 5, 1)
- grid_layout_trigger.addWidget(QLabel("Trigger Level:"), 6, 0)
- grid_layout_trigger.addWidget(self.trigger_level_edit, 6, 1)
- grid_layout_trigger.addWidget(QLabel("Trigger Delay:"), 7, 0)
- grid_layout_trigger.addWidget(self.trigger_delay_edit, 7, 1)
- grid_layout_trigger.addWidget(self.scope_sample_button, 8, 0, 1, 2)
-
- return trigger_group
-
- def create_variable_selection_group(self):
- """Create the variable selection group box."""
- variable_group = QGroupBox("Variable Selection")
- variable_layout = QVBoxLayout()
- variable_group.setLayout(variable_layout)
-
- grid_layout_variable = QGridLayout()
- variable_layout.addLayout(grid_layout_variable)
- number_of_variables = 8
- self.scope_var_lines = [QLineEdit() for _ in range(number_of_variables)]
- self.trigger_var_checkbox = [QCheckBox() for _ in range(number_of_variables)]
- self.scope_channel_checkboxes = [QCheckBox() for _ in range(number_of_variables)]
- self.scope_scaling_boxes = [QLineEdit("1") for _ in range(number_of_variables)]
-
- for checkbox in self.scope_channel_checkboxes:
- checkbox.setChecked(True)
-
- for line_edit in self.scope_var_lines:
- line_edit.setReadOnly(True)
- line_edit.setPlaceholderText("Search Variable")
- line_edit.installEventFilter(self)
-
- # Add "Search Variable" label
- grid_layout_variable.addWidget(QLabel("Search Variable"), 0, 1)
- grid_layout_variable.addWidget(QLabel("Trigger"), 0, 0)
- grid_layout_variable.addWidget(QLabel("Gain"), 0, 2)
- grid_layout_variable.addWidget(QLabel("Visible"), 0, 3)
-
- for i, (line_edit, trigger_checkbox, scale_box, show_checkbox) in enumerate(
- zip(
- self.scope_var_lines,
- self.trigger_var_checkbox,
- self.scope_scaling_boxes,
- self.scope_channel_checkboxes,
- )
- ):
- line_edit.setMinimumHeight(20)
- trigger_checkbox.setMinimumHeight(20)
- show_checkbox.setMinimumHeight(20)
- scale_box.setMinimumHeight(20)
- scale_box.setFixedSize(50, 20)
- scale_box.setValidator(self.decimal_validator)
-
- line_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
- grid_layout_variable.addWidget(trigger_checkbox, i + 1, 0)
- grid_layout_variable.addWidget(line_edit, i + 1, 1)
- grid_layout_variable.addWidget(scale_box, i + 1, 2)
- grid_layout_variable.addWidget(show_checkbox, i + 1, 3)
-
- trigger_checkbox.stateChanged.connect(
- lambda state, x=i: self.handle_scope_checkbox_change(state, x)
- )
- scale_box.editingFinished.connect(self.update_scope_plot)
- show_checkbox.stateChanged.connect(self.update_scope_plot)
-
- return variable_group
-
- def create_scope_plot_widget(self):
- """Create the scope plot widget."""
- scope_plot_widget = pg.PlotWidget(title="Scope Plot")
- scope_plot_widget.setBackground("w")
- scope_plot_widget.addLegend()
- scope_plot_widget.showGrid(x=True, y=True)
- scope_plot_widget.getViewBox().setMouseMode(pg.ViewBox.RectMode)
-
- return scope_plot_widget
-
- def create_save_load_buttons(self):
- """Create the save and load buttons."""
- self.save_button_scope = QPushButton("Save Config")
- self.load_button_scope = QPushButton("Load Config")
- self.save_button_scope.setFixedSize(100, 30)
- self.load_button_scope.setFixedSize(100, 30)
- self.save_button_scope.clicked.connect(self.save_config)
- self.load_button_scope.clicked.connect(self.load_config)
-
- button_layout = QHBoxLayout()
- button_layout.addWidget(self.save_button_scope)
- button_layout.addWidget(self.load_button_scope)
-
- return button_layout
-
- def handle_scope_checkbox_change(self, state, index):
- """Handle the change in the state of the scope view checkboxes."""
- if state == Qt.Checked:
- for i, checkbox in enumerate(self.trigger_var_checkbox):
- if i != index:
- checkbox.setChecked(False)
- self.triggerVariable = self.scope_var_lines[index].text()
- logging.debug(f"Checked variable: {self.scope_var_lines[index].text()}")
- else:
- self.triggerVariable = None
-
- def setup_port_layout(self, layout):
- """Set up the port selection, baud rate, and device information layout in two sections."""
- # Create the left layout for device information (QVBoxLayout)
- left_layout = QGridLayout()
-
- # Add device information labels to the left side
- for label_key, label in self.device_info_labels.items():
- info_label = QLabel(label_key.replace("_", " ").capitalize() + ":")
- info_label.setAlignment(Qt.AlignLeft)
-
- row = list(self.device_info_labels.keys()).index(
- label_key
- ) # Get the row index
- device_info_layout = (
- QGridLayout()
- ) # Create a row layout for label and its value
- device_info_layout.addWidget(info_label, row, 0, Qt.AlignRight)
- device_info_layout.addWidget(label, row, 1, alignment=Qt.AlignRight)
- left_layout.addLayout(device_info_layout, row, 0, Qt.AlignLeft)
-
- # Create the right layout for COM port and settings (QGridLayout)
- right_layout = QGridLayout()
-
- # COM Port Selection
- port_label = QLabel("Select Port:")
- self.port_combo.setFixedSize(100, 25) # Set fixed size for the port combo box
- refresh_button = QPushButton()
- refresh_button.setFixedSize(25, 25)
- refresh_button.clicked.connect(self.refresh_ports)
- refresh_img = os.path.join(os.path.dirname(img_src.__file__), "refresh.png")
- refresh_button.setIcon(QIcon(refresh_img))
-
- # Add COM Port widgets to the right layout
- right_layout.addWidget(port_label, 0, 0, alignment=Qt.AlignRight)
- right_layout.addWidget(self.port_combo, 0, 1)
- right_layout.addWidget(refresh_button, 0, 2)
-
- # Baud Rate Selection
- baud_label = QLabel("Select Baud Rate:")
- self.baud_combo.setFixedSize(100, 25)
- self.baud_combo.addItems(["38400", "115200", "230400", "460800", "921600"])
- default_baud_rate = "115200"
- index = self.baud_combo.findText(default_baud_rate, Qt.MatchFixedString)
- if index >= 0:
- self.baud_combo.setCurrentIndex(index)
-
- # Add Baud Rate widgets to the right layout
- right_layout.addWidget(baud_label, 1, 0, alignment=Qt.AlignRight)
- right_layout.addWidget(self.baud_combo, 1, 1)
-
- # Add Connect button and Sample time
- self.Connect_button.setFixedSize(100, 30)
- self.Connect_button.clicked.connect(self.toggle_connection)
- sampletime_label = QLabel("Sample Time WatchPlot:")
- self.sampletime.setFixedSize(100, 30)
- self.sampletime.setText("500")
-
- # Add Connect and Sample Time widgets to the right layout
- self.sampletime.setValidator(self.decimal_validator)
- self.sampletime.editingFinished.connect(self.sampletime_edit)
- right_layout.addWidget(sampletime_label, 2, 0, alignment=Qt.AlignRight)
- right_layout.addWidget(self.sampletime, 2, 1, alignment=Qt.AlignLeft)
- right_layout.addWidget(QLabel("ms"), 2, 2, alignment=Qt.AlignLeft)
- right_layout.addWidget(self.Connect_button, 3, 1, alignment=Qt.AlignBottom)
-
- # Create a horizontal layout to contain both left and right sections
- horizontal_layout = QHBoxLayout()
-
- # Add left (device information) and right (settings) layouts to the horizontal layout
- horizontal_layout.addLayout(left_layout)
- horizontal_layout.addLayout(right_layout)
-
- # Finally, add the horizontal layout to the grid at a specific row and column
- layout.addLayout(horizontal_layout, 0, 0, 1, 2) # Span 1 row and 2 columns
-
- def setup_sampletime_layout(self, layout):
- """Set up the sample time layout."""
- # self.Connect_button.clicked.connect(self.toggle_connection)
- self.select_file_button.clicked.connect(self.select_elf_file)
- layout.addWidget(self.select_file_button, 4, 0)
-
- def setup_variable_layout(self, layout):
- """Set up the variable selection layout."""
- self.timer_list = [
- self.timer1,
- self.timer2,
- self.timer3,
- self.timer4,
- self.timer5,
- ]
-
- for col, label in enumerate(self.labels):
- self.grid_layout.addWidget(QLabel(label), 0, col)
-
- self.live_checkboxes = [
- self.Live_var1,
- self.Live_var2,
- self.Live_var3,
- self.Live_var4,
- self.Live_var5,
- ]
- self.line_edit_boxes = [
- self.line_edit1,
- self.line_edit2,
- self.line_edit3,
- self.line_edit4,
- self.line_edit5,
- ]
- self.Value_var_boxes = [
- self.Value_var1,
- self.Value_var2,
- self.Value_var3,
- self.Value_var4,
- self.Value_var5,
- ]
- self.scaling_boxes = [
- self.Scaling_var1,
- self.Scaling_var2,
- self.Scaling_var3,
- self.Scaling_var4,
- self.Scaling_var5,
- ]
- self.scaled_value_boxes = [
- self.ScaledValue_var1,
- self.ScaledValue_var2,
- self.ScaledValue_var3,
- self.ScaledValue_var4,
- self.ScaledValue_var5,
- ]
- unit_boxes = [
- self.Unit_var1,
- self.Unit_var2,
- self.Unit_var3,
- self.Unit_var4,
- self.Unit_var5,
- ]
- self.plot_checkboxes = [
- self.plot_var1_checkbox,
- self.plot_var2_checkbox,
- self.plot_var3_checkbox,
- self.plot_var4_checkbox,
- self.plot_var5_checkbox,
- ]
- self.offset_boxes = [
- self.offset_var1,
- self.offset_var2,
- self.offset_var3,
- self.offset_var4,
- self.offset_var5,
- ]
-
- for row_index, (
- live_var,
- line_edit,
- value_var,
- scaling_var,
- offset_var,
- scaled_value_var,
- unit_var,
- plot_checkbox,
- ) in enumerate(
- zip(
- self.live_checkboxes,
- self.line_edit_boxes,
- self.Value_var_boxes,
- self.scaling_boxes,
- self.offset_boxes,
- self.scaled_value_boxes,
- unit_boxes,
- self.plot_checkboxes,
- ),
- 1,
- ):
- live_var.setEnabled(False)
- line_edit.setEnabled(False)
-
- # Set size policy for variable name (line_edit) and value field to expand
- line_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
- value_var.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
-
- value_var.setText("0")
- value_var.setValidator(self.decimal_validator)
- scaling_var.setText("1")
- offset_var.setText("0")
- offset_var.setValidator(self.decimal_validator)
- scaled_value_var.setText("0")
- scaled_value_var.setValidator(self.decimal_validator)
- display_row = row_index # Use a different variable name for the assignment
- if display_row > 1:
- display_row += 1
- self.grid_layout.addWidget(live_var, display_row, 0)
- self.grid_layout.addWidget(line_edit, display_row, 1)
- if display_row == 1:
- self.grid_layout.addWidget(self.slider_var1, display_row + 1, 0, 1, 7)
-
- self.grid_layout.addWidget(value_var, display_row, 2)
- self.grid_layout.addWidget(scaling_var, display_row, 3)
- self.grid_layout.addWidget(offset_var, display_row, 4)
- self.grid_layout.addWidget(scaled_value_var, display_row, 5)
- self.grid_layout.addWidget(unit_var, display_row, 6)
- self.grid_layout.addWidget(plot_checkbox, display_row, 7)
- plot_checkbox.stateChanged.connect(
- lambda state, x=row_index - 1: self.update_watch_plot()
- )
-
- layout.addLayout(self.grid_layout, 5, 0)
-
- # Adjust the column stretch factors to ensure proper resizing
- self.grid_layout.setColumnStretch(1, 5) # Variable column expands more
- self.grid_layout.setColumnStretch(2, 2) # Value column
- self.grid_layout.setColumnStretch(3, 1) # Scaling column
- self.grid_layout.setColumnStretch(4, 1) # Offset column
- self.grid_layout.setColumnStretch(5, 2) # Scaled Value column
- self.grid_layout.setColumnStretch(6, 1) # Unit column
-
- # Add the plot widget for watch view
- self.watch_plot_widget = pg.PlotWidget(title="Watch Plot")
- self.watch_plot_widget.setBackground("w")
- self.watch_plot_widget.addLegend() # Add legend to the plot widget
- self.watch_plot_widget.showGrid(x=True, y=True) # Enable grid lines
- # Change to 1-button zoom mode
- self.watch_plot_widget.getViewBox().setMouseMode(pg.ViewBox.RectMode)
- self.tab1.layout.addWidget(self.watch_plot_widget)
-
- def setup_connections(self):
- """Set up connections for various widgets."""
- self.plot_button.clicked.connect(self.plot_data_plot)
-
- for timer, line_edit, value_var in zip(
- self.timer_list, self.line_edit_boxes, self.Value_var_boxes
- ):
- timer.timeout.connect(
- lambda cb=line_edit, v_var=value_var: self.handle_var_update(
- cb.text(), v_var
- )
- )
-
- for line_edit, value_var in zip(self.line_edit_boxes, self.Value_var_boxes):
- value_var.editingFinished.connect(
- lambda cb=line_edit, v_var=value_var: self.handle_variable_putram(
- cb.text(), v_var
- )
- )
-
- self.connect_editing_finished()
-
- for timer, live_var in zip(self.timer_list, self.live_checkboxes):
- live_var.stateChanged.connect(
- lambda state, lv=live_var, tm=timer: self.var_live(lv, tm)
- )
-
- self.slider_var1.setMinimum(-32768)
- self.slider_var1.setMaximum(32767)
- self.slider_var1.setEnabled(False)
- self.slider_var1.valueChanged.connect(self.slider_var1_changed)
-
- self.plot_update_timer.timeout.connect(
- self.update_watch_plot
- ) # Connect the QTimer to the update method
-
- def connect_editing_finished(self):
- """Connect editingFinished signals for value and scaling inputs."""
- for (
- scaling,
- value_var,
- scaled_value,
- offset,
- ) in zip(
- self.scaling_boxes,
- self.Value_var_boxes,
- self.scaled_value_boxes,
- self.offset_boxes,
- ):
-
- def connect_editing_finished(
- sc_edit=scaling,
- v_edit=value_var,
- scd_edit=scaled_value,
- off_edit=offset,
- ):
- def on_editing_finished():
- self.update_scaled_value(sc_edit, v_edit, scd_edit, off_edit)
-
- return on_editing_finished
-
- value_var.editingFinished.connect(connect_editing_finished())
-
- for (
- scaling,
- value_var,
- scaled_value,
- offset,
- ) in zip(
- self.scaling_boxes,
- self.Value_var_boxes,
- self.scaled_value_boxes,
- self.offset_boxes,
- ):
-
- def connect_editing_finished(
- sc_edit=scaling,
- v_edit=value_var,
- scd_edit=scaled_value,
- off_edit=offset,
- ):
- def on_editing_finished():
- self.update_scaled_value(sc_edit, v_edit, scd_edit, off_edit)
-
- return on_editing_finished
-
- scaling.editingFinished.connect(connect_editing_finished())
-
- for (
- scaling,
- value_var,
- scaled_value,
- offset,
- ) in zip(
- self.scaling_boxes,
- self.Value_var_boxes,
- self.scaled_value_boxes,
- self.offset_boxes,
- ):
-
- def connect_text_changed(
- sc_edit=scaling,
- v_edit=value_var,
- scd_edit=scaled_value,
- off_edit=offset,
- ):
- def on_text_changed():
- self.update_scaled_value(sc_edit, v_edit, scd_edit, off_edit)
-
- return on_text_changed
-
- value_var.textChanged.connect(connect_text_changed())
-
- for (
- scaling,
- value_var,
- scaled_value,
- offset,
- ) in zip(
- self.scaling_boxes,
- self.Value_var_boxes,
- self.scaled_value_boxes,
- self.offset_boxes,
- ):
-
- def connect_text_changed(
- sc_edit=scaling,
- v_edit=value_var,
- scd_edit=scaled_value,
- off_edit=offset,
- ):
- def on_text_changed():
- self.update_scaled_value(sc_edit, v_edit, scd_edit, off_edit)
-
- return on_text_changed
-
- offset.editingFinished.connect(connect_text_changed())
-
- @pyqtSlot()
- def var_live(self, live_var, timer):
- """Handles the state change of live variable checkboxes.
-
- Args:
- live_var (QCheckBox): The checkbox representing a live variable.
- timer (QTimer): The timer associated with the live variable.
- """
- try:
- if live_var.isChecked():
- if not timer.isActive():
- timer.start(self.timerValue)
- elif timer.isActive():
- timer.stop()
- except Exception as e:
- logging.error(e)
- self.handle_error(f"Live Variable: {e}")
-
- @pyqtSlot()
- def update_scaled_value(self, scaling_var, value_var, scaled_value_var, offset_var):
- """Updates the scaled value based on the provided scaling factor and offset.
-
- Args:
- scaling_var : Input field for the scaling factor.
- value_var : Input field for the raw value.
- scaled_value_var : Input field for the scaled value.
- offset_var : Input field for the offset.
- """
- scaling_text = scaling_var.text()
- value_text = value_var.text()
- offset_text = offset_var.text()
- try:
- value = float(value_text)
- if offset_text.startswith("-"):
- float_offset = float(offset_text.lstrip("-"))
- offset = -1 * float_offset
- else:
- offset = float(offset_text)
- if scaling_text.startswith("-"):
- float_scaling = float(scaling_text.lstrip("-"))
- scaling = -1 * float_scaling
- else:
- scaling = float(scaling_text)
- scaled_value = (scaling * value) + offset
- scaled_value_var.setText("{:.2f}".format(scaled_value))
- except Exception as e:
- logging.error(e)
- self.handle_error(f"Error update Scaled Value: {e}")
-
- def plot_data_update(self):
- """Updates the data for plotting."""
- try:
- timestamp = datetime.now()
- if len(self.plot_data) > 0:
- last_timestamp = self.plot_data[-1][0]
- time_diff = (
- timestamp - last_timestamp
- ).total_seconds() * 1000 # to convert time in ms.
- else:
- time_diff = 0
-
- def safe_float(value):
- try:
- return float(value)
- except ValueError:
- return 0.0
-
- self.plot_data.append(
- (
- timestamp,
- time_diff,
- safe_float(self.ScaledValue_var1.text()),
- safe_float(self.ScaledValue_var2.text()),
- safe_float(self.ScaledValue_var3.text()),
- safe_float(self.ScaledValue_var4.text()),
- safe_float(self.ScaledValue_var5.text()),
- )
- )
- except Exception as e:
- logging.error(e)
-
- def update_watch_plot(self):
- """Updates the plot in the WatchView tab with new data."""
- try:
- if not self.plot_data:
- return
-
- # Clear the plot and remove old labels
- self.watch_plot_widget.clear()
-
- data = np.array(self.plot_data, dtype=object).T
- time_diffs = np.array(data[1], dtype=float)
- values = [np.array(data[i], dtype=float) for i in range(2, 7)]
-
- # Keep track of plot lines to avoid clearing and recreating them unnecessarily
- for i, (value, line_edit, plot_var) in enumerate(
- zip(values, self.line_edit_boxes, self.plot_checkboxes)
- ):
- # Check if the variable should be plotted and is not empty
- if plot_var.isChecked() and line_edit.text() != "":
- self.watch_plot_widget.plot(
- np.cumsum(time_diffs),
- value,
- pen=pg.mkPen(
- color=self.plot_colors[i], width=2
- ), # Thicker plot line
- name=line_edit.text(),
- )
-
- # Reset plot labels
- self.watch_plot_widget.setLabel("left", "Value")
- self.watch_plot_widget.setLabel("bottom", "Time", units="ms")
- self.watch_plot_widget.showGrid(x=True, y=True) # Enable grid lines
- except Exception as e:
- logging.error(e)
-
- def update_scope_plot(self):
- """Updates the plot in the ScopeView tab with new data and scaling."""
- try:
- if not self.sampling_active:
- return
-
- if not self.x2cscope.is_scope_data_ready():
- return
-
- data_storage = {}
- for channel, data in self.x2cscope.get_scope_channel_data(valid_data=True).items():
- data_storage[channel] = data
-
- self.scope_plot_widget.clear()
-
- for i, (channel, data) in enumerate(data_storage.items()):
- checkbox_state = self.scope_channel_checkboxes[i].isChecked()
- logging.debug(
- f"Channel {channel}: Checkbox is {'checked' if checkbox_state else 'unchecked'}"
- )
- if checkbox_state: # Check if the checkbox is checked
- scale_factor = float(
- self.scope_scaling_boxes[i].text()
- ) # Get the scaling factor
- # time_values = self.real_sampletime # Generate time values in ms
- # start = self.real_sampletime / len(data)
- start = 0
- time_values = np.linspace(start, self.real_sampletime, len(data))
- data_scaled = (
- np.array(data, dtype=float) * scale_factor
- ) # Apply the scaling factor
- self.scope_plot_widget.plot(
- time_values,
- data_scaled,
- pen=pg.mkPen(color=self.plot_colors[i], width=2),
- name=f"Channel {channel}",
- )
- logging.debug(
- f"Plotting channel {channel} with color {self.plot_colors[i]}"
- )
- else:
- logging.debug(f"Not plotting channel {channel}")
- self.scope_plot_widget.setLabel("left", "Value")
- self.scope_plot_widget.setLabel("bottom", "Time", units="ms")
- self.scope_plot_widget.showGrid(x=True, y=True)
- except Exception as e:
- error_message = f"Error updating scope plot: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def plot_data_plot(self):
- """Initializes and starts data plotting."""
- try:
- if not self.plot_data:
- return
-
- self.update_watch_plot()
- self.update_scope_plot()
-
- if not self.plot_window_open:
- self.plot_window_open = True
- except Exception as e:
- logging.error(e)
-
- def handle_error(self, error_message: str):
- """Displays an error message in a message box with a cooldown period."""
- current_time = time.time()
- if self.last_error_time is None or (
- current_time - self.last_error_time > self.timeout
- ): # Cooldown period of 5 seconds
- msg_box = QMessageBox(self)
- msg_box.setWindowTitle("Error")
- msg_box.setText(error_message)
- msg_box.setStandardButtons(QMessageBox.Ok)
- msg_box.exec_()
- self.last_error_time = current_time # Update the last error time
-
- def sampletime_edit(self):
- """Handles the editing of the sample time value."""
- try:
- new_sample_time = int(self.sampletime.text())
- if new_sample_time != self.timerValue:
- self.timerValue = new_sample_time
- for timer in self.timer_list:
- if timer.isActive():
- timer.start(self.timerValue)
- except ValueError as e:
- logging.error(e)
- self.handle_error(f"Invalid sample time: {e}")
-
- @pyqtSlot()
- def handle_var_update(self, counter, value_var):
- """Handles the update of variable values from the microcontroller.
-
- Args:
- counter: The variable to update.
- value_var (QLineEdit): The input field to display the updated value.
- """
- if not self.is_connected():
- return # Do not proceed if the device is not connected
- try:
- if counter is not None:
- counter = self.x2cscope.get_variable(counter)
- value = counter.get_value()
- value_var.setText(str(value))
- if value_var == self.Value_var1:
- self.slider_var1.setValue(int(value))
- self.plot_data_update()
- except Exception as e:
- error_message = f"Error: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def slider_var1_changed(self, value):
- """Handles the change in slider value for Variable 1.
-
- Args:
- value (int): The new value of the slider.
- """
- if self.line_edit1.text() == "":
- self.handle_error("Search Variable")
- else:
- self.Value_var1.setText(str(value))
- self.update_scaled_value(
- self.Scaling_var1,
- self.Value_var1,
- self.ScaledValue_var1,
- self.offset_var1,
- )
- self.handle_variable_putram(self.line_edit1.text(), self.Value_var1)
-
- @pyqtSlot()
- def handle_variable_getram(self, variable, value_var):
- """Handle the retrieval of values from RAM for the specified variable.
-
- Args:
- variable: The variable to retrieve the value for.
- value_var: The QLineEdit widget to display the retrieved value.
- """
- if not self.is_connected():
- return # Do not proceed if the device is not connected
-
- try:
- current_variable = variable
-
- for index, line_edit in enumerate(self.line_edit_boxes):
- if line_edit.text() == current_variable:
- self.selected_var_indices[index] = current_variable
-
- if current_variable and current_variable != "None":
- counter = self.x2cscope.get_variable(current_variable)
- value = counter.get_value()
- value_var.setText(str(value))
- if value_var == self.Value_var1:
- self.slider_var1.setValue(int(value))
-
- if current_variable not in self.selected_variables:
- self.selected_variables.append(current_variable)
-
- except Exception as e:
- error_message = f"Error: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- @pyqtSlot()
- def handle_variable_putram(self, variable, value_var):
- """Handle the writing of values to RAM for the specified variable.
-
- Args:
- variable: The variable to write the value to.
- value_var: The QLineEdit widget to get the value from.
- """
- if not self.is_connected():
- return # Do not proceed if the device is not connected
- try:
- current_variable = variable
- value = float(value_var.text())
-
- if current_variable and current_variable != "None":
- counter = self.x2cscope.get_variable(current_variable)
- counter.set_value(value)
-
- except Exception as e:
- error_message = f"Error: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- @pyqtSlot()
- def select_elf_file(self):
- """Function to select elf file."""
- file_dialog = QFileDialog()
- file_dialog.setNameFilter("ELF Files (*.elf)")
- file_dialog.setFileMode(QFileDialog.ExistingFile)
- if self.file_path:
- file_dialog.setDirectory(os.path.dirname(self.file_path))
- if file_dialog.exec_():
- selected_files = file_dialog.selectedFiles()
- if selected_files:
- self.file_path = selected_files[0]
- self.settings.setValue("file_path", self.file_path)
- self.select_file_button.setText(QFileInfo(self.file_path).fileName())
- self.elf_file_loaded = True
-
- # If Auto Connect is selected, attempt to auto-connect to the first available port
-
- def refresh_line_edit(self):
- """Refresh the contents of the variable selection line edits.
-
- This method repopulates the line edits used for variable selection
- with the updated list of variables.
- """
- if self.VariableList is not None:
- for index, line_edit in enumerate(self.line_edit_boxes):
- current_selected_text = line_edit.text()
-
- if current_selected_text in self.VariableList:
- line_edit.setText(current_selected_text)
- else:
- line_edit.setText("")
-
- for line_edit in self.scope_var_lines:
- current_selected_text = line_edit.text()
-
- if current_selected_text in self.VariableList:
- line_edit.setText(current_selected_text)
- else:
- line_edit.setText("")
- else:
- logging.warning("VariableList is None. Unable to refresh line edits.")
-
- def refresh_ports(self):
- """Refresh the list of available serial ports.
-
- This method updates the combo box containing the list of available
- serial ports to reflect the current state of the system.
- """
- available_ports = [port.device for port in serial.tools.list_ports.comports()]
- self.port_combo.clear()
- self.port_combo.addItem("Auto Connect") # Add an Auto Connect option
- self.port_combo.addItems(available_ports)
-
- @pyqtSlot()
- def toggle_connection(self):
- """Handle the connection or disconnection of the serial port.
-
- This method establishes or terminates the serial connection based on
- the current state of the connection.
- """
- if self.file_path == "":
- QMessageBox.warning(self, "Error", "Please select an ELF file.")
- self.select_elf_file()
- return
-
- # Check if already connected
- if self.ser is not None and self.ser.is_open:
- # Call the disconnect function if already connected
- logging.info("Already connected, disconnecting now.")
- self.disconnect_serial()
- else:
- # Handle connection logic if not connected
- for label in self.device_info_labels.values():
- label.setText("Loading...")
- for timer in self.timer_list:
- if timer.isActive():
- timer.stop()
- self.plot_data.clear()
- self.save_selected_variables() # Save the current selections before connecting
-
- try:
- self.connect_serial() # Attempt to connect
- if self.ser is not None and self.ser.is_open:
- # Fetch device information after successful connection
- self.update_device_info()
- except Exception as e:
- logging.error(e)
- self.handle_error(f"Error connecting: {e}")
-
- def handle_failed_connection(self):
- """Popping up a window for the errors."""
- choice = QMessageBox.question(
- self,
- "Connection Failed",
- "Failed to connect with the current settings. Would you like to adjust the settings?",
- QMessageBox.Yes | QMessageBox.No,
- QMessageBox.No,
- )
- if choice == QMessageBox.Yes:
- # Optionally, bring up settings dialog or similar
- self.show_connection_settings()
-
- def save_selected_variables(self):
- """Save the current selections of variables in WatchView and ScopeView."""
- self.previous_selected_variables = {
- "watch": [(le.text(), le.text()) for le in self.line_edit_boxes],
- "scope": [(le.text(), le.text()) for le in self.scope_var_lines],
- }
-
- def restore_selected_variables(self):
- """Restore the previously selected variables in WatchView and ScopeView."""
- if "watch" in self.previous_selected_variables:
- for le, (var, _) in zip(
- self.line_edit_boxes, self.previous_selected_variables["watch"]
- ):
- le.setText(var)
-
- if "scope" in self.previous_selected_variables:
- for le, (var, _) in zip(
- self.scope_var_lines, self.previous_selected_variables["scope"]
- ):
- le.setText(var)
-
- def disconnect_serial(self):
- """Disconnect the current serial connection.
-
- This method safely terminates the existing serial connection, if any,
- and updates the UI to reflect the disconnection.
- """
- try:
- if self.ser is not None and self.ser.is_open:
- self.ser.stop()
- self.ser = None
-
- self.Connect_button.setText("Connect")
- self.Connect_button.setEnabled(True)
- self.select_file_button.setEnabled(True)
- widget_list = [self.port_combo, self.baud_combo]
-
- for widget in widget_list:
- widget.setEnabled(True)
-
- for line_edit in self.line_edit_boxes:
- line_edit.setEnabled(False)
-
- for live_var in self.live_checkboxes:
- live_var.setEnabled(False)
-
- self.slider_var1.setEnabled(False)
- for timer in self.timer_list:
- if timer.isActive():
- timer.stop()
-
- self.plot_update_timer.stop() # Stop the continuous plot update
-
- except Exception as e:
- error_message = f"Error while disconnecting: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- if self.ser and self.ser.is_open:
- self.ser.close()
- self.Connect_button.setText("Connect")
-
- def connect_serial(self):
- """Establish a serial connection based on the current UI settings."""
- try:
- # Disconnect if already connected
- if self.ser is not None and self.ser.is_open:
- self.disconnect_serial()
-
- baud_rate = int(self.baud_combo.currentText())
-
- # Check if Auto Connect is selected
- if self.port_combo.currentText() == "Auto Connect":
- self.auto_connect_serial(baud_rate)
- else:
- self.manual_connect_serial(baud_rate)
-
- except Exception as e:
- error_message = f"Error while connecting: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def auto_connect_serial(self, baud_rate):
- """Attempt to auto-connect to available COM ports."""
- available_ports = [port.device for port in serial.tools.list_ports.comports()]
-
- # Iterate through available ports and attempt to connect
- for port in available_ports:
- if self.connect_to_port(port, baud_rate):
- return # Exit once a connection is established
-
- # If no ports were successfully connected
- self.handle_error(
- "Auto-connect failed to connect to any available COM ports. Please check your connection!"
- )
- raise Exception("Auto-connect failed to connect to any available COM ports.")
-
- def manual_connect_serial(self, baud_rate):
- """Attempt to manually connect to the selected COM port."""
- port = self.port_combo.currentText()
- logging.info(f"Trying to connect to {port} manually.")
-
- # Retry mechanism: try to connect twice if the first attempt fails
- for attempt in range(2):
- if self.connect_to_port(port, baud_rate):
- return # Exit once the connection is successful
- logging.info(f"Retrying connection to {port} (Attempt {attempt + 1})")
-
- raise Exception(f"Failed to connect to {port} after multiple attempts.")
-
- def connect_to_port(self, port, baud_rate):
- """Attempt to establish a connection to the specified port."""
- try:
- logging.info(f"Trying to connect to {port}...")
- self.x2cscope = X2CScope(
- port=port, elf_file=self.file_path, baud_rate=baud_rate
- )
- self.ser = self.x2cscope.interface
-
- # If connection is successful
- logging.info(f"Connected to {port} successfully.")
- self.select_file_button.setText(QFileInfo(self.file_path).fileName())
- self.port_combo.setCurrentText(
- port
- ) # Update combo box with the successful port
- self.setup_connected_state() # Handle UI updates after connection
- return True
- except OSError as e:
- logging.error(f"Failed to connect to {port}: {e}")
- return False
- except Exception as e:
- logging.error(f"Unexpected error connecting to {port}: {e}")
- return False
-
- def setup_connected_state(self):
- """Handle the UI updates and logic when a connection is successfully established."""
- # Refresh the variable list from the device
- self.VariableList = self.x2cscope.list_variables()
- if self.VariableList:
- self.VariableList.insert(0, "None")
- self.refresh_line_edit()
-
- # Update the UI elements
- self.Connect_button.setText("Disconnect")
- self.Connect_button.setEnabled(True)
-
- widget_list = [self.port_combo, self.baud_combo, self.select_file_button]
- for widget in widget_list:
- widget.setEnabled(False)
-
- for line_edit in self.line_edit_boxes:
- line_edit.setEnabled(True)
- self.slider_var1.setEnabled(True)
-
- for live_var in self.live_checkboxes:
- live_var.setEnabled(True)
-
- # Start any live variable timers
- for timer, live_var in zip(self.timer_list, self.live_checkboxes):
- if live_var.isChecked():
- timer.start(self.timerValue)
-
- # Start the continuous plot update
- self.plot_update_timer.start(self.timerValue)
-
- # Restore any selected variables that were saved before disconnecting
- self.restore_selected_variables()
-
- def close_plot_window(self):
- """Close the plot window if it is open.
-
- This method stops the animation and closes the plot window if it is open.
- """
- self.plot_window_open = False
-
- def close_event(self, event):
- """Handle the event when the main window is closed.
-
- Args:
- event: The close event.
-
- This method ensures that all resources are properly released and the
- application is closed cleanly.
- """
- if self.sampling_active:
- self.sampling_active = False
- if self.ser:
- self.disconnect_serial()
- event.accept()
-
- def start_sampling(self):
- """Start the sampling process."""
- if not self.is_connected():
- return # Do not proceed if the device is not connected
- try:
- a = time.time()
- if self.sampling_active:
- self.sampling_active = False
- self.scope_sample_button.setText("Sample")
- logging.info("Stopped sampling.")
-
- # Stop sampling and timers
- if self.scope_timer.isActive():
- self.scope_timer.stop() # Stop the periodic sampling
-
- self.x2cscope.clear_all_scope_channel() # Clears channels and stops requests
- else:
- self.x2cscope.clear_all_scope_channel()
- for line_edit in self.scope_var_lines:
- variable_name = line_edit.text()
- if variable_name and variable_name != "None":
- variable = self.x2cscope.get_variable(variable_name)
- self.x2cscope.add_scope_channel(variable)
-
- self.x2cscope.set_sample_time(
- int(self.sample_time_factor.text())
- ) # set sample time factor
-
- # Set the scope sample time from the user input in microseconds
- scope_sample_time_us = int(self.scope_sampletime_edit.text())
- self.real_sampletime = self.x2cscope.get_scope_sample_time(
- scope_sample_time_us
- )
- logging.debug(f"Real sample time: {self.real_sampletime} µs") # Check this value
-
- # Update the Total Time display
- self.total_time_value.setText(str(self.real_sampletime))
-
- self.sampling_active = True
- self.configure_trigger()
- self.scope_sample_button.setText("Stop")
- logging.info("Started sampling.")
- self.x2cscope.request_scope_data()
- self.sample_scope_data(
- single_shot=self.single_shot_checkbox.isChecked()
- )
- b = time.time()
- logging.debug(f"time execution '{b - a}'")
- except Exception as e:
- error_message = f"Error starting sampling: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def configure_trigger(self):
- """Configure the trigger settings."""
- if not self.is_connected():
- return # Do not proceed if the device is not connected
- try:
- if self.triggerVariable is not None:
- variable_name = self.triggerVariable
- variable = self.x2cscope.get_variable(variable_name)
-
- # Handle empty string for trigger level and delay
- trigger_level_text = self.trigger_level_edit.text().strip()
- trigger_delay_text = self.trigger_delay_edit.text().strip()
-
- if not trigger_level_text:
- trigger_level = 0
- else:
- try:
- trigger_level = int(trigger_level_text) # YA
- logging.debug(trigger_level)
- except ValueError:
- logging.error(
- f"Invalid trigger level value: {trigger_level_text}"
- )
- self.handle_error(
- f"Invalid trigger level value: {trigger_level_text}"
- )
- return
-
- if not trigger_delay_text:
- trigger_delay = 0
- else:
- try:
- trigger_delay = int(trigger_delay_text)
- except ValueError:
- logging.error(
- f"Invalid trigger delay value: {trigger_delay_text}"
- )
- self.handle_error(
- f"Invalid trigger delay value: {trigger_delay_text}"
- )
- return
-
- trigger_edge = (
- 0 if self.trigger_edge_combo.currentText() == "Rising" else 1
- )
- trigger_mode = (
- 2 if self.trigger_mode_combo.currentText() == "Auto" else 1
- )
-
- trigger_config = TriggerConfig(
- variable=variable,
- trigger_level=trigger_level,
- trigger_mode=trigger_mode,
- trigger_delay=trigger_delay,
- trigger_edge=trigger_edge,
- )
- self.x2cscope.set_scope_trigger(trigger_config)
- logging.info("Trigger configured.")
- except Exception as e:
- error_message = f"Error configuring trigger: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def sample_scope_data(self, single_shot=False):
- """Sample the scope data using QTimer for non-blocking updates."""
- try:
- if not self.is_connected():
- return # Do not proceed if the device is not connected
-
- self.sampling_active = True
- self.scope_sample_button.setText("Stop") # Update button text
-
- # Create a QTimer for periodic scope data requests
- self.scope_timer = QTimer()
- self.scope_timer.timeout.connect(
- lambda: self._sample_scope_data_timer(single_shot)
- )
- self.scope_timer.start(250) # Adjust the interval (milliseconds) as needed
-
- except Exception as e:
- error_message = f"Error starting sampling: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
-
- def _sample_scope_data_timer(self, single_shot):
- """Function that QTimer calls periodically to handle scope data sampling."""
- try:
- # Retry mechanism for single-shot mode
- if not self.x2cscope.is_scope_data_ready():
- if single_shot:
- QTimer.singleShot(250, lambda: self._sample_scope_data_timer(single_shot))
- return # Exit if data is not ready
-
- logging.info("Scope data is ready.")
- data_storage = {}
- for channel, data in self.x2cscope.get_scope_channel_data().items():
- data_storage[channel] = data
-
- # Plot the data
- self.scope_plot_widget.clear()
- for i, (channel, data) in enumerate(data_storage.items()):
- if self.scope_channel_checkboxes[i].isChecked(): # Check if the channel is enabled
- scale_factor = float(self.scope_scaling_boxes[i].text()) # Get the scaling factor
- start = 0
- time_values = np.linspace(start, self.real_sampletime, len(data))
- data_scaled = (
- np.array(data, dtype=float) * scale_factor
- ) # Apply the scaling factor
- self.scope_plot_widget.plot(
- time_values,
- data_scaled,
- pen=pg.mkPen(
- color=self.plot_colors[i], width=2
- ), # Thicker plot line
- name=f"Channel {channel}",
- )
-
- # Update plot labels and grid
- self.scope_plot_widget.setLabel("left", "Value")
- self.scope_plot_widget.setLabel("bottom", "Time", units="ms")
- self.scope_plot_widget.showGrid(x=True, y=True) # Enable grid lines
-
- # Stop timer if single-shot mode is active
- if single_shot:
- self.scope_timer.stop() # Stop the timer
- self.sampling_active = False
- self.scope_sample_button.setText("Sample") # Update button text
-
- # Request new data for the next tick
- if self.x2cscope.is_scope_data_ready():
- self.x2cscope.request_scope_data()
-
- except Exception as e:
- error_message = f"Error sampling scope data: {e}"
- logging.error(error_message)
- self.handle_error(error_message)
- self.scope_timer.stop() # Stop timer on error
- self.sampling_active = False
- self.scope_sample_button.setText("Sample") # Update button text
-
- def save_config(self):
- """Save current working config."""
- try:
- # Configuration dictionary includes the path to the ELF file
- config = {
- "elf_file": self.file_path, # Store the current ELF file path
- "com_port": self.port_combo.currentText(),
- "baud_rate": self.baud_combo.currentText(),
- "watch_view": {
- "variables": [le.text() for le in self.line_edit_boxes],
- "values": [ve.text() for ve in self.Value_var_boxes],
- "scaling": [sc.text() for sc in self.scaling_boxes],
- "offsets": [off.text() for off in self.offset_boxes],
- "visible": [cb.isChecked() for cb in self.plot_checkboxes],
- "live": [cb.isChecked() for cb in self.live_checkboxes],
- },
- "scope_view": {
- "variables": [le.text() for le in self.scope_var_lines],
- "trigger": [cb.isChecked() for cb in self.trigger_var_checkbox],
- "scale": [sc.text() for sc in self.scope_scaling_boxes],
- "show": [cb.isChecked() for cb in self.scope_channel_checkboxes],
- "trigger_variable": self.triggerVariable,
- "trigger_level": self.trigger_level_edit.text(),
- "trigger_delay": self.trigger_delay_edit.text(),
- "trigger_edge": self.trigger_edge_combo.currentText(),
- "trigger_mode": self.trigger_mode_combo.currentText(),
- "sample_time_factor": self.sample_time_factor.text(),
- "single_shot": self.single_shot_checkbox.isChecked(),
- },
- "tab3_view": {
- "variables": [le.text() for le in self.variable_line_edits],
- "values": [ve.text() for ve in self.value_line_edits],
- "scaling": [sc.text() for sc in self.scaling_edits_tab3],
- "offsets": [off.text() for off in self.offset_edits_tab3],
- "scaled_values": [sv.text() for sv in self.scaled_value_edits_tab3],
- "live": [cb.isChecked() for cb in self.live_tab3],
- },
- }
- file_path, _ = QFileDialog.getSaveFileName(
- self, "Save Configuration", "", "JSON Files (*.json)"
- )
- if file_path:
- with open(file_path, "w") as file:
- json.dump(config, file, indent=4)
- logging.info(f"Configuration saved to {file_path}")
- except Exception as e:
- logging.error(f"Error saving configuration: {e}")
- self.handle_error(f"Error saving configuration: {e}")
-
- def load_config(self):
- """Loads a pre-saved/configured config file and applies its settings to the application.
-
- The method first prompts the user to select a configuration file. If a valid file is selected,
- it parses the JSON contents and loads settings related to the general application configuration,
- WatchView, ScopeView, and Tab 3. If an ELF file path is missing or incorrect, the user is prompted
- to reselect it. If connection to a COM port fails, it attempts to connect to available ports.
- """
- try:
- file_path = self.prompt_for_file()
- if file_path:
- config = self.load_json_file(file_path)
- self.load_general_settings(config)
- self.load_watch_view(config.get("watch_view", {}))
- self.load_scope_view(config.get("scope_view", {}))
- self.load_tab3_view(config.get("tab3_view", {}))
- logging.info(f"Configuration loaded from {file_path}")
- except Exception as e:
- logging.error(f"Error loading configuration: {e}")
- self.handle_error(f"Error loading configuration: {e}")
-
- def prompt_for_file(self):
- """Prompts the user to select a configuration file through a file dialog.
-
- :return: The file path selected by the user, or None if no file was selected.
- """
- file_path, _ = QFileDialog.getOpenFileName(
- self, "Load Configuration", "", "JSON Files (*.json)"
- )
- return file_path if file_path else None
-
- def load_json_file(self, file_path):
- """Loads a JSON file from the specified file path.
-
- :param file_path: The path to the JSON configuration file.
- :return: Parsed JSON content as a dictionary.
- """
- with open(file_path, "r") as file:
- return json.load(file)
-
- def load_general_settings(self, config):
- """Loads general configuration settings such as COM port, baud rate, and ELF file path.
-
- If the ELF file does not exist, prompts the user to select a new one. Attempts to connect
- to the specified COM port or other available ports if the connection fails.
-
- :param config: A dictionary containing general configuration settings.
- """
- self.config_file_loaded = True
- config_port = config.get("com_port", "")
- self.baud_combo.setCurrentText(config.get("baud_rate", ""))
-
- elf_file_path = config.get("elf_file", "")
- if os.path.exists(elf_file_path):
- self.file_path = elf_file_path
- self.elf_file_loaded = True
- else:
- self.show_file_not_found_warning(elf_file_path)
- self.select_elf_file()
-
- self.handle_connection(config_port)
- self.select_file_button.setText(QFileInfo(self.file_path).fileName())
- self.settings.setValue("file_path", self.file_path)
-
- def show_file_not_found_warning(self, elf_file_path):
- """Shows a warning message if the specified ELF file does not exist.
-
- :param elf_file_path: The path to the ELF file that was not found.
- """
- QMessageBox.warning(
- self, "File Not Found", f"The ELF file {elf_file_path} does not exist."
- )
-
- def handle_connection(self, config_port):
- """Attempts to connect to the specified COM port or any available port.
-
- If the connection to the specified port fails, it tries to connect to other available ports.
- If no port connection is successful, it shows a warning message.
-
- :param config_port: The port specified in the configuration file.
- """
- if not self.is_connected():
- available_ports = [
- port.device for port in serial.tools.list_ports.comports()
- ]
- if config_port in available_ports and self.attempt_connection():
- logging.info(f"Connected to the specified port: {config_port}")
- else:
- self.try_other_ports(available_ports)
-
- def try_other_ports(self, available_ports):
- """Attempts to connect to other available COM ports if the specified port connection fails.
-
- :param available_ports: A list of available COM ports.
- """
- for port in available_ports:
- self.port_combo.setCurrentText(port)
- if self.attempt_connection():
- logging.info(f"Connected to an alternative port: {port}")
- break
- else:
- QMessageBox.warning(
- self,
- "Connection Failed",
- "Could not connect to any available ports. Please check your connection.",
- )
-
- def load_watch_view(self, watch_view):
- """Loads the WatchView settings from the configuration file.
-
- This includes variables, values, scaling, offsets, plot visibility, and live status.
-
- :param watch_view: A dictionary containing WatchView settings.
- """
- for le, var in zip(self.line_edit_boxes, watch_view.get("variables", [])):
- le.setText(var)
- for ve, val in zip(self.Value_var_boxes, watch_view.get("values", [])):
- ve.setText(val)
- for sc, scale in zip(self.scaling_boxes, watch_view.get("scaling", [])):
- sc.setText(scale)
- for off, offset in zip(self.offset_boxes, watch_view.get("offsets", [])):
- off.setText(offset)
- for cb, visible in zip(self.plot_checkboxes, watch_view.get("visible", [])):
- cb.setChecked(visible)
- for cb, live in zip(self.live_checkboxes, watch_view.get("live", [])):
- cb.setChecked(live)
-
- def load_scope_view(self, scope_view):
- """Loads the ScopeView settings from the configuration file.
-
- This includes variables, trigger settings, and sampling configuration.
-
- :param scope_view: A dictionary containing ScopeView settings.
- """
- for le, var in zip(self.scope_var_lines, scope_view.get("variables", [])):
- le.setText(var)
- for cb, trigger in zip(
- self.trigger_var_checkbox, scope_view.get("trigger", [])
- ):
- cb.setChecked(trigger)
- self.triggerVariable = scope_view.get("trigger_variable", "")
- self.trigger_level_edit.setText(scope_view.get("trigger_level", ""))
- self.trigger_delay_edit.setText(scope_view.get("trigger_delay", ""))
- self.trigger_edge_combo.setCurrentText(scope_view.get("trigger_edge", ""))
- self.trigger_mode_combo.setCurrentText(scope_view.get("trigger_mode", ""))
- self.sample_time_factor.setText(scope_view.get("sample_time_factor", ""))
- self.single_shot_checkbox.setChecked(scope_view.get("single_shot", False))
-
- def load_tab3_view(self, tab3_view):
- """Loads the configuration settings for Tab 3 (WatchView).
-
- This includes variables, values, scaling, offsets, scaled values, and live status.
-
- :param tab3_view: A dictionary containing Tab 3 settings.
- """
- self.clear_tab3()
- for var, val, sc, off, sv, live in zip(
- tab3_view.get("variables", []),
- tab3_view.get("values", []),
- tab3_view.get("scaling", []),
- tab3_view.get("offsets", []),
- tab3_view.get("scaled_values", []),
- tab3_view.get("live", []),
- ):
- self.add_variable_row()
- self.variable_line_edits[-1].setText(var)
- self.value_line_edits[-1].setText(val)
- self.scaling_edits_tab3[-1].setText(sc)
- self.offset_edits_tab3[-1].setText(off)
- self.scaled_value_edits_tab3[-1].setText(sv)
- self.live_tab3[-1].setChecked(live)
-
- def attempt_connection(self):
- """Attempt to connect to the selected port and ELF file."""
- if self.elf_file_loaded:
- try:
- self.toggle_connection() # Trigger connection
- if self.ser and self.ser.is_open:
- return True
- except Exception as e:
- logging.error(f"Connection failed: {e}")
- self.handle_error(f"Connection failed: {e}")
- return False
-
- def is_connected(self):
- """Check if the serial connection is established and the device is connected."""
- return self.ser is not None and self.ser.is_open
-
- def clear_tab3(self):
- """Remove all variable rows in Tab 3 efficiently."""
- if not self.row_widgets: # Check if there is anything to clear
- return # Skip clearing if already empty
-
- # Block updates to the GUI while making changes
- self.tab3.layout().blockSignals(True)
- try:
- while self.row_widgets:
- for widget in self.row_widgets.pop():
- widget.setVisible(False) # Hide widget to improve performance
- self.watchview_grid.removeWidget(widget) # Remove widget from grid
- widget.deleteLater() # Schedule widget for deletion
-
- self.current_row = 1 # Reset the row count
- finally:
- self.tab3.layout().blockSignals(False) # Ensure signals are re-enabled
- self.tab3.layout().update() # Force an update to the layout
-
- def setup_tab3(self):
- """Set up the third tab (WatchView Only) with Add/Remove Variable buttons and live functionality."""
- self.tab3.layout = QVBoxLayout()
- self.tab3.setLayout(self.tab3.layout)
-
- # Add a scroll area to the layout
- scroll_area = QScrollArea()
- scroll_area_widget = QWidget() # Widget to hold the scroll area content
- self.tab3.layout.addWidget(scroll_area)
-
- # Create a vertical layout inside the scroll area
- scroll_area_layout = QVBoxLayout(scroll_area_widget)
- scroll_area_widget.setLayout(scroll_area_layout)
-
- scroll_area.setWidget(scroll_area_widget)
- scroll_area.setWidgetResizable(True) # Make the scroll area resizable
-
- # Create grid layout for adding rows similar to WatchView
- self.watchview_grid = QGridLayout()
- scroll_area_layout.addLayout(self.watchview_grid)
-
- # Set margins and spacing to remove excess gaps
- self.watchview_grid.setContentsMargins(0, 0, 0, 0) # Remove margins
- self.watchview_grid.setVerticalSpacing(
- 2
- ) # Reduce vertical spacing between rows
- self.watchview_grid.setHorizontalSpacing(
- 5
- ) # Reduce horizontal spacing between columns
-
- # Add header row for the grid with proper alignment and size policy
- headers = [
- "Live",
- "Variable",
- "Value",
- "Scaling",
- "Offset",
- "Scaled Value",
- "Unit",
- "Remove",
- ]
- for i, header in enumerate(headers):
- label = QLabel(header)
- label.setAlignment(Qt.AlignCenter) # Center align the headers
- label.setSizePolicy(
- QSizePolicy.Minimum, QSizePolicy.Fixed
- ) # Prevent labels from stretching vertically
- self.watchview_grid.addWidget(label, 0, i)
-
- # Set column stretch to allow the variable field to resize
- self.watchview_grid.setColumnStretch(1, 5) # Column for 'Variable'
- self.watchview_grid.setColumnStretch(2, 2) # Column for 'Value'
- self.watchview_grid.setColumnStretch(3, 1) # Column for 'Scaling'
- self.watchview_grid.setColumnStretch(4, 1) # Column for 'Offset'
- self.watchview_grid.setColumnStretch(5, 1) # Column for 'Scaled Value'
- self.watchview_grid.setColumnStretch(6, 1) # Column for 'Unit'
-
- # Keep track of the current row count
- self.current_row = 1
-
- # Timer for updating live variables
- self.live_update_timer = QTimer()
- self.live_update_timer.timeout.connect(self.update_live_variables)
- self.live_update_timer.start(500) # Set the update interval (500 ms)
-
- # Store references to live checkboxes and variables
- self.live_tab3 = []
- self.variable_line_edits = []
- self.value_line_edits = []
- self.row_widgets = []
-
- # Add button to add more variables at the bottom
- self.add_variable_button = QPushButton("Add Variable")
- scroll_area_layout.addWidget(self.add_variable_button, alignment=Qt.AlignBottom)
- self.add_variable_button.clicked.connect(self.add_variable_row)
-
- # Add Load and Save Config buttons
- self.save_button_tab3 = QPushButton("Save Config")
- self.load_button_tab3 = QPushButton("Load Config")
- self.save_button_tab3.setFixedSize(100, 30)
- self.load_button_tab3.setFixedSize(100, 30)
- button_layout = QHBoxLayout()
- button_layout.addWidget(self.save_button_tab3)
- button_layout.addWidget(self.load_button_tab3)
- scroll_area_layout.addLayout(button_layout)
-
- # Connect buttons to their functions
- self.save_button_tab3.clicked.connect(self.save_config)
- self.load_button_tab3.clicked.connect(self.load_config)
-
- # Adjust the scroll area margins to remove any additional gaps
- scroll_area_layout.setContentsMargins(0, 0, 0, 0)
-
- @pyqtSlot()
- def add_variable_row(self):
- """Add a row of widgets to represent a variable in the WatchView Only tab with live functionality."""
- row = self.current_row
-
- # Create widgets for the row
- live_checkbox = QCheckBox(self)
- variable_edit = QLineEdit(self)
- value_edit = QLineEdit(self)
- scaling_edit = QLineEdit(self)
- offset_edit = QLineEdit(self)
- scaled_value_edit = QLineEdit(self)
- unit_edit = QLineEdit(self)
- remove_button = QPushButton("Remove", self)
-
- # Set default values for scaling and offset
- scaling_edit.setText("1") # Default scaling to 1
- offset_edit.setText("0") # Default offset to 0
-
- # Set placeholder text for variable search (like in WatchView)
- variable_edit.setPlaceholderText("Search Variable")
-
- # Make scaled value read-only
- scaled_value_edit.setReadOnly(True)
-
- # Set size policies to make the variable name and value resize dynamically
- variable_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
- value_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
-
- # Add the widgets to the grid layout
- self.watchview_grid.addWidget(live_checkbox, row, 0)
- self.watchview_grid.addWidget(variable_edit, row, 1)
- self.watchview_grid.addWidget(value_edit, row, 2)
- self.watchview_grid.addWidget(scaling_edit, row, 3)
- self.watchview_grid.addWidget(offset_edit, row, 4)
- self.watchview_grid.addWidget(scaled_value_edit, row, 5)
- self.watchview_grid.addWidget(unit_edit, row, 6)
- self.watchview_grid.addWidget(remove_button, row, 7)
-
- # Set column stretch to allow the variable field to resize
- self.watchview_grid.setColumnStretch(1, 5) # Column for 'Variable'
- self.watchview_grid.setColumnStretch(2, 2) # Column for 'Value'
- self.watchview_grid.setColumnStretch(3, 1) # Column for 'Scaling'
- self.watchview_grid.setColumnStretch(4, 1) # Column for 'Offset'
- self.watchview_grid.setColumnStretch(5, 1) # Column for 'Scaled Value'
- self.watchview_grid.setColumnStretch(6, 1) # Column for 'Unit'
-
- # Connect remove button to function to remove the row
- remove_button.clicked.connect(
- lambda: self.remove_variable_row(
- live_checkbox,
- variable_edit,
- value_edit,
- scaling_edit,
- offset_edit,
- scaled_value_edit,
- unit_edit,
- remove_button,
- )
- )
-
- # Connect the variable search to the dialog
- variable_edit.installEventFilter(self)
-
- # Connect value editing to set value using handle_putram when Enter is pressed
- value_edit.editingFinished.connect(
- lambda: self.handle_variable_putram(variable_edit.text(), value_edit)
- )
-
- # Connect scaling and offset fields to recalculate the scaled value
- scaling_edit.editingFinished.connect(
- lambda: self.update_scaled_value_tab3(
- value_edit, scaling_edit, offset_edit, scaled_value_edit
- )
- )
- offset_edit.editingFinished.connect(
- lambda: self.update_scaled_value_tab3(
- value_edit, scaling_edit, offset_edit, scaled_value_edit
- )
- )
-
- # Calculate and show the scaled value immediately using the default scaling and offset
- self.update_scaled_value_tab3(
- value_edit, scaling_edit, offset_edit, scaled_value_edit
- )
-
- # Add widgets to the lists for tracking
- self.live_tab3.append(live_checkbox)
- self.variable_line_edits.append(variable_edit)
- self.value_line_edits.append(value_edit)
-
- # Track scaling and offset for live updates in Tab 3
- self.scaling_edits_tab3.append(scaling_edit)
- self.offset_edits_tab3.append(offset_edit)
- self.scaled_value_edits_tab3.append(scaled_value_edit)
-
- # Track the row widgets to remove them easily
- self.row_widgets.append(
- (
- live_checkbox,
- variable_edit,
- value_edit,
- scaling_edit,
- offset_edit,
- scaled_value_edit,
- unit_edit,
- remove_button,
- )
- )
-
- # Increment the current row counter
- self.current_row += 1
-
- def remove_variable_row(
- self,
- live_checkbox,
- variable_edit,
- value_edit,
- scaling_edit,
- offset_edit,
- scaled_value_edit,
- unit_edit,
- remove_button,
- ):
- """Remove a specific row in the WatchView Only tab."""
- # Remove the widgets from the grid layout
- for widget in [
- live_checkbox,
- variable_edit,
- value_edit,
- scaling_edit,
- offset_edit,
- scaled_value_edit,
- unit_edit,
- remove_button,
- ]:
- widget.deleteLater()
-
- # Remove the corresponding widgets from the tracking lists
- self.live_tab3.remove(live_checkbox)
- self.variable_line_edits.remove(variable_edit)
- self.value_line_edits.remove(value_edit)
- self.scaling_edits_tab3.remove(scaling_edit) # Remove from a scaling list
- self.offset_edits_tab3.remove(offset_edit) # Remove from offset list
- self.scaled_value_edits_tab3.remove(
- scaled_value_edit
- ) # Remove from a scaled value list
-
- # Remove the widget references from the row widgets tracking
- self.row_widgets.remove(
- (
- live_checkbox,
- variable_edit,
- value_edit,
- scaling_edit,
- offset_edit,
- scaled_value_edit,
- unit_edit,
- remove_button,
- )
- )
-
- # Decrement the row count
- self.current_row -= 1
-
- # Adjust the layout for remaining rows
- self.rearrange_grid()
-
- def rearrange_grid(self):
- """Rearrange the grid layout after a row has been removed."""
- # Clear the entire grid layout
- for i in reversed(range(self.watchview_grid.count())):
- widget = self.watchview_grid.itemAt(i).widget()
- if widget is not None:
- widget.setParent(None)
-
- # Add the headers again
- headers = [
- "Live",
- "Variable",
- "Value",
- "Scaling",
- "Offset",
- "Scaled Value",
- "Unit",
- "Remove",
- ]
- for i, header in enumerate(headers):
- self.watchview_grid.addWidget(QLabel(header), 0, i)
-
- # Add the remaining rows back to the grid
- for row, widgets in enumerate(self.row_widgets, start=1):
- for col, widget in enumerate(widgets):
- self.watchview_grid.addWidget(widget, row, col)
-
- def eventFilter(self, source, event): # noqa: N802 #Overriding 3rd party function.
- """Event filter to handle line edit click events for variable selection."""
- if event.type() == QtCore.QEvent.MouseButtonPress:
- if isinstance(source, QLineEdit):
- dialog = VariableSelectionDialog(self.VariableList, self)
- if dialog.exec_() == QDialog.Accepted:
- selected_variable = dialog.selected_variable
- if selected_variable:
- source.setText(selected_variable)
- # Get the initial value from the microcontroller (if applicable)
- try:
- self.handle_variable_getram(
- selected_variable,
- self.value_line_edits[
- self.variable_line_edits.index(source)
- ],
- )
- except Exception as e:
- logging.debug(e)
-
- return super().eventFilter(source, event)
-
- def update_live_variables(self):
- """Update the values of variables in real-time if live checkbox is checked."""
- for (
- checkbox,
- variable_edit,
- value_edit,
- scaling_edit,
- offset_edit,
- scaled_value_edit,
- ) in zip(
- self.live_tab3,
- self.variable_line_edits,
- self.value_line_edits,
- self.scaling_edits_tab3,
- self.offset_edits_tab3,
- self.scaled_value_edits_tab3,
- ):
-
- if checkbox.isChecked() and variable_edit.text():
- # Fetch the variable value from the microcontroller
- variable_name = variable_edit.text()
- self.handle_variable_getram(variable_name, value_edit)
-
- # Update the scaled value in real-time based on the raw value, scaling, and offset
- self.update_scaled_value_tab3(
- value_edit, scaling_edit, offset_edit, scaled_value_edit
- )
-
- @pyqtSlot()
- def update_scaled_value_tab3(
- self, value_edit, scaling_edit, offset_edit, scaled_value_edit
- ):
- """Updates the scaled value in both Tab 1 and Tab 3 based on the provided scaling factor and offset.
-
- Args:
- value_edit : Input field for the raw value.
- scaling_edit : Input field for the scaling factor.
- offset_edit : Input field for the offset.
- scaled_value_edit : Output field for the scaled value.
- """
- try:
- value = float(value_edit.text())
- scaling = float(scaling_edit.text()) if scaling_edit.text() else 1.0
- offset = float(offset_edit.text()) if offset_edit.text() else 0.0
- scaled_value = (scaling * value) + offset
- scaled_value_edit.setText(f"{scaled_value:.2f}")
- except ValueError as e:
- logging.error(f"Error updating scaled value: {e}")
- scaled_value_edit.setText("0.00")
-
-
-if __name__ == "__main__":
- app = QApplication(sys.argv)
- ex = X2cscopeGui()
- ex.show()
- sys.exit(app.exec_())
diff --git a/pyx2cscope/gui/qt/__init__.py b/pyx2cscope/gui/qt/__init__.py
new file mode 100644
index 00000000..90925782
--- /dev/null
+++ b/pyx2cscope/gui/qt/__init__.py
@@ -0,0 +1,58 @@
+"""pyX2Cscope Qt GUI - A PyQt5-based GUI for motor control and debugging.
+
+This package provides a modular GUI with the following components:
+
+Tabs:
+ - SetupTab: Connection setup and device information
+ - ScopeViewTab: Oscilloscope-style capture and trigger configuration
+ - WatchViewTab: Dynamic watch variable management
+
+Controllers:
+ - ConnectionManager: Serial/TCP/CAN connection management
+ - ConfigManager: Configuration save/load
+
+Workers:
+ - DataPoller: Background thread for polling watch and scope data
+
+Models:
+ - AppState: Centralized application state management
+"""
+
+from pyx2cscope.gui.qt.controllers.config_manager import ConfigManager
+from pyx2cscope.gui.qt.controllers.connection_manager import ConnectionManager
+from pyx2cscope.gui.qt.dialogs.variable_selection import VariableSelectionDialog
+from pyx2cscope.gui.qt.main_window import MainWindow, execute_qt
+from pyx2cscope.gui.qt.models.app_state import (
+ AppState,
+ ScopeChannel,
+ TriggerSettings,
+ WatchVariable,
+)
+from pyx2cscope.gui.qt.tabs.base_tab import BaseTab
+from pyx2cscope.gui.qt.tabs.scope_view_tab import ScopeViewTab
+from pyx2cscope.gui.qt.tabs.setup_tab import SetupTab
+from pyx2cscope.gui.qt.tabs.watch_view_tab import WatchViewTab
+from pyx2cscope.gui.qt.workers.data_poller import DataPoller
+
+__all__ = [
+ # Main
+ "MainWindow",
+ "execute_qt",
+ # Models
+ "AppState",
+ "WatchVariable",
+ "ScopeChannel",
+ "TriggerSettings",
+ # Controllers
+ "ConnectionManager",
+ "ConfigManager",
+ # Workers
+ "DataPoller",
+ # Tabs
+ "BaseTab",
+ "SetupTab",
+ "ScopeViewTab",
+ "WatchViewTab",
+ # Dialogs
+ "VariableSelectionDialog",
+]
diff --git a/pyx2cscope/gui/qt/controllers/__init__.py b/pyx2cscope/gui/qt/controllers/__init__.py
new file mode 100644
index 00000000..3cc85995
--- /dev/null
+++ b/pyx2cscope/gui/qt/controllers/__init__.py
@@ -0,0 +1,6 @@
+"""Controllers for the generic GUI."""
+
+from .config_manager import ConfigManager
+from .connection_manager import ConnectionManager
+
+__all__ = ["ConnectionManager", "ConfigManager"]
diff --git a/pyx2cscope/gui/qt/controllers/config_manager.py b/pyx2cscope/gui/qt/controllers/config_manager.py
new file mode 100644
index 00000000..e0bc1e50
--- /dev/null
+++ b/pyx2cscope/gui/qt/controllers/config_manager.py
@@ -0,0 +1,192 @@
+"""Configuration management for saving and loading GUI state."""
+
+import json
+import logging
+import os
+from typing import Any, Dict, Optional
+
+from PyQt5.QtCore import QObject, QSettings, pyqtSignal
+from PyQt5.QtWidgets import QFileDialog, QMessageBox, QWidget
+
+
+class ConfigManager(QObject):
+ """Manages saving and loading of configuration files.
+
+ Handles serialization/deserialization of:
+ - Connection settings (port, baud rate, ELF file)
+ - WatchPlot variables (Tab1)
+ - ScopeView settings (Tab2)
+ - WatchView variables (Tab3)
+
+ Signals:
+ config_loaded: Emitted when a config is successfully loaded.
+ Args: (config: dict)
+ config_saved: Emitted when a config is successfully saved.
+ Args: (file_path: str)
+ error_occurred: Emitted when an error occurs.
+ Args: (message: str)
+ """
+
+ config_loaded = pyqtSignal(dict)
+ config_saved = pyqtSignal(str)
+ error_occurred = pyqtSignal(str)
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ """Initialize the config manager.
+
+ Args:
+ parent: Parent widget for dialogs.
+ """
+ super().__init__(parent)
+ self._parent = parent
+ self._settings = QSettings("Microchip", "pyX2Cscope")
+
+ def save_config(self, config: Dict[str, Any], file_path: Optional[str] = None) -> bool:
+ """Save configuration to a JSON file.
+
+ Args:
+ config: Configuration dictionary to save.
+ file_path: Optional path to save to. If None, prompts user.
+
+ Returns:
+ True if save successful, False otherwise.
+ """
+ try:
+ if not file_path:
+ file_path, _ = QFileDialog.getSaveFileName(
+ self._parent,
+ "Save Configuration",
+ "",
+ "JSON Files (*.json)",
+ )
+
+ if not file_path:
+ return False
+
+ with open(file_path, "w") as f:
+ json.dump(config, f, indent=4)
+
+ logging.info(f"Configuration saved to {file_path}")
+ self.config_saved.emit(file_path)
+ return True
+
+ except Exception as e:
+ error_msg = f"Error saving configuration: {e}"
+ logging.error(error_msg)
+ self.error_occurred.emit(error_msg)
+ return False
+
+ def load_config(self, file_path: Optional[str] = None) -> Optional[Dict[str, Any]]:
+ """Load configuration from a JSON file.
+
+ Args:
+ file_path: Optional path to load from. If None, prompts user.
+
+ Returns:
+ Configuration dictionary if successful, None otherwise.
+ """
+ try:
+ if not file_path:
+ file_path, _ = QFileDialog.getOpenFileName(
+ self._parent,
+ "Load Configuration",
+ "",
+ "JSON Files (*.json)",
+ )
+
+ if not file_path:
+ return None
+
+ with open(file_path, "r") as f:
+ config = json.load(f)
+
+ logging.info(f"Configuration loaded from {file_path}")
+ self.config_loaded.emit(config)
+ return config
+
+ except json.JSONDecodeError as e:
+ error_msg = f"Invalid JSON in configuration file: {e}"
+ logging.error(error_msg)
+ self.error_occurred.emit(error_msg)
+ return None
+ except Exception as e:
+ error_msg = f"Error loading configuration: {e}"
+ logging.error(error_msg)
+ self.error_occurred.emit(error_msg)
+ return None
+
+ def validate_elf_file(self, elf_path: str) -> bool:
+ """Validate that an ELF file exists.
+
+ Args:
+ elf_path: Path to the ELF file.
+
+ Returns:
+ True if file exists, False otherwise.
+ """
+ if not elf_path:
+ return False
+ return os.path.exists(elf_path)
+
+ def prompt_for_elf_file(self) -> Optional[str]:
+ """Prompt user to select an ELF file.
+
+ Returns:
+ Selected file path, or None if cancelled.
+ """
+ # Get last used directory from settings
+ last_dir = self._settings.value("last_elf_directory", "", type=str)
+
+ file_path, _ = QFileDialog.getOpenFileName(
+ self._parent,
+ "Select ELF File",
+ last_dir,
+ "ELF Files (*.elf);;All Files (*)",
+ )
+
+ if file_path:
+ # Save the directory for next time
+ self._settings.setValue("last_elf_directory", os.path.dirname(file_path))
+
+ return file_path if file_path else None
+
+ def show_file_not_found_warning(self, file_path: str):
+ """Show a warning dialog for missing ELF file.
+
+ Args:
+ file_path: The path that was not found.
+ """
+ QMessageBox.warning(
+ self._parent,
+ "File Not Found",
+ f"The ELF file '{file_path}' does not exist.\n\n"
+ "Please select a valid ELF file.",
+ )
+
+ @staticmethod
+ def build_config(
+ elf_file: str,
+ connection: Dict[str, Any],
+ scope_view: Dict[str, Any],
+ tab3_view: Dict[str, Any],
+ view_mode: str = "Both",
+ ) -> 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.
+ view_mode: Monitor view mode (WatchView, ScopeView, Both, None).
+
+ Returns:
+ Complete configuration dictionary.
+ """
+ return {
+ "elf_file": elf_file,
+ "connection": connection,
+ "scope_view": scope_view,
+ "tab3_view": tab3_view,
+ "view_mode": view_mode,
+ }
diff --git a/pyx2cscope/gui/qt/controllers/connection_manager.py b/pyx2cscope/gui/qt/controllers/connection_manager.py
new file mode 100644
index 00000000..65140f67
--- /dev/null
+++ b/pyx2cscope/gui/qt/controllers/connection_manager.py
@@ -0,0 +1,265 @@
+"""Connection management for the X2CScope device."""
+
+import logging
+
+import serial.tools.list_ports
+from PyQt5.QtCore import QObject, pyqtSignal
+
+from pyx2cscope.x2cscope import X2CScope
+
+
+class ConnectionManager(QObject):
+ """Manages X2CScope connection to the device.
+
+ Handles connecting/disconnecting, port enumeration, and
+ X2CScope initialization. Supports multiple interface types:
+ - UART (Serial)
+ - TCP/IP
+ - CAN
+
+ Signals:
+ connection_changed: Emitted when connection state changes.
+ Args: (connected: bool)
+ error_occurred: Emitted when a connection error occurs.
+ Args: (message: str)
+ ports_refreshed: Emitted when available ports are updated.
+ Args: (ports: list)
+ """
+
+ connection_changed = pyqtSignal(bool)
+ error_occurred = pyqtSignal(str)
+ ports_refreshed = pyqtSignal(list)
+
+ def __init__(self, app_state, parent=None):
+ """Initialize the connection manager.
+
+ Args:
+ app_state: The centralized AppState instance.
+ parent: Optional parent QObject.
+ """
+ super().__init__(parent)
+ self._app_state = app_state
+
+ def refresh_ports(self) -> list:
+ """Refresh and return list of available COM ports."""
+ ports = [port.device for port in serial.tools.list_ports.comports()]
+ self.ports_refreshed.emit(ports)
+ return ports
+
+ def connect_uart(self, port: str, baud_rate: int, elf_file: str) -> bool:
+ """Connect to the device via UART.
+
+ Args:
+ port: COM port name.
+ baud_rate: Baud rate for serial communication.
+ elf_file: Path to the ELF file for variable information.
+
+ Returns:
+ True if connection successful, False otherwise.
+ """
+ try:
+ x2cscope = X2CScope(
+ port=port,
+ elf_file=elf_file,
+ baud_rate=baud_rate,
+ )
+
+ self._app_state.port = port
+ self._app_state.baud_rate = baud_rate
+ self._app_state.elf_file = elf_file
+ self._app_state.set_x2cscope(x2cscope)
+
+ logging.info(f"Connected via UART to {port} at {baud_rate} baud")
+ self.connection_changed.emit(True)
+ return True
+
+ except Exception as e:
+ error_msg = f"UART connection error: {e}"
+ logging.error(error_msg)
+ self.error_occurred.emit(error_msg)
+ self._app_state.set_x2cscope(None)
+ return False
+
+ def connect_tcp(self, host: str, tcp_port: int, elf_file: str) -> bool:
+ """Connect to the device via TCP/IP.
+
+ Args:
+ host: IP address of the target.
+ tcp_port: TCP port number.
+ elf_file: Path to the ELF file for variable information.
+
+ Returns:
+ True if connection successful, False otherwise.
+ """
+ try:
+ x2cscope = X2CScope(
+ host=host,
+ tcp_port=tcp_port,
+ elf_file=elf_file,
+ )
+
+ self._app_state.elf_file = elf_file
+ self._app_state.set_x2cscope(x2cscope)
+
+ logging.info(f"Connected via TCP/IP to {host}:{tcp_port}")
+ self.connection_changed.emit(True)
+ return True
+
+ except Exception as e:
+ error_msg = f"TCP/IP connection error: {e}"
+ logging.error(error_msg)
+ self.error_occurred.emit(error_msg)
+ self._app_state.set_x2cscope(None)
+ return False
+
+ def connect_can(
+ self,
+ elf_file: str,
+ bus_type: str = "USB",
+ channel: int = 1,
+ baudrate: str = "125K",
+ mode: str = "Standard",
+ tx_id: str = "7F1",
+ rx_id: str = "7F0",
+ ) -> bool:
+ """Connect to the device via CAN.
+
+ Args:
+ elf_file: Path to the ELF file for variable information.
+ bus_type: CAN bus type ("USB" or "LAN").
+ channel: CAN channel number.
+ baudrate: CAN baudrate ("125K", "250K", "500K", "1M").
+ mode: CAN mode ("Standard" or "Extended").
+ tx_id: Transmit ID in hex.
+ rx_id: Receive ID in hex.
+
+ Returns:
+ True if connection successful, False otherwise.
+ """
+ try:
+ # Convert baudrate string to numeric value
+ baudrate_map = {
+ "125K": 125000,
+ "250K": 250000,
+ "500K": 500000,
+ "1M": 1000000,
+ }
+ baud_value = baudrate_map.get(baudrate, 500000)
+
+ # Convert hex IDs to integers
+ tx_id_int = int(tx_id, 16)
+ rx_id_int = int(rx_id, 16)
+
+ # Determine if extended mode
+ is_extended = mode == "Extended"
+
+ x2cscope = X2CScope(
+ elf_file=elf_file,
+ bus=bus_type.lower(),
+ channel=channel,
+ baudrate=baud_value,
+ tx_id=tx_id_int,
+ rx_id=rx_id_int,
+ extended=is_extended,
+ )
+
+ self._app_state.elf_file = elf_file
+ self._app_state.set_x2cscope(x2cscope)
+
+ logging.info(
+ f"Connected via CAN - {bus_type} ch{channel} @ {baudrate}, "
+ f"Tx:{tx_id} Rx:{rx_id} ({mode})"
+ )
+ self.connection_changed.emit(True)
+ return True
+
+ except Exception as e:
+ error_msg = f"CAN connection error: {e}"
+ logging.error(error_msg)
+ self.error_occurred.emit(error_msg)
+ self._app_state.set_x2cscope(None)
+ return False
+
+ def connect(self, elf_file: str, **params) -> bool:
+ """Connect to the device using specified interface parameters.
+
+ Args:
+ elf_file: Path to the ELF file for variable information.
+ **params: Interface-specific parameters:
+ - interface: "UART", "TCP/IP", or "CAN"
+ - UART: port, baud_rate
+ - TCP/IP: host, port
+ - CAN: bus_type, channel, baudrate, mode, tx_id, rx_id
+
+ Returns:
+ True if connection successful, False otherwise.
+ """
+ if self._app_state.is_connected():
+ logging.warning("Already connected. Disconnect first.")
+ return False
+
+ if not elf_file:
+ self.error_occurred.emit("No ELF file selected.")
+ return False
+
+ interface = params.get("interface", "UART")
+
+ if interface == "UART":
+ port = params.get("port", "")
+ baud_rate = params.get("baud_rate", 115200)
+ return self.connect_uart(port, baud_rate, elf_file)
+ elif interface == "TCP/IP":
+ host = params.get("host", "localhost")
+ port = params.get("port", 12666)
+ return self.connect_tcp(host, port, elf_file)
+ elif interface == "CAN":
+ return self.connect_can(
+ elf_file=elf_file,
+ bus_type=params.get("bus_type", "USB"),
+ channel=params.get("channel", 1),
+ baudrate=params.get("baudrate", "125K"),
+ mode=params.get("mode", "Standard"),
+ tx_id=params.get("tx_id", "7F1"),
+ rx_id=params.get("rx_id", "7F0"),
+ )
+ else:
+ self.error_occurred.emit(f"Unknown interface type: {interface}")
+ return False
+
+ def disconnect(self) -> bool:
+ """Disconnect from the device.
+
+ Returns:
+ True if disconnection successful, False otherwise.
+ """
+ try:
+ # X2CScope handles closing the serial connection
+ self._app_state.set_x2cscope(None)
+ logging.info("Disconnected from device")
+ self.connection_changed.emit(False)
+ return True
+ except Exception as e:
+ error_msg = f"Disconnection error: {e}"
+ logging.error(error_msg)
+ self.error_occurred.emit(error_msg)
+ return False
+
+ def is_connected(self) -> bool:
+ """Check if currently connected to device."""
+ return self._app_state.is_connected()
+
+ def toggle_connection(self, elf_file: str, **params) -> bool:
+ """Toggle the connection state.
+
+ Args:
+ elf_file: Path to the ELF file.
+ **params: Interface-specific connection parameters.
+
+ Returns:
+ True if now connected, False if now disconnected.
+ """
+ if self.is_connected():
+ self.disconnect()
+ return False
+ else:
+ return self.connect(elf_file, **params)
diff --git a/pyx2cscope/gui/qt/dialogs/__init__.py b/pyx2cscope/gui/qt/dialogs/__init__.py
new file mode 100644
index 00000000..8dd3db5a
--- /dev/null
+++ b/pyx2cscope/gui/qt/dialogs/__init__.py
@@ -0,0 +1,5 @@
+"""Dialog widgets for the generic GUI."""
+
+from .variable_selection import VariableSelectionDialog
+
+__all__ = ["VariableSelectionDialog"]
diff --git a/pyx2cscope/gui/qt/dialogs/variable_selection.py b/pyx2cscope/gui/qt/dialogs/variable_selection.py
new file mode 100644
index 00000000..b66f3d29
--- /dev/null
+++ b/pyx2cscope/gui/qt/dialogs/variable_selection.py
@@ -0,0 +1,115 @@
+"""Variable selection dialog for searching and selecting variables."""
+
+from typing import List, Optional
+
+from PyQt5.QtWidgets import (
+ QCheckBox,
+ QDialog,
+ QDialogButtonBox,
+ QHBoxLayout,
+ QLineEdit,
+ QListWidget,
+ QVBoxLayout,
+)
+
+
+class VariableSelectionDialog(QDialog):
+ """Dialog for searching and selecting a variable from a list.
+
+ Provides a search bar, an SFR toggle to switch between firmware variables
+ and Special Function Registers, and a list to select from.
+ Double-clicking or pressing OK selects the highlighted variable.
+ """
+
+ def __init__(self, variables: List[str], parent=None, sfr_variables: Optional[List[str]] = None):
+ """Initialize the variable selection dialog.
+
+ Args:
+ variables: A list of firmware variable names to select from.
+ parent: The parent widget.
+ 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.
+ """
+ super().__init__(parent)
+ self._variables = variables
+ self._sfr_variables: List[str] = sfr_variables or []
+ self._active_list = self._variables
+
+ self.selected_variable: Optional[str] = None
+ self.sfr_selected: bool = False # True when the selected name is an SFR
+
+ self._init_ui()
+
+ def _init_ui(self):
+ """Initialize the user interface components."""
+ self.setWindowTitle("Search Variable")
+ self.setMinimumSize(300, 400)
+
+ layout = QVBoxLayout()
+
+ # --- Search bar + SFR toggle row ---
+ search_row = QHBoxLayout()
+
+ self.search_bar = QLineEdit(self)
+ self.search_bar.setPlaceholderText("Search...")
+ self.search_bar.textChanged.connect(self._filter_variables)
+ search_row.addWidget(self.search_bar)
+
+ self.sfr_checkbox = QCheckBox("SFR", self)
+ self.sfr_checkbox.setEnabled(bool(self._sfr_variables))
+ self.sfr_checkbox.setToolTip(
+ "Search Special Function Registers instead of firmware variables"
+ )
+ self.sfr_checkbox.stateChanged.connect(self._on_sfr_toggled)
+ search_row.addWidget(self.sfr_checkbox)
+
+ layout.addLayout(search_row)
+
+ # Variable list
+ self.variable_list = QListWidget(self)
+ self.variable_list.addItems(self._active_list)
+ self.variable_list.itemDoubleClicked.connect(self._accept_selection)
+ layout.addWidget(self.variable_list)
+
+ # OK/Cancel buttons
+ self.button_box = QDialogButtonBox(
+ QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self
+ )
+ self.button_box.accepted.connect(self._accept_selection)
+ self.button_box.rejected.connect(self.reject)
+ layout.addWidget(self.button_box)
+
+ self.setLayout(layout)
+
+ def _on_sfr_toggled(self, state: int):
+ """Switch the active variable list when the SFR checkbox changes.
+
+ Args:
+ state: Qt checkbox state (Qt.Checked or Qt.Unchecked).
+ """
+ from PyQt5.QtCore import Qt
+
+ self._active_list = (
+ self._sfr_variables if state == Qt.Checked else self._variables
+ )
+ self.search_bar.clear()
+ self._filter_variables("")
+
+ def _filter_variables(self, text: str):
+ """Filter the variables based on user input in the search bar.
+
+ Args:
+ text: The input text to filter variables.
+ """
+ self.variable_list.clear()
+ filtered = [var for var in self._active_list if text.lower() in var.lower()]
+ self.variable_list.addItems(filtered)
+
+ def _accept_selection(self):
+ """Accept the selection when a variable is chosen from the list."""
+ selected_items = self.variable_list.selectedItems()
+ if selected_items:
+ self.selected_variable = selected_items[0].text()
+ self.sfr_selected = self.sfr_checkbox.isChecked()
+ self.accept()
diff --git a/pyx2cscope/gui/qt/main_window.py b/pyx2cscope/gui/qt/main_window.py
new file mode 100644
index 00000000..c9490c49
--- /dev/null
+++ b/pyx2cscope/gui/qt/main_window.py
@@ -0,0 +1,515 @@
+"""Main window for the Qt GUI application."""
+
+import logging
+import os
+
+from PyQt5 import QtGui
+from PyQt5.QtCore import QSettings, Qt
+from PyQt5.QtWidgets import (
+ QApplication,
+ QHBoxLayout,
+ QLabel,
+ QMainWindow,
+ QMessageBox,
+ QPushButton,
+ QSplitter,
+ QStyleFactory,
+ QTabWidget,
+ QVBoxLayout,
+ QWidget,
+)
+
+import pyx2cscope
+from pyx2cscope.gui import img as img_src
+from pyx2cscope.gui.qt.controllers.config_manager import ConfigManager
+from pyx2cscope.gui.qt.controllers.connection_manager import ConnectionManager
+from pyx2cscope.gui.qt.models.app_state import AppState
+from pyx2cscope.gui.qt.tabs.scope_view_tab import ScopeViewTab
+from pyx2cscope.gui.qt.tabs.scripting_tab import ScriptingTab
+from pyx2cscope.gui.qt.tabs.setup_tab import SetupTab
+from pyx2cscope.gui.qt.tabs.watch_view_tab import WatchViewTab
+from pyx2cscope.gui.qt.workers.data_poller import DataPoller
+
+
+class MainWindow(QMainWindow):
+ """Main application window for pyX2Cscope GUI.
+
+ Orchestrates all components:
+ - Connection management
+ - Data polling worker
+ - Tab widgets for different views
+ - Configuration save/load
+ """
+
+ def __init__(self, parent=None):
+ """Initialize the main window."""
+ super().__init__(parent)
+
+ # Initialize settings
+ self._settings = QSettings("Microchip", "pyX2Cscope")
+
+ # Initialize app state
+ self._app_state = AppState(self)
+
+ # Initialize controllers
+ self._connection_manager = ConnectionManager(self._app_state, self)
+ self._config_manager = ConfigManager(self)
+
+ # Initialize data poller (but don't start yet)
+ self._data_poller = DataPoller(self._app_state, self)
+
+ # Setup UI
+ self._setup_ui()
+ self._setup_connections()
+
+ # Start data poller thread
+ self._data_poller.start()
+
+ # Refresh ports on startup
+ self._refresh_ports()
+
+ def _setup_ui(self): # noqa: PLR0915
+ """Set up the user interface."""
+ QApplication.setStyle(QStyleFactory.create("Fusion"))
+
+ # Central widget
+ central_widget = QWidget(self)
+ self.setCentralWidget(central_widget)
+ main_layout = QVBoxLayout(central_widget)
+
+ # Create tabs
+ self._tab_widget = QTabWidget()
+ main_layout.addWidget(self._tab_widget)
+
+ # Tab 1: Setup
+ self._setup_tab = SetupTab(self._app_state, self)
+ self._tab_widget.addTab(self._setup_tab, "Setup")
+
+ # Tab 2: Data Views (contains WatchView and/or ScopeView)
+ self._data_views_tab = QWidget()
+ data_views_layout = QVBoxLayout(self._data_views_tab)
+ data_views_layout.setContentsMargins(5, 5, 5, 5) # left, top, right, bottom
+
+ # Top bar: Toggle buttons and Save/Load buttons
+ top_bar_layout = QHBoxLayout()
+ top_bar_layout.setContentsMargins(10, 10, 10, 10) # Add bottom padding
+
+ # Toggle button style
+ toggle_style = """
+ QPushButton {
+ border: 1px solid #999;
+ border-radius: 4px;
+ padding: 4px 8px;
+ background-color: #f0f0f0;
+ }
+ QPushButton:checked {
+ background-color: #0078d4;
+ color: white;
+ border: 1px solid #0078d4;
+ }
+ QPushButton:hover {
+ border: 1px solid #0078d4;
+ }
+ """
+
+ # WatchView toggle button
+ self._watch_view_btn = QPushButton("WatchView")
+ self._watch_view_btn.setCheckable(True)
+ self._watch_view_btn.setChecked(False) # Start disabled
+ self._watch_view_btn.setFixedSize(100, 28)
+ self._watch_view_btn.setStyleSheet(toggle_style)
+ self._watch_view_btn.clicked.connect(self._on_view_toggle_changed)
+ top_bar_layout.addWidget(self._watch_view_btn)
+
+ # ScopeView toggle button
+ self._scope_view_btn = QPushButton("ScopeView")
+ self._scope_view_btn.setCheckable(True)
+ self._scope_view_btn.setChecked(False) # Start disabled
+ self._scope_view_btn.setFixedSize(100, 28)
+ self._scope_view_btn.setStyleSheet(toggle_style)
+ self._scope_view_btn.clicked.connect(self._on_view_toggle_changed)
+ top_bar_layout.addWidget(self._scope_view_btn)
+
+ top_bar_layout.addStretch()
+
+ # Save/Load buttons
+ self._save_button = QPushButton("Save Config")
+ self._save_button.setFixedSize(100, 28)
+ self._save_button.clicked.connect(self._save_config)
+ self._load_button = QPushButton("Load Config")
+ self._load_button.setFixedSize(100, 28)
+ self._load_button.clicked.connect(self._load_config)
+ top_bar_layout.addWidget(self._save_button)
+ top_bar_layout.addWidget(self._load_button)
+ data_views_layout.addLayout(top_bar_layout)
+
+ # Create the views
+ self._watch_view_tab = WatchViewTab(self._app_state, self)
+ self._scope_view_tab = ScopeViewTab(self._app_state, self)
+
+ # Instruction screen (shown when no view is selected)
+ self._instruction_widget = QWidget()
+ instruction_layout = QVBoxLayout(self._instruction_widget)
+ instruction_layout.setAlignment(Qt.AlignCenter)
+ instruction_label = QLabel(
+ "
Use the toggle buttons above to select which views to display:
" + "WatchView: Monitor and modify variable values in real-time.
"
+ "Add variables, set scaling/offset, and write values directly.
ScopeView: Capture and visualize variable waveforms.
"
+ "Configure trigger settings and sample multiple channels.
Select both buttons to display a split view.
" + ) + instruction_label.setAlignment(Qt.AlignCenter) + instruction_label.setWordWrap(True) + instruction_label.setStyleSheet("color: #666; padding: 40px;") + instruction_layout.addWidget(instruction_label) + + # Splitter for combined view (horizontal for better usability) + self._view_splitter = QSplitter(Qt.Horizontal) + self._view_splitter.addWidget(self._watch_view_tab) + self._view_splitter.addWidget(self._scope_view_tab) + self._view_splitter.setStretchFactor(0, 1) # 50/50 split + self._view_splitter.setStretchFactor(1, 1) + + data_views_layout.addWidget(self._instruction_widget) + data_views_layout.addWidget(self._view_splitter) + + self._tab_widget.addTab(self._data_views_tab, "Data Views") + + # Tab 3: Scripting + self._scripting_tab = ScriptingTab(self._app_state, self) + self._tab_widget.addTab(self._scripting_tab, "Scripting") + + # Set initial view (Both selected) + self._on_view_toggle_changed() + + # Window properties + self.setWindowTitle(f"pyX2Cscope - v{pyx2cscope.__version__}") + icon_path = os.path.join(os.path.dirname(img_src.__file__), "pyx2cscope.jpg") + if os.path.exists(icon_path): + self.setWindowIcon(QtGui.QIcon(icon_path)) + + # Restore window state from settings + self._restore_window_state() + + def _setup_connections(self): + """Set up signal/slot connections.""" + # Connection manager signals + self._connection_manager.connection_changed.connect(self._on_connection_changed) + self._connection_manager.error_occurred.connect(self._show_error) + self._connection_manager.ports_refreshed.connect(self._on_ports_refreshed) + + # App state signals + self._app_state.connection_changed.connect(self._on_connection_changed) + self._app_state.variable_list_updated.connect(self._on_variable_list_updated) + + # Data poller signals + self._data_poller.scope_data_ready.connect(self._scope_view_tab.on_scope_data_ready) + self._data_poller.live_var_updated.connect(self._watch_view_tab.on_live_var_updated) + self._data_poller.error_occurred.connect(self._show_error) + + # Config manager signals + self._config_manager.error_occurred.connect(self._show_error) + + # Setup tab signals + self._setup_tab.connect_requested.connect(self._on_connect_clicked) + self._setup_tab.elf_file_selected.connect(self._on_elf_file_selected) + self._setup_tab.refresh_btn.clicked.connect(self._refresh_ports) + + # 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) + + def _refresh_ports(self): + """Refresh available COM ports.""" + self._connection_manager.refresh_ports() + + def _on_ports_refreshed(self, ports: list): + """Handle ports refreshed signal.""" + self._setup_tab.set_ports(ports) + + def _on_elf_file_selected(self, file_path: str): + """Handle ELF file selection from setup tab.""" + self._settings.setValue("elf_file_path", file_path) + + def _on_connect_clicked(self): + """Handle connect button click.""" + elf_path = self._setup_tab.elf_file_path + if not elf_path: + # Try to load from settings + elf_path = self._settings.value("elf_file_path", "", type=str) + if elf_path: + self._setup_tab.elf_file_path = elf_path + + if not elf_path: + self._setup_tab.set_loading(False) + self._show_error("Please select an ELF file first.") + return + + # Get connection parameters based on selected interface + conn_params = self._setup_tab.get_connection_params() + + # Process events to show loading indicator before blocking operation + QApplication.processEvents() + + connected = self._connection_manager.toggle_connection( + elf_path, **conn_params + ) + + if connected: + self._setup_tab.set_connected(True) + self._setup_tab.save_connection_settings() + self._update_device_info() + else: + self._setup_tab.set_connected(False) + + def _on_connection_changed(self, connected: bool): + """Handle connection state change.""" + self._setup_tab.set_connected(connected) + + # Update tabs + self._scope_view_tab.on_connection_changed(connected) + self._watch_view_tab.on_connection_changed(connected) + self._scripting_tab.on_connection_changed(connected) + + if connected: + self._update_device_info() + else: + self._clear_device_info() + + def _on_variable_list_updated(self, variables: list): + """Handle variable list update.""" + self._scope_view_tab.on_variable_list_updated(variables) + self._watch_view_tab.on_variable_list_updated(variables) + + def _on_view_toggle_changed(self): + """Handle view toggle button changes.""" + watch_selected = self._watch_view_btn.isChecked() + scope_selected = self._scope_view_btn.isChecked() + + if watch_selected and scope_selected: + # Both views - show splitter with both + self._instruction_widget.hide() + self._view_splitter.show() + self._watch_view_tab.show() + self._scope_view_tab.show() + elif watch_selected: + # Only WatchView + self._instruction_widget.hide() + self._view_splitter.show() + self._watch_view_tab.show() + self._scope_view_tab.hide() + elif scope_selected: + # Only ScopeView + self._instruction_widget.hide() + self._view_splitter.show() + self._watch_view_tab.hide() + self._scope_view_tab.show() + else: + # No view selected - show instruction screen + self._view_splitter.hide() + self._instruction_widget.show() + + def _on_scope_sampling_changed(self, is_sampling: bool, is_single_shot: bool): + """Handle scope sampling state change (Tab2).""" + self._data_poller.set_scope_polling_enabled(is_sampling, is_single_shot) + + def _on_live_watch_changed(self, index: int, is_live: bool): + """Handle live watch variable polling state change (Tab3).""" + if is_live: + self._data_poller.add_active_live_index(index) + else: + self._data_poller.remove_active_live_index(index) + + def _update_device_info(self): + """Update device info labels.""" + device_info = self._app_state.update_device_info() + self._setup_tab.update_device_info(device_info) + + def _clear_device_info(self): + """Clear device info labels.""" + self._setup_tab.clear_device_info() + + def _save_config(self): + """Save current configuration.""" + # Determine view mode from toggle buttons + watch_on = self._watch_view_btn.isChecked() + scope_on = self._scope_view_btn.isChecked() + if watch_on and scope_on: + view_mode = "Both" + elif watch_on: + view_mode = "WatchView" + elif scope_on: + view_mode = "ScopeView" + else: + view_mode = "None" + + # Get connection parameters + conn_params = self._setup_tab.get_connection_params() + + 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(), + view_mode=view_mode, + ) + self._config_manager.save_config(config) + + def _load_config(self): # noqa: PLR0912, PLR0915 + """Load configuration from file.""" + config = self._config_manager.load_config() + if not config: + return + + # Load ELF file + elf_path = config.get("elf_file", "") + if elf_path: + if self._config_manager.validate_elf_file(elf_path): + self._setup_tab.elf_file_path = elf_path + else: + self._config_manager.show_file_not_found_warning(elf_path) + new_path = self._config_manager.prompt_for_elf_file() + if new_path: + self._setup_tab.elf_file_path = new_path + + # Load connection settings (new format with interface support) + conn_params = config.get("connection", {}) + if conn_params: + self._setup_tab.set_connection_params(conn_params) + + # For UART, also set the port if available + if conn_params.get("interface") == "UART": + com_port = conn_params.get("port", "") + port_combo = self._setup_tab.port_combo + if com_port and com_port in [port_combo.itemText(i) for i in range(port_combo.count())]: + port_combo.setCurrentText(com_port) + else: + # Legacy config format support + baud_rate = config.get("baud_rate", "115200") + self._setup_tab.baud_combo.setCurrentText(baud_rate) + com_port = config.get("com_port", "") + port_combo = self._setup_tab.port_combo + if com_port and com_port in [port_combo.itemText(i) for i in range(port_combo.count())]: + port_combo.setCurrentText(com_port) + + # Try to connect (only if not already connected) + 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 view mode and set toggle buttons + view_mode = config.get("view_mode", "Both") + if view_mode == "Both": + self._watch_view_btn.setChecked(True) + self._scope_view_btn.setChecked(True) + elif view_mode == "WatchView": + self._watch_view_btn.setChecked(True) + self._scope_view_btn.setChecked(False) + elif view_mode == "ScopeView": + self._watch_view_btn.setChecked(False) + self._scope_view_btn.setChecked(True) + else: # None + self._watch_view_btn.setChecked(False) + self._scope_view_btn.setChecked(False) + self._on_view_toggle_changed() + + # Re-enable widgets after loading config (for dynamically created widgets) + is_connected = self._app_state.is_connected() + if is_connected: + self._scope_view_tab.on_connection_changed(True) + self._watch_view_tab.on_connection_changed(True) + + # Also ensure variable list is populated in tabs + variables = self._app_state.get_variable_list() + if variables: + self._scope_view_tab.on_variable_list_updated(variables) + self._watch_view_tab.on_variable_list_updated(variables) + + # Activate polling for any live checkboxes that were loaded as checked + self._activate_loaded_polling() + + def _activate_loaded_polling(self): + """Activate polling for any live checkboxes that were loaded as checked.""" + # WatchView tab - check live checkboxes + for i, cb in enumerate(self._watch_view_tab._live_checkboxes): + if cb.isChecked(): + self._data_poller.add_active_live_index(i) + + def _show_error(self, message: str): + """Show error message to user.""" + logging.error(message) + QMessageBox.critical(self, "Error", message) + + def _save_window_state(self): + """Save window geometry and state to settings.""" + self._settings.setValue("window/geometry", self.saveGeometry()) + self._settings.setValue("window/state", self.saveState()) + self._settings.setValue("window/splitter_sizes", self._view_splitter.sizes()) + self._settings.setValue("window/watch_view_checked", self._watch_view_btn.isChecked()) + self._settings.setValue("window/scope_view_checked", self._scope_view_btn.isChecked()) + self._settings.setValue("window/current_tab", self._tab_widget.currentIndex()) + + def _restore_window_state(self): + """Restore window geometry and state from settings.""" + # Restore window geometry + geometry = self._settings.value("window/geometry") + if geometry: + self.restoreGeometry(geometry) + + # Restore window state + state = self._settings.value("window/state") + if state: + self.restoreState(state) + + # Restore splitter sizes + splitter_sizes = self._settings.value("window/splitter_sizes") + if splitter_sizes: + # Convert to list of ints if needed + if isinstance(splitter_sizes, list): + sizes = [int(s) for s in splitter_sizes] + self._view_splitter.setSizes(sizes) + + # Restore toggle button states + watch_checked = self._settings.value("window/watch_view_checked", False, type=bool) + scope_checked = self._settings.value("window/scope_view_checked", False, type=bool) + self._watch_view_btn.setChecked(watch_checked) + self._scope_view_btn.setChecked(scope_checked) + self._on_view_toggle_changed() + + # Always start on Setup tab + self._tab_widget.setCurrentIndex(0) + + def closeEvent(self, event): # noqa: N802 + """Handle window close event.""" + # Save window state before closing + self._save_window_state() + + # Stop data poller + self._data_poller.stop() + + # Disconnect if connected + if self._connection_manager.is_connected(): + self._connection_manager.disconnect() + + event.accept() + + +def execute_qt(): + """Entry point for the Qt application.""" + import sys + + from PyQt5.QtWidgets import QApplication + + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + execute_qt() diff --git a/pyx2cscope/gui/qt/models/__init__.py b/pyx2cscope/gui/qt/models/__init__.py new file mode 100644 index 00000000..728feb78 --- /dev/null +++ b/pyx2cscope/gui/qt/models/__init__.py @@ -0,0 +1,5 @@ +"""Data models for the generic GUI.""" + +from .app_state import AppState, ScopeChannel, TriggerSettings, WatchVariable + +__all__ = ["AppState", "WatchVariable", "ScopeChannel", "TriggerSettings"] diff --git a/pyx2cscope/gui/qt/models/app_state.py b/pyx2cscope/gui/qt/models/app_state.py new file mode 100644 index 00000000..9f70f135 --- /dev/null +++ b/pyx2cscope/gui/qt/models/app_state.py @@ -0,0 +1,793 @@ +"""Centralized application state management. + +This module provides thread-safe state management for the generic GUI, +inspired by the WebScope pattern from the web GUI. +""" + +import logging +from collections import deque +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from PyQt5.QtCore import QMutex, QObject, pyqtSignal + +from pyx2cscope.x2cscope import TriggerConfig, X2CScope + + +@dataclass +class WatchVariable: + """Represents a watch variable configuration.""" + + name: str = "" + value: float = 0.0 + scaling: float = 1.0 + offset: float = 0.0 + unit: str = "" + live: bool = False + plot_enabled: bool = False + sfr: bool = False # True when the variable is a Special Function Register + _var_ref: Any = field(default=None, repr=False) # Cached x2cscope variable reference + + @property + def scaled_value(self) -> float: + """Calculate the scaled value.""" + return (self.value * self.scaling) + self.offset + + @property + def var_ref(self): + """Get the cached x2cscope variable reference.""" + return self._var_ref + + @var_ref.setter + def var_ref(self, value): + """Set the cached x2cscope variable reference.""" + self._var_ref = value + + +@dataclass +class ScopeChannel: + """Represents a scope channel configuration.""" + + name: str = "" + trigger: bool = False + gain: float = 1.0 + visible: bool = True + sfr: bool = False # True when the variable is a Special Function Register + + +@dataclass +class TriggerSettings: + """Trigger configuration settings.""" + + mode: str = "Auto" # "Auto" or "Triggered" + edge: str = "Rising" # "Rising" or "Falling" + level: float = 0.0 + delay: int = 0 + variable: Optional[str] = None + + +@dataclass +class DeviceInfo: + """Device information.""" + + processor_id: str = "" + uc_width: str = "" + date: str = "" + time: str = "" + app_ver: str = "" + dsp_state: str = "" + + +class AppState(QObject): + """Centralized application state management. + + Provides thread-safe access to all application state including: + - Connection status and device info + - Watch variables (Tab1 and Tab3) + - Scope channels and trigger settings (Tab2) + - Plot data accumulation + + Similar to WebScope in the web GUI, this class uses a mutex + to ensure thread-safe operations. + """ + + # Signals for state changes + connection_changed = pyqtSignal(bool) + device_info_updated = pyqtSignal(dict) + variable_list_updated = pyqtSignal(list) + + MAX_WATCH_VARS = 5 + MAX_SCOPE_CHANNELS = 8 + PLOT_DATA_MAXLEN = 250 + + def __init__(self, parent=None): + """Initialize the application state.""" + super().__init__(parent) + self._mutex = QMutex() + self._x2cscope: Optional[X2CScope] = None + + # Connection state + self._port: str = "" + self._baud_rate: int = 115200 + self._elf_file: str = "" + self._connected: bool = False + + # Device info + self._device_info = DeviceInfo() + + # Variable list cache + self._variable_list: List[str] = [] + + # Watch variables (Tab1 - WatchPlot) + self._watch_vars: List[WatchVariable] = [ + WatchVariable() for _ in range(self.MAX_WATCH_VARS) + ] + + # Scope channels (Tab2 - ScopeView) + self._scope_channels: List[ScopeChannel] = [ + ScopeChannel() for _ in range(self.MAX_SCOPE_CHANNELS) + ] + + # Trigger settings + self._trigger_settings = TriggerSettings() + + # Scope state + self._scope_active: bool = False + self._scope_single_shot: bool = False + self._sample_time_factor: int = 1 + self._scope_sample_time_us: int = 50 + self._real_sample_time: float = 0.0 + + # Dynamic watch variables (Tab3 - WatchView) + self._live_watch_vars: List[WatchVariable] = [] + + # Plot data accumulator + self._plot_data: deque = deque(maxlen=self.PLOT_DATA_MAXLEN) + + # Timing configuration + self._watch_poll_interval_ms: int = 500 + + # ============= Connection Management ============= + + @property + def x2cscope(self) -> Optional[X2CScope]: + """Get X2CScope instance (not thread-safe, use with caution).""" + return self._x2cscope + + def set_x2cscope(self, x2cscope: Optional[X2CScope]): + """Set the X2CScope instance (thread-safe).""" + self._mutex.lock() + try: + 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 = [] + self._connected = False + finally: + self._mutex.unlock() + self.connection_changed.emit(self._connected) + self.variable_list_updated.emit(self._variable_list) + + def is_connected(self) -> bool: + """Check if connected to device (thread-safe).""" + self._mutex.lock() + try: + return self._connected and self._x2cscope is not None + finally: + self._mutex.unlock() + + def get_variable_list(self) -> List[str]: + """Get cached variable list (thread-safe).""" + self._mutex.lock() + try: + return self._variable_list.copy() + finally: + self._mutex.unlock() + + def get_sfr_list(self) -> List[str]: + """Get the list of SFR (Special Function Register) names (thread-safe).""" + self._mutex.lock() + try: + if self._x2cscope: + return self._x2cscope.list_sfr() + return [] + finally: + self._mutex.unlock() + + def update_device_info(self) -> Optional[DeviceInfo]: + """Fetch and update device info (thread-safe).""" + self._mutex.lock() + try: + if not self._x2cscope: + return None + info = self._x2cscope.get_device_info() + self._device_info = DeviceInfo( + processor_id=str(info.get("processor_id", "")), + uc_width=str(info.get("uc_width", "")), + date=str(info.get("date", "")), + time=str(info.get("time", "")), + app_ver=str(info.get("AppVer", "")), + dsp_state=str(info.get("dsp_state", "")), + ) + return self._device_info + except Exception as e: + logging.error(f"Error fetching device info: {e}") + return None + finally: + self._mutex.unlock() + + def get_device_info(self) -> DeviceInfo: + """Get cached device info (thread-safe).""" + self._mutex.lock() + try: + return self._device_info + finally: + self._mutex.unlock() + + # ============= Connection Properties ============= + + @property + def port(self) -> str: + """Get the current port.""" + return self._port + + @port.setter + def port(self, value: str): + self._mutex.lock() + self._port = value + self._mutex.unlock() + + @property + def baud_rate(self) -> int: + """Get the current baud rate.""" + return self._baud_rate + + @baud_rate.setter + def baud_rate(self, value: int): + self._mutex.lock() + self._baud_rate = value + self._mutex.unlock() + + @property + def elf_file(self) -> str: + """Get the current ELF file path.""" + return self._elf_file + + @elf_file.setter + def elf_file(self, value: str): + self._mutex.lock() + self._elf_file = value + self._mutex.unlock() + + # ============= Variable Read/Write ============= + + def read_variable(self, name: str, sfr: bool = False) -> Optional[float]: + """Read a variable value from the device (thread-safe). + + Args: + name: The variable name to read. + sfr: When True, look up the name in the SFR register map. + """ + if not name or name == "None": + return None + self._mutex.lock() + try: + if self._x2cscope: + var = self._x2cscope.get_variable(name, sfr=sfr) + if var is not None: + return var.get_value() + except Exception as e: + logging.error(f"Error reading variable {name}: {e}") + finally: + self._mutex.unlock() + return None + + def read_watch_var_value(self, index: int) -> Optional[float]: + """Read a watch variable value using cached var_ref (thread-safe).""" + self._mutex.lock() + try: + if 0 <= index < len(self._watch_vars): + 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: + # Fallback: get and cache the variable + var_ref = self._x2cscope.get_variable(watch_var.name) + if var_ref is not None: + watch_var.var_ref = var_ref + return var_ref.get_value() + except Exception as e: + logging.error(f"Error reading watch var {index}: {e}") + finally: + self._mutex.unlock() + return None + + def read_live_watch_var_value(self, index: int) -> Optional[float]: + """Read a live watch variable value using cached var_ref (thread-safe).""" + self._mutex.lock() + try: + if 0 <= index < len(self._live_watch_vars): + 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: + # Fallback: get and cache the variable + var_ref = self._x2cscope.get_variable(live_var.name) + if var_ref is not None: + live_var.var_ref = var_ref + return var_ref.get_value() + except Exception as e: + logging.error(f"Error reading live watch var {index}: {e}") + finally: + self._mutex.unlock() + return None + + def write_variable(self, name: str, value: float, sfr: bool = False) -> bool: + """Write a variable value to the device (thread-safe). + + Args: + name: The variable name to write. + value: The value to write. + sfr: When True, look up the name in the SFR register map. + """ + if not name or name == "None": + return False + self._mutex.lock() + try: + if self._x2cscope: + var = self._x2cscope.get_variable(name, sfr=sfr) + if var is not None: + var.set_value(value) + return True + except Exception as e: + logging.error(f"Error writing variable {name}: {e}") + finally: + self._mutex.unlock() + return False + + # ============= Watch Variables (Tab1) ============= + + def get_watch_var(self, index: int) -> WatchVariable: + """Get watch variable at index (thread-safe).""" + self._mutex.lock() + try: + if 0 <= index < len(self._watch_vars): + return self._watch_vars[index] + return WatchVariable() + finally: + self._mutex.unlock() + + def set_watch_var(self, index: int, var: WatchVariable): + """Set watch variable at index (thread-safe).""" + self._mutex.lock() + try: + if 0 <= index < len(self._watch_vars): + self._watch_vars[index] = var + finally: + self._mutex.unlock() + + def update_watch_var_field(self, index: int, field: str, value: Any): + """Update a specific field of a watch variable (thread-safe).""" + self._mutex.lock() + try: + 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: + sfr = self._watch_vars[index].sfr + var_ref = self._x2cscope.get_variable(value, sfr=sfr) + if var_ref is not None: + self._watch_vars[index].var_ref = var_ref + finally: + self._mutex.unlock() + + def get_active_watch_vars(self) -> List[Dict[str, Any]]: + """Get all watch variables with live=True (thread-safe).""" + self._mutex.lock() + try: + return [ + {"index": i, "name": v.name, "live": v.live} + for i, v in enumerate(self._watch_vars) + if v.live and v.name and v.name != "None" + ] + finally: + self._mutex.unlock() + + def get_all_watch_vars(self) -> List[WatchVariable]: + """Get all watch variables (thread-safe copy).""" + self._mutex.lock() + try: + return [ + WatchVariable( + name=v.name, + value=v.value, + scaling=v.scaling, + offset=v.offset, + unit=v.unit, + live=v.live, + plot_enabled=v.plot_enabled, + ) + for v in self._watch_vars + ] + finally: + self._mutex.unlock() + + # ============= Scope Channels (Tab2) ============= + + def get_scope_channel(self, index: int) -> ScopeChannel: + """Get scope channel at index (thread-safe).""" + self._mutex.lock() + try: + if 0 <= index < len(self._scope_channels): + return self._scope_channels[index] + return ScopeChannel() + finally: + self._mutex.unlock() + + def set_scope_channel(self, index: int, channel: ScopeChannel): + """Set scope channel at index (thread-safe).""" + self._mutex.lock() + try: + if 0 <= index < len(self._scope_channels): + self._scope_channels[index] = channel + finally: + self._mutex.unlock() + + def update_scope_channel_field(self, index: int, field: str, value: Any): + """Update a specific field of a scope channel (thread-safe).""" + self._mutex.lock() + try: + if 0 <= index < len(self._scope_channels): + setattr(self._scope_channels[index], field, value) + finally: + self._mutex.unlock() + + def get_all_scope_channels(self) -> List[ScopeChannel]: + """Get all scope channels (thread-safe copy).""" + self._mutex.lock() + try: + return [ + ScopeChannel( + name=c.name, trigger=c.trigger, gain=c.gain, visible=c.visible + ) + for c in self._scope_channels + ] + finally: + self._mutex.unlock() + + def get_trigger_variable(self) -> Optional[str]: + """Get the currently selected trigger variable (thread-safe).""" + self._mutex.lock() + try: + for channel in self._scope_channels: + if channel.trigger and channel.name: + return channel.name + return None + finally: + self._mutex.unlock() + + # ============= Trigger Settings ============= + + def get_trigger_settings(self) -> TriggerSettings: + """Get trigger settings (thread-safe).""" + self._mutex.lock() + try: + return TriggerSettings( + mode=self._trigger_settings.mode, + edge=self._trigger_settings.edge, + level=self._trigger_settings.level, + delay=self._trigger_settings.delay, + variable=self._trigger_settings.variable, + ) + finally: + self._mutex.unlock() + + def set_trigger_settings(self, settings: TriggerSettings): + """Set trigger settings (thread-safe).""" + self._mutex.lock() + try: + self._trigger_settings = settings + finally: + self._mutex.unlock() + + # ============= Scope Operations ============= + + def start_scope(self) -> bool: + """Start scope sampling (thread-safe).""" + self._mutex.lock() + try: + if not self._x2cscope: + return False + + # Clear existing channels + self._x2cscope.clear_all_scope_channel() + + # Add configured channels + for channel in self._scope_channels: + if channel.name and channel.name != "None": + variable = self._x2cscope.get_variable(channel.name, sfr=channel.sfr) + if variable is not None: + self._x2cscope.add_scope_channel(variable) + + # Set sample time + self._x2cscope.set_sample_time(self._sample_time_factor) + self._real_sample_time = self._x2cscope.get_scope_sample_time( + self._scope_sample_time_us + ) + + self._scope_active = True + return True + except Exception as e: + logging.error(f"Error starting scope: {e}") + return False + finally: + self._mutex.unlock() + + def configure_scope_trigger(self) -> bool: + """Configure scope trigger (thread-safe).""" + self._mutex.lock() + try: + if not self._x2cscope: + return False + + trigger_var_name = self.get_trigger_variable() + if self._trigger_settings.mode == "Auto": + self._x2cscope.reset_scope_trigger() + return True + + if trigger_var_name: + # Find sfr flag for the trigger channel + trigger_sfr = next( + (ch.sfr for ch in self._scope_channels if ch.trigger and ch.name == trigger_var_name), + False, + ) + variable = self._x2cscope.get_variable(trigger_var_name, sfr=trigger_sfr) + if variable is not None: + trigger_edge = ( + 0 if self._trigger_settings.edge == "Rising" else 1 + ) + trigger_mode = 1 # Triggered mode + + trigger_config = TriggerConfig( + variable=variable, + trigger_level=self._trigger_settings.level, + trigger_mode=trigger_mode, + trigger_delay=self._trigger_settings.delay, + trigger_edge=trigger_edge, + ) + self._x2cscope.set_scope_trigger(trigger_config) + return True + else: + self._x2cscope.reset_scope_trigger() + return True + except Exception as e: + logging.error(f"Error configuring trigger: {e}") + return False + finally: + self._mutex.unlock() + + def request_scope_data(self): + """Request scope data (thread-safe).""" + self._mutex.lock() + try: + if self._x2cscope: + self._x2cscope.request_scope_data() + finally: + self._mutex.unlock() + + def stop_scope(self): + """Stop scope sampling (thread-safe).""" + self._mutex.lock() + try: + self._scope_active = False + if self._x2cscope: + self._x2cscope.clear_all_scope_channel() + finally: + self._mutex.unlock() + + def is_scope_active(self) -> bool: + """Check if scope is actively sampling (thread-safe).""" + self._mutex.lock() + try: + return self._scope_active + finally: + self._mutex.unlock() + + def is_scope_data_ready(self) -> bool: + """Check if scope data is ready (thread-safe).""" + self._mutex.lock() + try: + if self._x2cscope: + return self._x2cscope.is_scope_data_ready() + except Exception as e: + logging.error(f"Error checking scope data ready: {e}") + finally: + self._mutex.unlock() + return False + + def get_scope_channel_data(self) -> Dict[str, List[float]]: + """Get scope channel data (thread-safe).""" + self._mutex.lock() + try: + if self._x2cscope: + return self._x2cscope.get_scope_channel_data() + except Exception as e: + logging.error(f"Error getting scope data: {e}") + finally: + self._mutex.unlock() + return {} + + # ============= Scope Settings Properties ============= + + @property + def scope_single_shot(self) -> bool: + """Get the scope single shot mode setting.""" + return self._scope_single_shot + + @scope_single_shot.setter + def scope_single_shot(self, value: bool): + self._mutex.lock() + self._scope_single_shot = value + self._mutex.unlock() + + @property + def sample_time_factor(self) -> int: + """Get the sample time factor.""" + return self._sample_time_factor + + @sample_time_factor.setter + def sample_time_factor(self, value: int): + self._mutex.lock() + self._sample_time_factor = value + self._mutex.unlock() + + @property + def scope_sample_time_us(self) -> int: + """Get the scope sample time in microseconds.""" + return self._scope_sample_time_us + + @scope_sample_time_us.setter + def scope_sample_time_us(self, value: int): + self._mutex.lock() + self._scope_sample_time_us = value + self._mutex.unlock() + + @property + def real_sample_time(self) -> float: + """Get the real sample time.""" + return self._real_sample_time + + # ============= Tab3 Live Variables ============= + + def add_live_watch_var(self) -> int: + """Add a new live watch variable row (thread-safe).""" + self._mutex.lock() + try: + self._live_watch_vars.append(WatchVariable()) + return len(self._live_watch_vars) - 1 + finally: + self._mutex.unlock() + + def remove_live_watch_var(self, index: int): + """Remove a live watch variable row (thread-safe).""" + self._mutex.lock() + try: + if 0 <= index < len(self._live_watch_vars): + self._live_watch_vars.pop(index) + finally: + self._mutex.unlock() + + def get_live_watch_var(self, index: int) -> WatchVariable: + """Get live watch variable at index (thread-safe).""" + self._mutex.lock() + try: + if 0 <= index < len(self._live_watch_vars): + return self._live_watch_vars[index] + return WatchVariable() + finally: + self._mutex.unlock() + + def update_live_watch_var_field(self, index: int, field: str, value: Any): + """Update a specific field of a live watch variable (thread-safe).""" + self._mutex.lock() + try: + 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: + 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 + finally: + self._mutex.unlock() + + def get_active_live_watch_vars(self) -> List[Dict[str, Any]]: + """Get all live watch variables with live=True (thread-safe).""" + self._mutex.lock() + try: + return [ + {"index": i, "name": v.name, "live": v.live} + for i, v in enumerate(self._live_watch_vars) + if v.live and v.name and v.name != "None" + ] + finally: + self._mutex.unlock() + + def get_all_live_watch_vars(self) -> List[WatchVariable]: + """Get all live watch variables (thread-safe copy).""" + self._mutex.lock() + try: + return [ + WatchVariable( + name=v.name, + value=v.value, + scaling=v.scaling, + offset=v.offset, + unit=v.unit, + live=v.live, + plot_enabled=v.plot_enabled, + ) + for v in self._live_watch_vars + ] + finally: + self._mutex.unlock() + + def get_live_watch_var_count(self) -> int: + """Get count of live watch variables (thread-safe).""" + self._mutex.lock() + try: + return len(self._live_watch_vars) + finally: + self._mutex.unlock() + + def clear_live_watch_vars(self): + """Clear all live watch variables (thread-safe).""" + self._mutex.lock() + try: + self._live_watch_vars.clear() + finally: + self._mutex.unlock() + + # ============= Plot Data ============= + + def append_plot_data(self, data: tuple): + """Append data to plot buffer (thread-safe).""" + self._mutex.lock() + try: + self._plot_data.append(data) + finally: + self._mutex.unlock() + + def get_plot_data(self) -> List[tuple]: + """Get plot data (thread-safe copy).""" + self._mutex.lock() + try: + return list(self._plot_data) + finally: + self._mutex.unlock() + + def clear_plot_data(self): + """Clear plot data buffer (thread-safe).""" + self._mutex.lock() + try: + self._plot_data.clear() + finally: + self._mutex.unlock() + + # ============= Timing Configuration ============= + + @property + def watch_poll_interval_ms(self) -> int: + """Get the watch poll interval in milliseconds.""" + return self._watch_poll_interval_ms + + @watch_poll_interval_ms.setter + def watch_poll_interval_ms(self, value: int): + self._mutex.lock() + self._watch_poll_interval_ms = max(50, value) + self._mutex.unlock() diff --git a/pyx2cscope/gui/qt/tabs/__init__.py b/pyx2cscope/gui/qt/tabs/__init__.py new file mode 100644 index 00000000..786e0f1f --- /dev/null +++ b/pyx2cscope/gui/qt/tabs/__init__.py @@ -0,0 +1,9 @@ +"""Tab widgets for the Qt GUI.""" + +from .base_tab import BaseTab +from .scope_view_tab import ScopeViewTab +from .scripting_tab import ScriptingTab +from .setup_tab import SetupTab +from .watch_view_tab import WatchViewTab + +__all__ = ["BaseTab", "ScopeViewTab", "ScriptingTab", "SetupTab", "WatchViewTab"] diff --git a/pyx2cscope/gui/qt/tabs/base_tab.py b/pyx2cscope/gui/qt/tabs/base_tab.py new file mode 100644 index 00000000..595b9ab9 --- /dev/null +++ b/pyx2cscope/gui/qt/tabs/base_tab.py @@ -0,0 +1,111 @@ +"""Base tab class for the Qt GUI tabs.""" + +from typing import TYPE_CHECKING + +from PyQt5.QtCore import QRegExp +from PyQt5.QtGui import QRegExpValidator +from PyQt5.QtWidgets import QWidget + +if TYPE_CHECKING: + from pyx2cscope.gui.qt.models.app_state import AppState + + +class BaseTab(QWidget): + """Base class for all tab widgets. + + Provides common functionality and access to shared resources. + """ + + # Common colors for plots + PLOT_COLORS = ["b", "g", "r", "c", "m", "y", "k"] + + def __init__(self, app_state: "AppState", parent=None): + """Initialize the base tab. + + Args: + app_state: The centralized application state. + parent: Optional parent widget. + """ + super().__init__(parent) + self._app_state = app_state + self._setup_validators() + + def _setup_validators(self): + """Set up input validators.""" + decimal_regex = QRegExp(r"-?[0-9]+(\.[0-9]+)?") + self._decimal_validator = QRegExpValidator(decimal_regex) + + @property + def app_state(self) -> "AppState": + """Get the application state.""" + return self._app_state + + @property + def decimal_validator(self) -> QRegExpValidator: + """Get the decimal number validator.""" + return self._decimal_validator + + def on_connection_changed(self, connected: bool): + """Handle connection state changes. + + Override in subclasses to update UI based on connection state. + + Args: + connected: True if connected, False otherwise. + """ + pass + + def on_variable_list_updated(self, variables: list): + """Handle variable list updates. + + Override in subclasses to update UI when variables change. + + Args: + variables: List of available variable names. + """ + pass + + def calculate_scaled_value( + self, value: float, scaling: float, offset: float + ) -> float: + """Calculate a scaled value. + + Args: + value: The raw value. + scaling: The scaling factor. + offset: The offset to add. + + Returns: + The scaled value: (value * scaling) + offset + """ + return (value * scaling) + offset + + def safe_float(self, text: str, default: float = 0.0) -> float: + """Safely convert text to float. + + Args: + text: The text to convert. + default: Default value if conversion fails. + + Returns: + The converted float or default value. + """ + try: + return float(text) if text else default + except ValueError: + return default + + def safe_int(self, text: str, default: int = 0) -> int: + """Safely convert text to int. + + Args: + text: The text to convert. + default: Default value if conversion fails. + + Returns: + The converted int or default value. + """ + try: + return int(text) if text else default + except ValueError: + return default diff --git a/pyx2cscope/gui/qt/tabs/scope_view_tab.py b/pyx2cscope/gui/qt/tabs/scope_view_tab.py new file mode 100644 index 00000000..d19dcb6c --- /dev/null +++ b/pyx2cscope/gui/qt/tabs/scope_view_tab.py @@ -0,0 +1,540 @@ +"""ScopeView tab (Tab2) - Scope capture and trigger configuration.""" + +import logging +from typing import TYPE_CHECKING, Dict, List, Optional + +import numpy as np +import pyqtgraph as pg +from PyQt5 import QtCore +from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot +from PyQt5.QtGui import QColor, QIcon, QPixmap +from PyQt5.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QGridLayout, + QGroupBox, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QVBoxLayout, +) + +from pyx2cscope.gui.qt.dialogs.variable_selection import VariableSelectionDialog +from pyx2cscope.gui.qt.tabs.base_tab import BaseTab +from pyx2cscope.x2cscope import TriggerConfig + +if TYPE_CHECKING: + from pyx2cscope.gui.qt.models.app_state import AppState + + +class ScopeViewTab(BaseTab): + """Tab for oscilloscope-style data capture and visualization. + + Features: + - 8 scope channels with trigger selection + - Configurable trigger mode, edge, level, and delay + - Single-shot and continuous capture modes + - Real-time plotting with pyqtgraph + """ + + # Signal emitted when scope sampling state changes: (is_sampling, is_single_shot) + scope_sampling_changed = pyqtSignal(bool, bool) + + MAX_CHANNELS = 8 + + def __init__(self, app_state: "AppState", parent=None): + """Initialize the ScopeView tab. + + Args: + app_state: The centralized application state. + parent: Optional parent widget. + """ + super().__init__(app_state, parent) + self._variable_list: List[str] = [] + self._trigger_variable: Optional[str] = None + self._sampling_active: bool = False + self._real_sampletime: float = 0.0 + + # Widget lists + self._var_line_edits: List[QLineEdit] = [] + self._trigger_checkboxes: List[QCheckBox] = [] + self._scaling_edits: List[QLineEdit] = [] + self._offset_edits: List[QLineEdit] = [] + self._color_combos: List[QComboBox] = [] + self._visible_checkboxes: List[QCheckBox] = [] + + # Available colors for channels (name -> RGB tuple) + self._colors = { + "Blue": (0, 0, 255), + "Green": (0, 255, 0), + "Red": (255, 0, 0), + "Cyan": (0, 255, 255), + "Magenta": (255, 0, 255), + "Yellow": (255, 255, 0), + "Orange": (255, 165, 0), + "Purple": (128, 0, 128), + "Black": (0, 0, 0), + "White": (255, 255, 255), + } + + self._setup_ui() + + def _setup_ui(self): + """Set up the user interface.""" + layout = QVBoxLayout() + self.setLayout(layout) + + # Plot widget (at the top) + self._plot_widget = pg.PlotWidget() + self._plot_widget.setBackground("w") + self._plot_widget.showGrid(x=True, y=True) + self._plot_widget.getViewBox().setMouseMode(pg.ViewBox.RectMode) + layout.addWidget(self._plot_widget, stretch=2) + + # Main grid for trigger config and variable selection (below plot) + main_grid = QGridLayout() + layout.addLayout(main_grid) + + # Trigger configuration group + trigger_group = self._create_trigger_group() + main_grid.addWidget(trigger_group, 0, 0) + + # Variable selection group + variable_group = self._create_variable_group() + main_grid.addWidget(variable_group, 0, 1) + + # Set column stretch + main_grid.setColumnStretch(0, 1) + main_grid.setColumnStretch(1, 3) + + def _create_trigger_group(self) -> QGroupBox: + """Create the trigger configuration group box.""" + group = QGroupBox("Trigger Configuration") + layout = QVBoxLayout() + group.setLayout(layout) + + grid = QGridLayout() + layout.addLayout(grid) + + # Single shot checkbox + self._single_shot_checkbox = QCheckBox("Single Shot") + grid.addWidget(self._single_shot_checkbox, 0, 0, 1, 2) + + # Sample time factor + grid.addWidget(QLabel("Sample Time Factor"), 1, 0) + self._sample_time_factor_edit = QLineEdit("1") + self._sample_time_factor_edit.setValidator(self.decimal_validator) + grid.addWidget(self._sample_time_factor_edit, 1, 1) + + # Scope sample time + grid.addWidget(QLabel("Scope Sample Time (us):"), 2, 0) + self._scope_sampletime_edit = QLineEdit("50") + self._scope_sampletime_edit.setValidator(self.decimal_validator) + grid.addWidget(self._scope_sampletime_edit, 2, 1) + + # Trigger mode + grid.addWidget(QLabel("Trigger Mode:"), 3, 0) + self._trigger_mode_combo = QComboBox() + self._trigger_mode_combo.addItems(["Auto", "Triggered"]) + grid.addWidget(self._trigger_mode_combo, 3, 1) + + # Trigger edge + grid.addWidget(QLabel("Trigger Edge:"), 4, 0) + self._trigger_edge_combo = QComboBox() + self._trigger_edge_combo.addItems(["Rising", "Falling"]) + grid.addWidget(self._trigger_edge_combo, 4, 1) + + # Trigger level + grid.addWidget(QLabel("Trigger Level:"), 5, 0) + self._trigger_level_edit = QLineEdit("0") + self._trigger_level_edit.setValidator(self.decimal_validator) + grid.addWidget(self._trigger_level_edit, 5, 1) + + # Trigger delay (combo box with -50% to +50% in 10% steps) + grid.addWidget(QLabel("Trigger Delay (%):"), 6, 0) + self._trigger_delay_combo = QComboBox() + self._trigger_delay_combo.addItems(["-50", "-40", "-30", "-20", "-10", "0", "10", "20", "30", "40", "50"]) + self._trigger_delay_combo.setCurrentText("0") + grid.addWidget(self._trigger_delay_combo, 6, 1) + + # Sample button + self._sample_button = QPushButton("Sample") + self._sample_button.setFixedSize(100, 30) + self._sample_button.clicked.connect(self._on_sample_clicked) + grid.addWidget(self._sample_button, 7, 0, 1, 2) + + return group + + def _create_variable_group(self) -> QGroupBox: # noqa: PLR0915 + """Create the variable selection group box.""" + group = QGroupBox("Variable Selection") + layout = QVBoxLayout() + group.setLayout(layout) + + grid = QGridLayout() + layout.addLayout(grid) + + # Headers + grid.addWidget(QLabel("Trigger"), 0, 0) + grid.addWidget(QLabel("Search Variable"), 0, 1) + grid.addWidget(QLabel("Gain"), 0, 2) + grid.addWidget(QLabel("Offset"), 0, 3) + grid.addWidget(QLabel("Color"), 0, 4) + grid.addWidget(QLabel("Visible"), 0, 5) + + # Channel rows + for i in range(self.MAX_CHANNELS): + row = i + 1 + + # Trigger checkbox + trigger_cb = QCheckBox() + trigger_cb.setMinimumHeight(20) + trigger_cb.stateChanged.connect(lambda state, idx=i: self._on_trigger_changed(idx, state)) + self._trigger_checkboxes.append(trigger_cb) + grid.addWidget(trigger_cb, row, 0) + + # Variable line edit + line_edit = QLineEdit() + line_edit.setReadOnly(True) + line_edit.setPlaceholderText("Search Variable") + line_edit.setMinimumHeight(20) + line_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + line_edit.installEventFilter(self) + self._var_line_edits.append(line_edit) + grid.addWidget(line_edit, row, 1) + + # Scaling edit (Gain) + scaling_edit = QLineEdit("1") + scaling_edit.setFixedSize(50, 20) + scaling_edit.setValidator(self.decimal_validator) + scaling_edit.editingFinished.connect(self._update_plot) + self._scaling_edits.append(scaling_edit) + grid.addWidget(scaling_edit, row, 2) + + # Offset edit + offset_edit = QLineEdit("0") + offset_edit.setFixedSize(50, 20) + offset_edit.setValidator(self.decimal_validator) + offset_edit.editingFinished.connect(self._update_plot) + self._offset_edits.append(offset_edit) + grid.addWidget(offset_edit, row, 3) + + # Color combo with colored squares + color_combo = QComboBox() + self._populate_color_combo(color_combo) + color_combo.setCurrentIndex(i % len(self._colors)) + color_combo.setFixedSize(50, 20) + color_combo.currentIndexChanged.connect(lambda idx: self._update_plot()) + self._color_combos.append(color_combo) + grid.addWidget(color_combo, row, 4) + + # Visible checkbox + visible_cb = QCheckBox() + visible_cb.setChecked(True) + visible_cb.setMinimumHeight(20) + visible_cb.stateChanged.connect(lambda state: self._update_plot()) + self._visible_checkboxes.append(visible_cb) + grid.addWidget(visible_cb, row, 5) + + return group + + def _populate_color_combo(self, combo: QComboBox): + """Populate a combobox with colored square icons.""" + for name, rgb in self._colors.items(): + pixmap = QPixmap(16, 16) + pixmap.fill(QColor(*rgb)) + combo.addItem(QIcon(pixmap), "", name) # Store color name as item data + + def _get_color_from_combo(self, combo: QComboBox) -> tuple: + """Get the RGB color tuple from a color combo box.""" + color_name = combo.currentData() + if color_name and color_name in self._colors: + return self._colors[color_name] + # Fallback to index-based lookup + idx = combo.currentIndex() + color_names = list(self._colors.keys()) + if 0 <= idx < len(color_names): + return self._colors[color_names[idx]] + return (0, 0, 255) # Default to blue + + def on_connection_changed(self, connected: bool): + """Handle connection state changes.""" + self._sample_button.setEnabled(connected) + for line_edit in self._var_line_edits: + line_edit.setEnabled(connected) + + def on_variable_list_updated(self, variables: list): + """Handle variable list updates.""" + self._variable_list = variables + + def eventFilter(self, source, event): # noqa: N802 + """Event filter to handle line edit click events for variable selection.""" + if event.type() == QtCore.QEvent.MouseButtonPress: + if isinstance(source, QLineEdit) and source in self._var_line_edits: + index = self._var_line_edits.index(source) + self._on_variable_click(index) + return True # Consume the event after handling + return super().eventFilter(source, event) + + def _on_variable_click(self, index: int): + """Handle click on variable field to open selection dialog.""" + if not self._variable_list: + return + + sfr_list = self._app_state.get_sfr_list() + dialog = VariableSelectionDialog(self._variable_list, self, sfr_variables=sfr_list) + 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) + + def _on_trigger_changed(self, index: int, state: int): + """Handle trigger checkbox change - only one can be selected.""" + if state == Qt.Checked: + # Uncheck all other trigger checkboxes + for i, cb in enumerate(self._trigger_checkboxes): + if i != index: + cb.setChecked(False) + self._trigger_variable = self._var_line_edits[index].text() + self._app_state.update_scope_channel_field(index, "trigger", True) + logging.debug(f"Trigger variable set to: {self._trigger_variable}") + else: + self._trigger_variable = None + self._app_state.update_scope_channel_field(index, "trigger", False) + + def _on_sample_clicked(self): + """Handle sample button click.""" + if not self._app_state.is_connected(): + return + + if self._sampling_active: + self._stop_sampling() + else: + self._start_sampling() + + def _start_sampling(self): + """Start scope sampling.""" + try: + x2cscope = self._app_state.x2cscope + if not x2cscope: + return + + # Clear existing channels + x2cscope.clear_all_scope_channel() + + # 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": + sfr = self._app_state.get_scope_channel(i).sfr + variable = x2cscope.get_variable(var_name, sfr=sfr) + if variable is not None: + x2cscope.add_scope_channel(variable) + logging.debug(f"Added scope channel: {var_name}") + + # Set sample time + sample_time_factor = self.safe_int(self._sample_time_factor_edit.text(), 1) + x2cscope.set_sample_time(sample_time_factor) + + # Get real sample time + scope_sample_time_us = self.safe_int(self._scope_sampletime_edit.text(), 50) + self._real_sampletime = x2cscope.get_scope_sample_time(scope_sample_time_us) + + # Configure trigger + self._configure_trigger() + + # Start sampling + self._sampling_active = True + self._sample_button.setText("Stop") + x2cscope.request_scope_data() + + # Emit signal to notify DataPoller + self.scope_sampling_changed.emit(True, self._single_shot_checkbox.isChecked()) + + logging.info("Started scope sampling") + + except Exception as e: + error_msg = f"Error starting sampling: {e}" + logging.error(error_msg) + + def _stop_sampling(self): + """Stop scope sampling.""" + self._sampling_active = False + self._sample_button.setText("Sample") + + x2cscope = self._app_state.x2cscope + if x2cscope: + x2cscope.clear_all_scope_channel() + + # Emit signal to notify DataPoller + self.scope_sampling_changed.emit(False, False) + + logging.info("Stopped scope sampling") + + def _configure_trigger(self): + """Configure the scope trigger.""" + try: + x2cscope = self._app_state.x2cscope + if not x2cscope: + return + + trigger_mode = self._trigger_mode_combo.currentText() + + if trigger_mode == "Auto": + x2cscope.reset_scope_trigger() + return + + if not self._trigger_variable: + x2cscope.reset_scope_trigger() + return + + # Find the sfr flag for the trigger channel + trigger_sfr = next( + ( + self._app_state.get_scope_channel(i).sfr + for i, le in enumerate(self._var_line_edits) + if le.text() == self._trigger_variable + and i < len(self._trigger_checkboxes) + and self._trigger_checkboxes[i].isChecked() + ), + False, + ) + variable = x2cscope.get_variable(self._trigger_variable, sfr=trigger_sfr) + if variable is None: + logging.warning(f"Trigger variable not found: {self._trigger_variable}") + return + + trigger_level = self.safe_float(self._trigger_level_edit.text()) + trigger_delay = self.safe_int(self._trigger_delay_combo.currentText()) + + # Rising = 0, Falling = 1 (per TriggerConfig spec) + trigger_edge = 1 if self._trigger_edge_combo.currentText() == "Rising" else 0 + trigger_mode_val = 1 # Triggered mode + + trigger_config = TriggerConfig( + variable=variable, + trigger_level=trigger_level, + trigger_mode=trigger_mode_val, + trigger_delay=trigger_delay, + trigger_edge=trigger_edge, + ) + x2cscope.set_scope_trigger(trigger_config) + logging.info("Trigger configured") + + except Exception as e: + logging.error(f"Error configuring trigger: {e}") + + @pyqtSlot(dict) + def on_scope_data_ready(self, data: Dict[str, List[float]]): + """Handle scope data ready signal from data poller. + + Args: + data: Dictionary of channel name to data values. + """ + if not self._sampling_active: + return + + self._plot_widget.clear() + + for i, (channel, values) in enumerate(data.items()): + if i >= self.MAX_CHANNELS: + break + + if self._visible_checkboxes[i].isChecked(): + scale_factor = self.safe_float(self._scaling_edits[i].text(), 1.0) + offset = self.safe_float(self._offset_edits[i].text(), 0.0) + time_values = np.linspace(0, self._real_sampletime, len(values)) + data_scaled = (np.array(values, dtype=float) * scale_factor) + offset + + # Get color from combo box (RGB tuple) + color = self._get_color_from_combo(self._color_combos[i]) + + self._plot_widget.plot( + time_values, + data_scaled, + pen=pg.mkPen(color=color, width=2), + name=f"{channel}", + ) + + self._plot_widget.setLabel("left", "Value") + self._plot_widget.setLabel("bottom", "Time", units="ms") + self._plot_widget.showGrid(x=True, y=True) + + # Handle single-shot mode - stop sampling after receiving data + if self._single_shot_checkbox.isChecked(): + self._stop_sampling() + # Note: In continuous mode, DataPoller handles requesting next data + + def _update_plot(self): + """Update the plot (called when scale or visibility changes).""" + # The plot will be updated on next data ready signal + pass + + @property + def is_sampling(self) -> bool: + """Check if currently sampling.""" + return self._sampling_active + + @property + def is_single_shot(self) -> bool: + """Check if single-shot mode is enabled.""" + return self._single_shot_checkbox.isChecked() + + def get_config(self) -> dict: + """Get the current tab configuration.""" + return { + "variables": [le.text() for le in self._var_line_edits], + "trigger": [cb.isChecked() for cb in self._trigger_checkboxes], + "scale": [sc.text() for sc in self._scaling_edits], + "offset": [off.text() for off in self._offset_edits], + "color": [cb.currentData() or list(self._colors.keys())[cb.currentIndex()] for cb in self._color_combos], + "show": [cb.isChecked() for cb in self._visible_checkboxes], + "trigger_variable": self._trigger_variable, + "trigger_level": self._trigger_level_edit.text(), + "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.text(), + "single_shot": self._single_shot_checkbox.isChecked(), + } + + def load_config(self, config: dict): + """Load configuration into the tab.""" + variables = config.get("variables", []) + triggers = config.get("trigger", []) + scales = config.get("scale", []) + offsets = config.get("offset", []) + colors = config.get("color", []) + shows = config.get("show", []) + + for i, (le, var) in enumerate(zip(self._var_line_edits, variables)): + le.setText(var) + # Update app state with variable name + self._app_state.update_scope_channel_field(i, "name", var) + for i, (cb, trigger) in enumerate(zip(self._trigger_checkboxes, triggers)): + cb.setChecked(trigger) + self._app_state.update_scope_channel_field(i, "trigger", trigger) + for i, (sc, scale) in enumerate(zip(self._scaling_edits, scales)): + sc.setText(scale) + for off, offset in zip(self._offset_edits, offsets): + off.setText(offset) + for cb, color_name in zip(self._color_combos, colors): + # Find the index of the color by name in item data + for idx in range(cb.count()): + if cb.itemData(idx) == color_name: + cb.setCurrentIndex(idx) + break + for i, (cb, show) in enumerate(zip(self._visible_checkboxes, shows)): + 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", "Auto")) + self._sample_time_factor_edit.setText(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 new file mode 100644 index 00000000..d6fdc0c2 --- /dev/null +++ b/pyx2cscope/gui/qt/tabs/scripting_tab.py @@ -0,0 +1,586 @@ +"""Scripting tab - Execute Python scripts with access to x2cscope.""" + +import io +import os +import subprocess +import sys +import traceback +from contextlib import redirect_stderr, redirect_stdout +from datetime import datetime +from typing import TYPE_CHECKING + +from PyQt5.QtCore import QSettings, Qt, QThread, pyqtSignal +from PyQt5.QtWidgets import ( + QCheckBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPlainTextEdit, + QPushButton, + QTabWidget, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +from pyx2cscope.gui.resources import get_resource_path + +if TYPE_CHECKING: + from pyx2cscope.gui.qt.models.app_state import AppState + + +class ScriptHelpDialog(QDialog): + """Dialog showing help for writing scripts.""" + + def __init__(self, parent=None): + """Initialize the script help dialog.""" + super().__init__(parent) + self.setWindowTitle("Script Help") + self.setMinimumSize(700, 600) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + help_browser = QTextBrowser() + help_browser.setOpenExternalLinks(True) + help_browser.setStyleSheet( + "QTextBrowser { font-family: Segoe UI, Arial, sans-serif; font-size: 10pt; }" + ) + help_browser.setMarkdown(self._load_help_content()) + layout.addWidget(help_browser) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok) + button_box.accepted.connect(self.accept) + layout.addWidget(button_box) + + def _load_help_content(self) -> str: + """Load help content from external markdown file.""" + try: + help_path = get_resource_path("script_help.md") + with open(help_path, "r", encoding="utf-8") as f: + return f.read() + except Exception as e: + return f"# Error\n\nCould not load help file: {e}\n\nPlease check that `script_help.md` exists in the resources folder." + + +class ScriptWorker(QThread): + """Worker thread for executing Python scripts.""" + + output_ready = pyqtSignal(str) + finished_with_code = pyqtSignal(int) + + def __init__(self, script_path: str, x2cscope, parent=None): + """Initialize the script worker. + + Args: + script_path: Path to the Python script to execute. + x2cscope: The x2cscope instance to inject into script namespace. + parent: Optional parent QObject. + """ + super().__init__(parent) + self._script_path = script_path + self._x2cscope = x2cscope + self._stop_requested = False + + def is_stop_requested(self) -> bool: + """Check if stop has been requested. Scripts should call this in loops.""" + return self._stop_requested + + def run(self): + """Execute the script in this thread.""" + exit_code = 0 + + # Create a custom stdout/stderr that emits signals + class OutputCapture(io.StringIO): + def __init__(self, signal): + super().__init__() + self._signal = signal + + def write(self, text): + if text: + self._signal.emit(text) + return len(text) if text else 0 + + def flush(self): + pass + + stdout_capture = OutputCapture(self.output_ready) + stderr_capture = OutputCapture(self.output_ready) + + try: + with open(self._script_path, "r", encoding="utf-8") as f: + script_code = f.read() + + # Create namespace with x2cscope and stop_requested function + namespace = { + "__name__": "__main__", + "__file__": self._script_path, + "x2cscope": self._x2cscope, + "stop_requested": self.is_stop_requested, + } + + # Execute with captured output + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + exec(compile(script_code, self._script_path, "exec"), namespace) + + except SystemExit as e: + # Handle sys.exit() calls in scripts + exit_code = e.code if isinstance(e.code, int) else 1 + except StopIteration: + # Script was stopped via stop_requested + exit_code = 0 + except Exception as e: + self.output_ready.emit(f"\nScript error: {e}\n") + self.output_ready.emit(traceback.format_exc()) + exit_code = 1 + + self.finished_with_code.emit(exit_code) + + def request_stop(self): + """Request the script to stop. Scripts should check stop_requested() in loops.""" + self._stop_requested = True + + +class ScriptingTab(QWidget): + """Tab for executing Python scripts with x2cscope access. + + Features: + - Select and execute Python scripts + - View script output in real-time (separate tab) + - Log messages with timestamps (separate tab) + - Option to log output to file with custom location + - Edit scripts with IDLE + - Scripts can access the x2cscope instance from the main app + """ + + # Signals + script_started = pyqtSignal() + script_finished = pyqtSignal(int) # exit code + + def __init__(self, app_state: "AppState", parent=None): + """Initialize the Scripting tab. + + Args: + app_state: The centralized application state. + parent: Optional parent widget. + """ + super().__init__(parent) + self._app_state = app_state + self._script_path = "" + self._settings = QSettings("Microchip", "pyX2Cscope") + self._worker = None + self._log_file = None + self._log_file_path = "" + + self._setup_ui() + + def _setup_ui(self): # noqa: PLR0915 + """Set up the user interface.""" + main_layout = QVBoxLayout() + main_layout.setContentsMargins(10, 10, 10, 10) + self.setLayout(main_layout) + + # === Script Selection Group === + script_group = QGroupBox("Script Selection") + script_layout = QHBoxLayout() + script_layout.setSpacing(8) + script_layout.setContentsMargins(10, 15, 10, 10) + script_group.setLayout(script_layout) + + # Script path display + self._script_path_edit = QLineEdit() + self._script_path_edit.setReadOnly(True) + self._script_path_edit.setPlaceholderText("No script selected") + script_layout.addWidget(self._script_path_edit, 1) + + # Browse button + self._browse_btn = QPushButton("Browse...") + self._browse_btn.setFixedWidth(80) + self._browse_btn.clicked.connect(self._on_browse_clicked) + script_layout.addWidget(self._browse_btn) + + # Edit with IDLE button + self._edit_btn = QPushButton("Edit (IDLE)") + self._edit_btn.setFixedWidth(90) + self._edit_btn.clicked.connect(self._on_edit_clicked) + self._edit_btn.setEnabled(False) + script_layout.addWidget(self._edit_btn) + + # Help button + self._help_btn = QPushButton("Help") + self._help_btn.setFixedWidth(60) + self._help_btn.clicked.connect(self._on_help_clicked) + script_layout.addWidget(self._help_btn) + + main_layout.addWidget(script_group) + + # === Execution Controls Group === + controls_group = QGroupBox("Execution Controls") + controls_layout = QVBoxLayout() + controls_layout.setSpacing(8) + controls_layout.setContentsMargins(10, 15, 10, 10) + controls_group.setLayout(controls_layout) + + # First row: Execute, Stop, Status + row1_layout = QHBoxLayout() + + # Execute button + self._execute_btn = QPushButton("Execute") + self._execute_btn.setFixedSize(100, 30) + self._execute_btn.clicked.connect(self._on_execute_clicked) + self._execute_btn.setEnabled(False) + row1_layout.addWidget(self._execute_btn) + + # Stop button + self._stop_btn = QPushButton("Stop") + self._stop_btn.setFixedSize(100, 30) + self._stop_btn.clicked.connect(self._on_stop_clicked) + self._stop_btn.setEnabled(False) + self._stop_btn.setToolTip("Request script to stop (may not work for blocking operations)") + row1_layout.addWidget(self._stop_btn) + + # Status label + row1_layout.addStretch() + self._status_label = QLabel("Ready") + self._status_label.setStyleSheet("color: #666;") + row1_layout.addWidget(self._status_label) + + controls_layout.addLayout(row1_layout) + + # Second row: Log to file checkbox and path + row2_layout = QHBoxLayout() + + # Log to file checkbox + self._log_checkbox = QCheckBox("Log output to file:") + self._log_checkbox.setChecked(False) + self._log_checkbox.stateChanged.connect(self._on_log_checkbox_changed) + row2_layout.addWidget(self._log_checkbox) + + # Log file path display + self._log_path_edit = QLineEdit() + self._log_path_edit.setReadOnly(True) + self._log_path_edit.setPlaceholderText("Select log file location...") + self._log_path_edit.setEnabled(False) + row2_layout.addWidget(self._log_path_edit, 1) + + # Browse log location button + self._log_browse_btn = QPushButton("Browse...") + self._log_browse_btn.setFixedWidth(80) + self._log_browse_btn.clicked.connect(self._on_log_browse_clicked) + self._log_browse_btn.setEnabled(False) + row2_layout.addWidget(self._log_browse_btn) + + controls_layout.addLayout(row2_layout) + + main_layout.addWidget(controls_group) + + # === Output Tabs === + self._output_tabs = QTabWidget() + + # Script Output tab + script_output_widget = QWidget() + script_output_layout = QVBoxLayout(script_output_widget) + script_output_layout.setContentsMargins(5, 5, 5, 5) + + self._script_output_text = QPlainTextEdit() + self._script_output_text.setReadOnly(True) + self._script_output_text.setStyleSheet( + "QPlainTextEdit { font-family: Consolas, monospace; font-size: 10pt; }" + ) + script_output_layout.addWidget(self._script_output_text) + + # Clear button for script output + script_clear_layout = QHBoxLayout() + script_clear_layout.addStretch() + script_clear_btn = QPushButton("Clear") + script_clear_btn.setFixedWidth(80) + script_clear_btn.clicked.connect(self._script_output_text.clear) + script_clear_layout.addWidget(script_clear_btn) + script_output_layout.addLayout(script_clear_layout) + + self._output_tabs.addTab(script_output_widget, "Script Output") + + # Log Output tab + log_output_widget = QWidget() + log_output_layout = QVBoxLayout(log_output_widget) + log_output_layout.setContentsMargins(5, 5, 5, 5) + + self._log_output_text = QPlainTextEdit() + self._log_output_text.setReadOnly(True) + self._log_output_text.setStyleSheet( + "QPlainTextEdit { font-family: Consolas, monospace; font-size: 10pt; " + "color: #555; }" + ) + log_output_layout.addWidget(self._log_output_text) + + # Clear button for log output + log_clear_layout = QHBoxLayout() + log_clear_layout.addStretch() + log_clear_btn = QPushButton("Clear") + log_clear_btn.setFixedWidth(80) + log_clear_btn.clicked.connect(self._log_output_text.clear) + log_clear_layout.addWidget(log_clear_btn) + log_output_layout.addLayout(log_clear_layout) + + self._output_tabs.addTab(log_output_widget, "Log") + + main_layout.addWidget(self._output_tabs, 1) # Stretch factor 1 + + # === Info Label === + info_label = QLabel( + "Scripts have access to 'x2cscope' variable when connected. " + "Click Help for examples and documentation." + ) + info_label.setStyleSheet("color: #666; font-style: italic; padding: 5px;") + info_label.setWordWrap(True) + main_layout.addWidget(info_label) + + def _on_log_checkbox_changed(self, state): + """Handle log checkbox state change.""" + enabled = state == Qt.Checked + self._log_path_edit.setEnabled(enabled) + self._log_browse_btn.setEnabled(enabled) + + if enabled and not self._log_file_path: + # Suggest a default path based on script location + if self._script_path: + script_dir = os.path.dirname(self._script_path) + script_name = os.path.splitext(os.path.basename(self._script_path))[0] + self._log_file_path = os.path.join(script_dir, f"{script_name}_log.txt") + self._log_path_edit.setText(self._log_file_path) + + def _on_log_browse_clicked(self): + """Handle log file browse button click.""" + # Get initial directory + if self._log_file_path: + initial_dir = os.path.dirname(self._log_file_path) + elif self._script_path: + initial_dir = os.path.dirname(self._script_path) + else: + initial_dir = self._settings.value("log_file_dir", "", type=str) + + # Generate default filename with timestamp + if self._script_path: + script_name = os.path.splitext(os.path.basename(self._script_path))[0] + default_name = f"{script_name}_log.txt" + else: + default_name = "script_log.txt" + + file_path, _ = QFileDialog.getSaveFileName( + self, + "Select Log File Location", + os.path.join(initial_dir, default_name), + "Text Files (*.txt);;Log Files (*.log);;All Files (*.*)", + ) + if file_path: + self._log_file_path = file_path + self._log_path_edit.setText(file_path) + self._settings.setValue("log_file_dir", os.path.dirname(file_path)) + + def _on_help_clicked(self): + """Show help dialog.""" + dialog = ScriptHelpDialog(self) + dialog.exec_() + + def _on_browse_clicked(self): + """Handle browse button click.""" + # Get last directory from settings + last_dir = self._settings.value("script_file_dir", "", type=str) + + file_path, _ = QFileDialog.getOpenFileName( + self, + "Select Python Script", + last_dir, + "Python Files (*.py);;All Files (*.*)", + ) + if file_path: + 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"Selected script: {file_path}") + + # Update suggested log path if logging is enabled + if self._log_checkbox.isChecked(): + script_dir = os.path.dirname(file_path) + script_name = os.path.splitext(os.path.basename(file_path))[0] + self._log_file_path = os.path.join(script_dir, f"{script_name}_log.txt") + self._log_path_edit.setText(self._log_file_path) + + def _on_edit_clicked(self): + """Open the script in IDLE.""" + if not self._script_path: + return + + try: + # Try to open with IDLE + if sys.platform == "win32": + # On Windows, use pythonw to avoid console window + subprocess.Popen( + [sys.executable, "-m", "idlelib.idle", self._script_path], + creationflags=subprocess.CREATE_NO_WINDOW, + ) + else: + subprocess.Popen([sys.executable, "-m", "idlelib.idle", self._script_path]) + self._log_message(f"Opened {os.path.basename(self._script_path)} in IDLE") + except Exception as e: + self._log_message(f"Error opening IDLE: {e}") + + def _on_execute_clicked(self): + """Execute the selected script.""" + if not self._script_path: + return + + if not os.path.exists(self._script_path): + self._log_message(f"Error: Script file not found: {self._script_path}") + return + + if self._worker is not None and self._worker.isRunning(): + self._log_message("A script is already running.") + return + + # Clear script output only + self._script_output_text.clear() + + # Setup logging if enabled + if self._log_checkbox.isChecked(): + self._setup_log_file() + + # Update UI state + self._execute_btn.setEnabled(False) + self._stop_btn.setEnabled(True) + self._status_label.setText("Running...") + self._status_label.setStyleSheet("color: #0078d4;") + + # Log messages go to Log tab + self._log_message(f"Script started: {os.path.basename(self._script_path)}") + + # Check if x2cscope is available + x2cscope = self._app_state.x2cscope + if x2cscope: + self._log_message("x2cscope instance available") + else: + self._log_message("x2cscope not connected (variable will be None)") + + # Create and start worker thread + self._worker = ScriptWorker(self._script_path, x2cscope, self) + self._worker.output_ready.connect(self._on_script_output) + self._worker.finished_with_code.connect(self._on_script_finished) + self._worker.start() + + self.script_started.emit() + + def _on_script_output(self, text: str): + """Handle output from the script worker - goes to Script Output tab.""" + self._append_script_output(text) + + def _on_script_finished(self, exit_code: int): + """Handle script completion.""" + self._log_message(f"Script finished with exit code {exit_code}") + + # Close log file if open + if self._log_file: + self._log_file.close() + self._log_file = None + + # Update UI state + self._execute_btn.setEnabled(True) + self._stop_btn.setEnabled(False) + + if exit_code == 0: + self._status_label.setText("Completed") + self._status_label.setStyleSheet("color: green;") + else: + self._status_label.setText(f"Finished (code {exit_code})") + self._status_label.setStyleSheet("color: orange;") + + self._worker = None + self.script_finished.emit(exit_code) + + def _on_stop_clicked(self): + """Stop the running script.""" + if self._worker and self._worker.isRunning(): + self._worker.request_stop() + self._log_message("Stop requested (may not work for blocking operations)") + self._status_label.setText("Stop requested...") + self._status_label.setStyleSheet("color: orange;") + + def _log_message(self, message: str): + """Add a timestamped message to the Log tab.""" + timestamp = datetime.now().strftime("%H:%M:%S") + formatted = f"[{timestamp}] {message}" + + cursor = self._log_output_text.textCursor() + cursor.movePosition(cursor.End) + if not self._log_output_text.toPlainText().endswith("\n") and self._log_output_text.toPlainText(): + cursor.insertText("\n") + cursor.insertText(formatted) + self._log_output_text.setTextCursor(cursor) + self._log_output_text.ensureCursorVisible() + + # Also write to log file if enabled + if self._log_file: + try: + self._log_file.write(f"{formatted}\n") + self._log_file.flush() + except Exception: + pass + + def _append_script_output(self, text: str): + """Append text to the Script Output tab.""" + cursor = self._script_output_text.textCursor() + cursor.movePosition(cursor.End) + cursor.insertText(text) + self._script_output_text.setTextCursor(cursor) + self._script_output_text.ensureCursorVisible() + + # Write to log file if enabled + if self._log_file: + try: + self._log_file.write(text) + self._log_file.flush() + except Exception: + pass + + def _setup_log_file(self): + """Setup log file for script output.""" + if not self._log_file_path: + self._log_message("Warning: No log file path specified") + return + + try: + # Create directory if it doesn't exist + log_dir = os.path.dirname(self._log_file_path) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir) + + self._log_file = open(self._log_file_path, "a", encoding="utf-8") + + # Write header + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self._log_file.write(f"\n{'='*60}\n") + self._log_file.write(f"Script execution started: {timestamp}\n") + self._log_file.write(f"Script: {self._script_path}\n") + self._log_file.write(f"{'='*60}\n\n") + self._log_file.flush() + + self._log_message(f"Logging to: {self._log_file_path}") + except Exception as e: + self._log_message(f"Warning: Could not create log file: {e}") + self._log_file = None + + def on_connection_changed(self, connected: bool): + """Handle connection state change.""" + # Update info about x2cscope availability + if connected: + self._status_label.setText("Ready (x2cscope available)") + self._log_message("Connection established - x2cscope now available") + else: + self._status_label.setText("Ready (x2cscope not connected)") + self._log_message("Disconnected - x2cscope not available") diff --git a/pyx2cscope/gui/qt/tabs/setup_tab.py b/pyx2cscope/gui/qt/tabs/setup_tab.py new file mode 100644 index 00000000..0ffad4d3 --- /dev/null +++ b/pyx2cscope/gui/qt/tabs/setup_tab.py @@ -0,0 +1,544 @@ +"""Setup tab - Connection and device configuration.""" + +import os +from typing import TYPE_CHECKING + +from PyQt5.QtCore import QRegExp, QSettings, Qt, pyqtSignal +from PyQt5.QtGui import QIcon, QIntValidator, QRegExpValidator +from PyQt5.QtWidgets import ( + QComboBox, + QFileDialog, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from pyx2cscope.gui import img as img_src + +if TYPE_CHECKING: + from pyx2cscope.gui.qt.models.app_state import AppState + + +class SetupTab(QWidget): + """Tab for connection and device setup. + + Features: + - Interface selection (UART, TCP/IP, CAN) + - UART settings (Port, Baud Rate) + - TCP/IP settings (IP Address, Port) + - CAN settings (Bus Type, Channel, Baudrate, Mode, Tx-ID, Rx-ID) + - ELF file selection + - Device info display + """ + + # Constants + MAX_FILENAME_DISPLAY_LENGTH = 25 + + # Signals + connect_requested = pyqtSignal() + elf_file_selected = pyqtSignal(str) + + def __init__(self, app_state: "AppState", parent=None): + """Initialize the Setup tab. + + Args: + app_state: The centralized application state. + parent: Optional parent widget. + """ + super().__init__(parent) + self._app_state = app_state + self._elf_file_path = "" + self._settings = QSettings("Microchip", "pyX2Cscope") + + # Device info labels + self._device_info_labels = {} + + self._setup_ui() + self._restore_connection_settings() + + def _setup_ui(self): # noqa: PLR0915 + """Set up the user interface.""" + main_layout = QHBoxLayout() + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop) + self.setLayout(main_layout) + + # Left side: Connection settings + left_layout = QVBoxLayout() + left_layout.setAlignment(Qt.AlignTop) + + # === Connection Settings Group (ELF file + Interface selection) === + connection_group = QGroupBox("Connection Settings") + connection_layout = QGridLayout() + connection_layout.setSpacing(8) + connection_layout.setContentsMargins(10, 15, 10, 10) + connection_group.setLayout(connection_layout) + connection_group.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + row = 0 + + # ELF file selection + connection_layout.addWidget(QLabel("ELF File:"), row, 0, Qt.AlignRight) + self._elf_button = QPushButton("Select ELF file") + self._elf_button.setFixedWidth(200) + self._elf_button.clicked.connect(self._on_select_elf) + connection_layout.addWidget(self._elf_button, row, 1, 1, 2) + row += 1 + + # Interface selection + connection_layout.addWidget(QLabel("Interface:"), row, 0, Qt.AlignRight) + self._interface_combo = QComboBox() + self._interface_combo.addItems(["UART", "TCP/IP", "CAN"]) + self._interface_combo.setFixedWidth(120) + self._interface_combo.currentTextChanged.connect(self._on_interface_changed) + connection_layout.addWidget(self._interface_combo, row, 1) + row += 1 + + # Connect button and loading indicator row + connect_layout = QHBoxLayout() + self._connect_btn = QPushButton("Connect") + self._connect_btn.setFixedSize(100, 30) + self._connect_btn.clicked.connect(self._on_connect_clicked) + connect_layout.addWidget(self._connect_btn) + + # Loading indicator (simple text label) + self._loading_label = QLabel("Loading...") + self._loading_label.setStyleSheet("color: #666; font-style: italic;") + self._loading_label.hide() + connect_layout.addWidget(self._loading_label) + connect_layout.addStretch() + + connection_layout.addLayout(connect_layout, row, 1, 1, 2) + + self._connection_group = connection_group + left_layout.addWidget(connection_group) + + # === UART Settings Group === + self._uart_group = QGroupBox("UART Settings") + uart_layout = QGridLayout() + uart_layout.setSpacing(8) + uart_layout.setContentsMargins(10, 15, 10, 10) + self._uart_group.setLayout(uart_layout) + self._uart_group.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + uart_row = 0 + + # Port + uart_layout.addWidget(QLabel("Port:"), uart_row, 0, Qt.AlignRight) + self._port_combo = QComboBox() + self._port_combo.setFixedWidth(120) + uart_layout.addWidget(self._port_combo, uart_row, 1) + + # Refresh button + self._refresh_btn = QPushButton() + self._refresh_btn.setFixedSize(25, 25) + refresh_icon = os.path.join(os.path.dirname(img_src.__file__), "refresh.png") + if os.path.exists(refresh_icon): + self._refresh_btn.setIcon(QIcon(refresh_icon)) + uart_layout.addWidget(self._refresh_btn, uart_row, 2) + uart_row += 1 + + # Baud Rate + uart_layout.addWidget(QLabel("Baud Rate:"), uart_row, 0, Qt.AlignRight) + self._baud_combo = QComboBox() + self._baud_combo.setFixedWidth(120) + self._baud_combo.addItems(["38400", "115200", "230400", "460800", "921600"]) + self._baud_combo.setCurrentText("115200") + uart_layout.addWidget(self._baud_combo, uart_row, 1) + + left_layout.addWidget(self._uart_group) + + # === TCP/IP Settings Group === + self._tcp_group = QGroupBox("TCP/IP Settings") + tcp_layout = QGridLayout() + tcp_layout.setSpacing(8) + tcp_layout.setContentsMargins(10, 15, 10, 10) + self._tcp_group.setLayout(tcp_layout) + self._tcp_group.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + tcp_row = 0 + + # Host (IP address or hostname) + tcp_layout.addWidget(QLabel("Host:"), tcp_row, 0, Qt.AlignRight) + self._ip_edit = QLineEdit("192.168.0.100") + self._ip_edit.setFixedWidth(120) + self._ip_edit.setPlaceholderText("IP or hostname") + # Validator for IP address or hostname (alphanumeric, dots, hyphens) + host_regex = QRegExp(r"^[a-zA-Z0-9][a-zA-Z0-9.\-]*$") + self._ip_edit.setValidator(QRegExpValidator(host_regex)) + tcp_layout.addWidget(self._ip_edit, tcp_row, 1) + tcp_row += 1 + + # Port (numbers only, 1-65535) + tcp_layout.addWidget(QLabel("Port:"), tcp_row, 0, Qt.AlignRight) + self._tcp_port_edit = QLineEdit("12666") + self._tcp_port_edit.setFixedWidth(120) + self._tcp_port_edit.setValidator(QIntValidator(1, 65535)) + tcp_layout.addWidget(self._tcp_port_edit, tcp_row, 1) + + left_layout.addWidget(self._tcp_group) + + # === CAN Settings Group === + self._can_group = QGroupBox("CAN Settings") + can_layout = QGridLayout() + can_layout.setSpacing(8) + can_layout.setContentsMargins(10, 15, 10, 10) + self._can_group.setLayout(can_layout) + self._can_group.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + can_row = 0 + + # Bus Type + can_layout.addWidget(QLabel("Bus Type:"), can_row, 0, Qt.AlignRight) + self._can_bus_type_combo = QComboBox() + self._can_bus_type_combo.addItems(["USB", "LAN"]) + self._can_bus_type_combo.setFixedWidth(120) + can_layout.addWidget(self._can_bus_type_combo, can_row, 1) + can_row += 1 + + # Channel (numbers only) + can_layout.addWidget(QLabel("Channel:"), can_row, 0, Qt.AlignRight) + self._can_channel_edit = QLineEdit("1") + self._can_channel_edit.setFixedWidth(120) + self._can_channel_edit.setValidator(QIntValidator(0, 255)) + can_layout.addWidget(self._can_channel_edit, can_row, 1) + can_row += 1 + + # Baudrate + can_layout.addWidget(QLabel("Baudrate:"), can_row, 0, Qt.AlignRight) + self._can_baudrate_combo = QComboBox() + self._can_baudrate_combo.addItems(["125K", "250K", "500K", "1M"]) + self._can_baudrate_combo.setCurrentText("125K") + self._can_baudrate_combo.setFixedWidth(120) + can_layout.addWidget(self._can_baudrate_combo, can_row, 1) + can_row += 1 + + # Mode + can_layout.addWidget(QLabel("Mode:"), can_row, 0, Qt.AlignRight) + self._can_mode_combo = QComboBox() + self._can_mode_combo.addItems(["Standard", "Extended"]) + self._can_mode_combo.setFixedWidth(120) + can_layout.addWidget(self._can_mode_combo, can_row, 1) + can_row += 1 + + # Tx-ID (hex) + can_layout.addWidget(QLabel("Tx-ID (hex):"), can_row, 0, Qt.AlignRight) + self._can_tx_id_edit = QLineEdit("7F1") + self._can_tx_id_edit.setFixedWidth(120) + can_layout.addWidget(self._can_tx_id_edit, can_row, 1) + can_row += 1 + + # Rx-ID (hex) + can_layout.addWidget(QLabel("Rx-ID (hex):"), can_row, 0, Qt.AlignRight) + self._can_rx_id_edit = QLineEdit("7F0") + self._can_rx_id_edit.setFixedWidth(120) + can_layout.addWidget(self._can_rx_id_edit, can_row, 1) + + left_layout.addWidget(self._can_group) + left_layout.addStretch() + + main_layout.addLayout(left_layout) + + # Add spacing between groups + main_layout.addSpacing(20) + + # Right side: Device info (aligned to top) + right_layout = QVBoxLayout() + right_layout.setAlignment(Qt.AlignTop) + + device_group = QGroupBox("Device Information") + device_layout = QGridLayout() + device_layout.setSpacing(5) + device_layout.setContentsMargins(10, 15, 10, 10) + device_group.setLayout(device_layout) + device_group.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + self._device_info_labels = { + "processor_id": QLabel("Not connected"), + "uc_width": QLabel("Not connected"), + "date": QLabel("Not connected"), + "time": QLabel("Not connected"), + "appVer": QLabel("Not connected"), + "dsp_state": QLabel("Not connected"), + } + + info_titles = { + "processor_id": "Processor ID:", + "uc_width": "UC Width:", + "date": "Date:", + "time": "Time:", + "appVer": "App Version:", + "dsp_state": "DSP State:", + } + + for i, (key, label) in enumerate(self._device_info_labels.items()): + title_label = QLabel(info_titles[key]) + title_label.setAlignment(Qt.AlignRight) + device_layout.addWidget(title_label, i, 0, Qt.AlignRight) + label.setMinimumWidth(150) + device_layout.addWidget(label, i, 1, Qt.AlignLeft) + + right_layout.addWidget(device_group) + right_layout.addStretch() + + main_layout.addLayout(right_layout) + + # Add stretch to push everything to the left + main_layout.addStretch() + + # Set initial interface visibility + self._on_interface_changed("UART") + + def _on_interface_changed(self, interface: str): + """Handle interface selection change.""" + # Hide all interface settings groups + self._uart_group.hide() + self._tcp_group.hide() + self._can_group.hide() + + # Show relevant group based on interface + if interface == "UART": + self._uart_group.show() + elif interface == "TCP/IP": + self._tcp_group.show() + elif interface == "CAN": + self._can_group.show() + + def _on_select_elf(self): + """Handle ELF file selection.""" + # Get last directory from settings + last_dir = self._settings.value("elf_file_dir", "", type=str) + + file_path, _ = QFileDialog.getOpenFileName( + self, + "Select ELF File", + last_dir, + "ELF Files (*.elf);;All Files (*.*)", + ) + if file_path: + self._elf_file_path = file_path + # Save the directory for next time + self._settings.setValue("elf_file_dir", os.path.dirname(file_path)) + # Show shortened filename + basename = os.path.basename(file_path) + if len(basename) > self.MAX_FILENAME_DISPLAY_LENGTH: + basename = basename[: self.MAX_FILENAME_DISPLAY_LENGTH - 3] + "..." + self._elf_button.setText(basename) + self._elf_button.setToolTip(file_path) + self.elf_file_selected.emit(file_path) + + # Public properties and methods + + @property + def interface_type(self) -> str: + """Get the selected interface type.""" + return self._interface_combo.currentText() + + @property + def port_combo(self) -> QComboBox: + """Get the port combo box.""" + return self._port_combo + + @property + def baud_combo(self) -> QComboBox: + """Get the baud rate combo box.""" + return self._baud_combo + + @property + def ip_address(self) -> str: + """Get the IP address.""" + return self._ip_edit.text() + + @property + def tcp_port(self) -> int: + """Get the TCP port.""" + try: + return int(self._tcp_port_edit.text()) + except ValueError: + return 12666 + + @property + def connect_btn(self) -> QPushButton: + """Get the connect button.""" + return self._connect_btn + + @property + def refresh_btn(self) -> QPushButton: + """Get the refresh button.""" + return self._refresh_btn + + @property + def elf_file_path(self) -> str: + """Get the selected ELF file path.""" + return self._elf_file_path + + @elf_file_path.setter + def elf_file_path(self, path: str): + """Set the ELF file path.""" + self._elf_file_path = path + if path: + basename = os.path.basename(path) + if len(basename) > self.MAX_FILENAME_DISPLAY_LENGTH: + basename = basename[: self.MAX_FILENAME_DISPLAY_LENGTH - 3] + "..." + self._elf_button.setText(basename) + self._elf_button.setToolTip(path) + + def set_ports(self, ports: list): + """Set available COM ports.""" + self._port_combo.clear() + self._port_combo.addItems(ports) + + def set_connected(self, connected: bool): + """Update UI for connection state.""" + # Hide loading label + self._loading_label.hide() + self._connect_btn.setEnabled(True) + + self._connect_btn.setText("Disconnect" if connected else "Connect") + + # Disable/enable interface selection and ELF button + self._interface_combo.setEnabled(not connected) + self._elf_button.setEnabled(not connected) + + # Disable/enable interface settings groups when connected + self._uart_group.setEnabled(not connected) + self._tcp_group.setEnabled(not connected) + self._can_group.setEnabled(not connected) + + def set_loading(self, loading: bool): + """Show or hide the loading indicator.""" + if loading: + self._loading_label.show() + self._connect_btn.setEnabled(False) + else: + self._loading_label.hide() + self._connect_btn.setEnabled(True) + + def _on_connect_clicked(self): + """Handle connect button click - show loading and emit signal.""" + self.set_loading(True) + self.connect_requested.emit() + + def update_device_info(self, device_info): + """Update device info labels.""" + if device_info: + self._device_info_labels["processor_id"].setText(device_info.processor_id) + self._device_info_labels["uc_width"].setText(device_info.uc_width) + self._device_info_labels["date"].setText(device_info.date) + self._device_info_labels["time"].setText(device_info.time) + self._device_info_labels["appVer"].setText(device_info.app_ver) + self._device_info_labels["dsp_state"].setText(device_info.dsp_state) + + def clear_device_info(self): + """Clear device info labels.""" + for label in self._device_info_labels.values(): + label.setText("Not connected") + + def get_connection_params(self) -> dict: + """Get connection parameters based on selected interface.""" + interface = self.interface_type + params = {"interface": interface} + + if interface == "UART": + params["port"] = self._port_combo.currentText() + params["baud_rate"] = int(self._baud_combo.currentText()) + elif interface == "TCP/IP": + params["host"] = self._ip_edit.text() + params["tcp_port"] = self.tcp_port + elif interface == "CAN": + params["bus_type"] = self._can_bus_type_combo.currentText() + params["channel"] = int(self._can_channel_edit.text()) + params["baudrate"] = self._can_baudrate_combo.currentText() + params["mode"] = self._can_mode_combo.currentText() + params["tx_id"] = self._can_tx_id_edit.text() + params["rx_id"] = self._can_rx_id_edit.text() + + return params + + def set_interface(self, interface: str): + """Set the interface type.""" + index = self._interface_combo.findText(interface) + if index >= 0: + self._interface_combo.setCurrentIndex(index) + + def set_connection_params(self, params: dict): + """Set connection parameters from config.""" + interface = params.get("interface", "UART") + self.set_interface(interface) + + if interface == "UART": + if "baud_rate" in params: + self._baud_combo.setCurrentText(str(params["baud_rate"])) + elif interface == "TCP/IP": + if "host" in params: + self._ip_edit.setText(params["host"]) + if "port" in params: + self._tcp_port_edit.setText(str(params["port"])) + elif interface == "CAN": + if "bus_type" in params: + self._can_bus_type_combo.setCurrentText(params["bus_type"]) + if "channel" in params: + self._can_channel_edit.setText(str(params["channel"])) + if "baudrate" in params: + self._can_baudrate_combo.setCurrentText(params["baudrate"]) + if "mode" in params: + self._can_mode_combo.setCurrentText(params["mode"]) + if "tx_id" in params: + self._can_tx_id_edit.setText(params["tx_id"]) + if "rx_id" in params: + self._can_rx_id_edit.setText(params["rx_id"]) + + def save_connection_settings(self): + """Save current connection settings to persistent storage.""" + self._settings.setValue("connection/interface", self._interface_combo.currentText()) + + # UART settings + self._settings.setValue("connection/uart_baud", self._baud_combo.currentText()) + + # TCP/IP settings + self._settings.setValue("connection/tcp_host", self._ip_edit.text()) + self._settings.setValue("connection/tcp_port", self._tcp_port_edit.text()) + + # CAN settings + self._settings.setValue("connection/can_bus_type", self._can_bus_type_combo.currentText()) + self._settings.setValue("connection/can_channel", self._can_channel_edit.text()) + self._settings.setValue("connection/can_baudrate", self._can_baudrate_combo.currentText()) + self._settings.setValue("connection/can_mode", self._can_mode_combo.currentText()) + self._settings.setValue("connection/can_tx_id", self._can_tx_id_edit.text()) + self._settings.setValue("connection/can_rx_id", self._can_rx_id_edit.text()) + + def _restore_connection_settings(self): + """Restore connection settings from persistent storage.""" + # Restore interface type + interface = self._settings.value("connection/interface", "UART", type=str) + self.set_interface(interface) + + # Restore UART settings + baud = self._settings.value("connection/uart_baud", "115200", type=str) + self._baud_combo.setCurrentText(baud) + + # Restore TCP/IP settings + host = self._settings.value("connection/tcp_host", "192.168.0.100", type=str) + self._ip_edit.setText(host) + tcp_port = self._settings.value("connection/tcp_port", "12666", type=str) + self._tcp_port_edit.setText(tcp_port) + + # Restore CAN settings + bus_type = self._settings.value("connection/can_bus_type", "USB", type=str) + self._can_bus_type_combo.setCurrentText(bus_type) + channel = self._settings.value("connection/can_channel", "1", type=str) + self._can_channel_edit.setText(channel) + baudrate = self._settings.value("connection/can_baudrate", "125K", type=str) + self._can_baudrate_combo.setCurrentText(baudrate) + mode = self._settings.value("connection/can_mode", "Standard", type=str) + self._can_mode_combo.setCurrentText(mode) + tx_id = self._settings.value("connection/can_tx_id", "7F1", type=str) + self._can_tx_id_edit.setText(tx_id) + rx_id = self._settings.value("connection/can_rx_id", "7F0", type=str) + self._can_rx_id_edit.setText(rx_id) diff --git a/pyx2cscope/gui/qt/tabs/watch_view_tab.py b/pyx2cscope/gui/qt/tabs/watch_view_tab.py new file mode 100644 index 00000000..662ea245 --- /dev/null +++ b/pyx2cscope/gui/qt/tabs/watch_view_tab.py @@ -0,0 +1,374 @@ +"""WatchView tab (Tab3) - Dynamic watch variables without plotting.""" + +import logging +from typing import TYPE_CHECKING, List, Tuple + +from PyQt5 import QtCore +from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot +from PyQt5.QtWidgets import ( + QCheckBox, + QDialog, + QGridLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from pyx2cscope.gui.qt.dialogs.variable_selection import VariableSelectionDialog +from pyx2cscope.gui.qt.tabs.base_tab import BaseTab + +if TYPE_CHECKING: + from pyx2cscope.gui.qt.models.app_state import AppState + + +class WatchViewTab(BaseTab): + """Tab for dynamically adding/removing watch variables. + + Features: + - Dynamic row addition/removal + - Live polling for checked variables + - Scaling and offset calculations + - Scrollable interface for many variables + """ + + # Signal emitted when live polling state changes: (index, is_live) + live_polling_changed = pyqtSignal(int, bool) + + def __init__(self, app_state: "AppState", parent=None): + """Initialize the WatchView tab. + + Args: + app_state: The centralized application state. + parent: Optional parent widget. + """ + super().__init__(app_state, parent) + self._variable_list: List[str] = [] + self._current_row = 1 + + # Widget lists for each row + self._row_widgets: List[Tuple] = [] + self._live_checkboxes: List[QCheckBox] = [] + self._variable_edits: List[QLineEdit] = [] + self._value_edits: List[QLineEdit] = [] + self._scaling_edits: List[QLineEdit] = [] + self._offset_edits: List[QLineEdit] = [] + self._scaled_value_edits: List[QLineEdit] = [] + self._unit_edits: List[QLineEdit] = [] + + self._setup_ui() + + def _setup_ui(self): + """Set up the user interface.""" + # Set white background + self.setAutoFillBackground(True) + self.setStyleSheet("background-color: white;") + + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + # Scroll area + scroll_area = QScrollArea() + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll_widget = QWidget() + scroll_layout = QVBoxLayout(scroll_widget) + scroll_area.setWidget(scroll_widget) + scroll_area.setWidgetResizable(True) + main_layout.addWidget(scroll_area) + + # 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) + scroll_layout.addLayout(self._grid_layout) + + # Headers + headers = ["Live", "Variable", "Value", "Scaling", "Offset", "Scaled Value", "Unit", "Remove"] + for col, header in enumerate(headers): + label = QLabel(header) + label.setAlignment(Qt.AlignCenter) + label.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) + self._grid_layout.addWidget(label, 0, col) + + # 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 + + # Add Variable button + self._add_button = QPushButton("Add Variable") + self._add_button.clicked.connect(self._add_variable_row) + scroll_layout.addWidget(self._add_button) + + # Add stretch to push content to top + scroll_layout.addStretch() + + scroll_layout.setContentsMargins(0, 0, 0, 0) + + def on_connection_changed(self, connected: bool): + """Handle connection state changes.""" + self._add_button.setEnabled(connected) + for var_edit in self._variable_edits: + var_edit.setEnabled(connected) + + def on_variable_list_updated(self, variables: list): + """Handle variable list updates.""" + self._variable_list = variables + + def eventFilter(self, source, event): # noqa: N802 + """Event filter to handle line edit click events for variable selection.""" + if event.type() == QtCore.QEvent.MouseButtonPress: + if isinstance(source, QLineEdit) and source in self._variable_edits: + index = self._variable_edits.index(source) + self._on_variable_click(index) + 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 + live_cb = QCheckBox() + live_cb.stateChanged.connect(lambda state, idx=index: self._on_live_changed(idx, 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.installEventFilter(self) + + value_edit = QLineEdit() + value_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + value_edit.editingFinished.connect(lambda idx=index: self._on_value_changed(idx)) + + scaling_edit = QLineEdit("1") + scaling_edit.editingFinished.connect(lambda idx=index: self._update_scaled_value(idx)) + + offset_edit = QLineEdit("0") + offset_edit.editingFinished.connect(lambda idx=index: self._update_scaled_value(idx)) + + scaled_value_edit = QLineEdit() + scaled_value_edit.setReadOnly(True) + + unit_edit = QLineEdit() + + 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) + self._row_widgets.append(widgets) + self._live_checkboxes.append(live_cb) + self._variable_edits.append(var_edit) + 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): + """Remove a variable row from the grid.""" + if index >= len(self._row_widgets): + return + + # Get widgets to remove + widgets = self._row_widgets[index] + + # Remove from grid and delete + for widget in widgets: + self._grid_layout.removeWidget(widget) + widget.deleteLater() + + # Remove from tracking lists + self._row_widgets.pop(index) + self._live_checkboxes.pop(index) + self._variable_edits.pop(index) + self._value_edits.pop(index) + self._scaling_edits.pop(index) + self._offset_edits.pop(index) + self._scaled_value_edits.pop(index) + self._unit_edits.pop(index) + + # Remove from app state + self._app_state.remove_live_watch_var(index) + + self._current_row -= 1 + + # Rearrange grid + self._rearrange_grid() + + def _rearrange_grid(self): + """Rearrange the grid after row removal.""" + # Remove all widgets from grid + for i in reversed(range(self._grid_layout.count())): + widget = self._grid_layout.itemAt(i).widget() + if widget: + widget.setParent(None) + + # Re-add headers + headers = ["Live", "Variable", "Value", "Scaling", "Offset", "Scaled Value", "Unit", "Remove"] + for col, header in enumerate(headers): + self._grid_layout.addWidget(QLabel(header), 0, col) + + # Re-add rows + for row, widgets in enumerate(self._row_widgets, start=1): + for col, widget in enumerate(widgets): + self._grid_layout.addWidget(widget, row, col) + + # Update remove button connections + for i, widgets in enumerate(self._row_widgets): + remove_btn = widgets[7] + remove_btn.clicked.disconnect() + remove_btn.clicked.connect(lambda checked, idx=i: self._remove_variable_row(idx)) + + def _on_variable_click(self, index: int): + """Handle click on variable field to open selection dialog.""" + if not self._variable_list or index >= len(self._variable_edits): + return + + sfr_list = self._app_state.get_sfr_list() + dialog = VariableSelectionDialog(self._variable_list, self, sfr_variables=sfr_list) + if dialog.exec_() == QDialog.Accepted and dialog.selected_variable: + sfr = dialog.sfr_selected + self._variable_edits[index].setText(dialog.selected_variable) + # Store sfr flag before updating name so the var_ref cache uses the right namespace + 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 initial value + value = self._app_state.read_variable(dialog.selected_variable, sfr=sfr) + if value is not None: + self._value_edits[index].setText(str(value)) + 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): + return + 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): + return + + var_name = self._variable_edits[index].text() + if var_name and var_name != "None": + try: + value = float(self._value_edits[index].text()) + sfr = self._app_state.get_live_watch_var(index).sfr + self._app_state.write_variable(var_name, value, sfr=sfr) + except ValueError: + pass + + def _update_scaled_value(self, index: int): + """Update the scaled value for a variable.""" + if index >= len(self._value_edits): + return + + try: + value = self.safe_float(self._value_edits[index].text()) + scaling = self.safe_float(self._scaling_edits[index].text(), 1.0) + 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}") + except Exception as e: + logging.error(f"Error updating scaled value: {e}") + self._scaled_value_edits[index].setText("0.00") + + @pyqtSlot(int, str, float) + def on_live_var_updated(self, index: int, name: str, value: float): + """Handle live variable update from data poller. + + Args: + index: Variable index. + name: Variable name. + value: New value. + """ + if index < len(self._value_edits): + self._value_edits[index].setText(str(value)) + self._update_scaled_value(index) + + def clear_all_rows(self): + """Clear all variable rows.""" + while self._row_widgets: + self._remove_variable_row(0) + + def get_config(self) -> dict: + """Get the current tab configuration.""" + return { + "variables": [le.text() for le in self._variable_edits], + "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], + "live": [cb.isChecked() for cb in self._live_checkboxes], + } + + def load_config(self, config: dict): + """Load configuration into the tab.""" + # Clear existing rows + self.clear_all_rows() + + # Add rows for each variable + variables = config.get("variables", []) + values = config.get("values", []) + scalings = config.get("scaling", []) + offsets = config.get("offsets", []) + scaled_values = config.get("scaled_values", []) + lives = config.get("live", []) + + for i, var in enumerate(variables): + self._add_variable_row() + if i < len(self._variable_edits): + self._variable_edits[i].setText(var) + # 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(scalings) and i < len(self._scaling_edits): + self._scaling_edits[i].setText(scalings[i]) + 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]) + 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]) + + @property + def row_count(self) -> int: + """Get the number of variable rows.""" + return len(self._row_widgets) diff --git a/pyx2cscope/gui/qt/workers/__init__.py b/pyx2cscope/gui/qt/workers/__init__.py new file mode 100644 index 00000000..a616c7a7 --- /dev/null +++ b/pyx2cscope/gui/qt/workers/__init__.py @@ -0,0 +1,5 @@ +"""Background worker threads for the generic GUI.""" + +from .data_poller import DataPoller + +__all__ = ["DataPoller"] diff --git a/pyx2cscope/gui/qt/workers/data_poller.py b/pyx2cscope/gui/qt/workers/data_poller.py new file mode 100644 index 00000000..2be2d834 --- /dev/null +++ b/pyx2cscope/gui/qt/workers/data_poller.py @@ -0,0 +1,274 @@ +"""Background worker thread for data polling. + +This module consolidates all timer-based polling into a single QThread, +replacing the 7 separate QTimer instances from the original implementation. +""" + +import logging +import time +from typing import List + +from PyQt5.QtCore import QMutex, QThread, pyqtSignal + + +class DataPoller(QThread): + """Background thread for polling watch variables and scope data. + + Consolidates polling for: + - Tab1: Watch variables (previously timer1-5) + - Tab1: Plot data updates (previously plot_update_timer) + - Tab2: Scope data sampling (previously scope_timer) + - Tab3: Live variable updates (previously live_update_timer) + + Signals: + watch_var_updated: Emitted when a watch variable value is read. + Args: (index: int, name: str, value: float) + scope_data_ready: Emitted when scope data is available. + Args: (data: dict) + live_var_updated: Emitted when a Tab3 live variable is read. + Args: (index: int, name: str, value: float) + error_occurred: Emitted when an error occurs during polling. + Args: (message: str) + """ + + # Signals for thread-safe UI updates + watch_var_updated = pyqtSignal(int, str, float) # index, name, value + scope_data_ready = pyqtSignal(dict) # channel_data + live_var_updated = pyqtSignal(int, str, float) # index, name, value + error_occurred = pyqtSignal(str) # error message + plot_data_ready = pyqtSignal() # signal to update plot + + def __init__(self, app_state, parent=None): + """Initialize the data poller. + + Args: + app_state: The centralized AppState instance. + parent: Optional parent QObject. + """ + super().__init__(parent) + self._app_state = app_state + self._mutex = QMutex() + self._running = False + + # Polling intervals (ms) + self._watch_interval_ms = 500 + self._scope_interval_ms = 250 + self._live_interval_ms = 500 + + # Enable flags for different polling tasks + self._watch_polling_enabled = False + self._scope_polling_enabled = False + self._live_polling_enabled = False + self._scope_single_shot = False + + # Track which watch variables are active (Tab1) + self._active_watch_indices: List[int] = [] + + # Track which live variables are active (Tab3) + self._active_live_indices: List[int] = [] + + def run(self): + """Main thread loop - polls data at configured intervals.""" + self._running = True + logging.debug("DataPoller thread started") + + # Track last poll times + last_watch_poll = 0.0 + last_scope_poll = 0.0 + last_live_poll = 0.0 + + while self._running: + current_time = time.time() * 1000 # Convert to ms + + try: + # Poll watch variables (Tab1) + if self._watch_polling_enabled: + if current_time - last_watch_poll >= self._watch_interval_ms: + self._poll_watch_variables() + last_watch_poll = current_time + + # Poll scope data (Tab2) + if self._scope_polling_enabled: + if current_time - last_scope_poll >= self._scope_interval_ms: + self._poll_scope_data() + last_scope_poll = current_time + + # Poll live variables (Tab3) + if self._live_polling_enabled: + if current_time - last_live_poll >= self._live_interval_ms: + self._poll_live_variables() + last_live_poll = current_time + + except Exception as e: + logging.error(f"DataPoller error: {e}") + self.error_occurred.emit(str(e)) + + # Sleep to prevent busy-waiting (10ms granularity) + self.msleep(10) + + def stop(self): + """Stop the polling thread.""" + self._running = False + self.wait() + + # ============= Watch Variables (Tab1) ============= + + def set_watch_polling_enabled(self, enabled: bool): + """Enable or disable watch variable polling.""" + self._mutex.lock() + self._watch_polling_enabled = enabled + self._mutex.unlock() + + def set_active_watch_indices(self, indices: List[int]): + """Set which watch variable indices should be polled.""" + self._mutex.lock() + self._active_watch_indices = indices.copy() + self._mutex.unlock() + + def add_active_watch_index(self, index: int): + """Add a watch variable index to active polling.""" + logging.debug(f"add_active_watch_index: adding index {index}") + self._mutex.lock() + if index not in self._active_watch_indices: + self._active_watch_indices.append(index) + self._watch_polling_enabled = len(self._active_watch_indices) > 0 + logging.debug(f"add_active_watch_index: enabled={self._watch_polling_enabled}, indices={self._active_watch_indices}") + self._mutex.unlock() + + def remove_active_watch_index(self, index: int): + """Remove a watch variable index from active polling.""" + self._mutex.lock() + if index in self._active_watch_indices: + self._active_watch_indices.remove(index) + self._watch_polling_enabled = len(self._active_watch_indices) > 0 + self._mutex.unlock() + + def _poll_watch_variables(self): + """Poll all active watch variables.""" + if not self._app_state.is_connected(): + logging.debug("_poll_watch_variables: not connected") + return + + self._mutex.lock() + indices = self._active_watch_indices.copy() + self._mutex.unlock() + + logging.debug(f"_poll_watch_variables: polling indices {indices}") + 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": + # 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}") + if value is not None: + self._app_state.update_watch_var_field(index, "value", value) + self.watch_var_updated.emit(index, watch_var.name, value) + + # Signal plot update after all variables polled + if indices: + self.plot_data_ready.emit() + + # ============= Scope Data (Tab2) ============= + + def set_scope_polling_enabled(self, enabled: bool, single_shot: bool = False): + """Enable or disable scope data polling.""" + self._mutex.lock() + self._scope_polling_enabled = enabled + self._scope_single_shot = single_shot + self._mutex.unlock() + + def _poll_scope_data(self): + """Poll scope data if ready.""" + if not self._app_state.is_connected(): + return + + if self._app_state.is_scope_data_ready(): + data = self._app_state.get_scope_channel_data() + if data: + self.scope_data_ready.emit(data) + + # Handle single-shot mode + self._mutex.lock() + is_single_shot = self._scope_single_shot + self._mutex.unlock() + + if is_single_shot: + self.set_scope_polling_enabled(False) + else: + # Request next data + self._app_state.request_scope_data() + + # ============= Live Variables (Tab3) ============= + + def set_live_polling_enabled(self, enabled: bool): + """Enable or disable live variable polling (Tab3).""" + self._mutex.lock() + self._live_polling_enabled = enabled + self._mutex.unlock() + + def set_active_live_indices(self, indices: List[int]): + """Set which live variable indices should be polled.""" + self._mutex.lock() + self._active_live_indices = indices.copy() + self._mutex.unlock() + + def add_active_live_index(self, index: int): + """Add a live variable index to active polling.""" + logging.debug(f"add_active_live_index: adding index {index}") + self._mutex.lock() + if index not in self._active_live_indices: + self._active_live_indices.append(index) + self._live_polling_enabled = len(self._active_live_indices) > 0 + logging.debug(f"add_active_live_index: enabled={self._live_polling_enabled}, indices={self._active_live_indices}") + self._mutex.unlock() + + def remove_active_live_index(self, index: int): + """Remove a live variable index from active polling.""" + self._mutex.lock() + if index in self._active_live_indices: + self._active_live_indices.remove(index) + self._live_polling_enabled = len(self._active_live_indices) > 0 + self._mutex.unlock() + + def _poll_live_variables(self): + """Poll all active live variables (Tab3).""" + if not self._app_state.is_connected(): + logging.debug("_poll_live_variables: not connected") + return + + self._mutex.lock() + indices = self._active_live_indices.copy() + self._mutex.unlock() + + logging.debug(f"_poll_live_variables: polling indices {indices}") + 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": + # 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}") + if value is not None: + self._app_state.update_live_watch_var_field(index, "value", value) + self.live_var_updated.emit(index, live_var.name, value) + + # ============= Interval Configuration ============= + + def set_watch_interval(self, interval_ms: int): + """Set the watch variable polling interval.""" + self._mutex.lock() + self._watch_interval_ms = max(50, interval_ms) + self._mutex.unlock() + + def set_scope_interval(self, interval_ms: int): + """Set the scope data polling interval.""" + self._mutex.lock() + self._scope_interval_ms = max(50, interval_ms) + self._mutex.unlock() + + def set_live_interval(self, interval_ms: int): + """Set the live variable polling interval.""" + self._mutex.lock() + self._live_interval_ms = max(50, interval_ms) + self._mutex.unlock() diff --git a/pyx2cscope/gui/resources/__init__.py b/pyx2cscope/gui/resources/__init__.py new file mode 100644 index 00000000..73345094 --- /dev/null +++ b/pyx2cscope/gui/resources/__init__.py @@ -0,0 +1,10 @@ +"""Shared resources for Qt and Web GUIs.""" + +import os + +RESOURCES_DIR = os.path.dirname(__file__) + + +def get_resource_path(filename: str) -> str: + """Get the full path to a resource file.""" + return os.path.join(RESOURCES_DIR, filename) diff --git a/pyx2cscope/gui/resources/script_help.md b/pyx2cscope/gui/resources/script_help.md new file mode 100644 index 00000000..13b773de --- /dev/null +++ b/pyx2cscope/gui/resources/script_help.md @@ -0,0 +1,138 @@ +This Scripting Section allows you to run Python scripts without the use of an IDE. You can load Python scripts and run them standalone as the examples available under PyX2Cscope examples folder. Otherwise you can take advantage of this App and use the Setup tab to connect to your device. Doing this, the script has access to the x2cscope connection and some methods as described below. + +For further information, check the PyX2Cscope scripting documentation at: + +[https://x2cscope.github.io/pyx2cscope/scripting.html](https://x2cscope.github.io/pyx2cscope/scripting.html) + + +## Available Objects/Functions + +- **x2cscope** - The X2CScope instance (or `None` if not connected) +- **stop_requested()** - Returns `True` when Stop button is pressed + +--- + +## Basic Example + +```python +var = x2cscope.get_variable("myVariable") +value = var.get_value() +print(f"Current value: {value}") +``` + +## Write a Value + +```python +var = x2cscope.get_variable("myVariable") +var.set_value(123.45) +print("Value written successfully") +``` + +## Read Multiple Variables + +```python +variables = ["var1", "var2", "var3"] +for name in variables: + var = x2cscope.get_variable(name) + if var: + print(f"{name} = {var.get_value()}") +``` + +## List All Variables + +```python +all_vars = x2cscope.list_variables() +for name in all_vars[:10]: # First 10 + print(name) +``` + +--- + +## Loop with Stop Support + +Use `stop_requested()` to make your loops respond to the **Stop** button: + +```python +import time + +while not stop_requested(): + var = x2cscope.get_variable("myVar") + print(f"Value: {var.get_value()}") + time.sleep(0.5) + +print("Script stopped gracefully") +``` + +--- + +## Script that Works Both Standalone and in App + +Use `globals().get()` to check if variables are injected: + +```python +from pyx2cscope.x2cscope import X2CScope +from pyx2cscope.utils import get_elf_file_path +import time + +# Use injected x2cscope or create our own +if globals().get("x2cscope") is None: + x2cscope = X2CScope(port="COM3", elf_file=get_elf_file_path()) + +# Use injected stop_requested or a dummy +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) +``` + +--- + +## Create Your Own Connection (Standalone Mode) + +If you want to run independently from the GUI connection: + +```python +from pyx2cscope.x2cscope import X2CScope + +# Create new connection (will fail if GUI is already connected!) +my_scope = X2CScope(port="COM3", elf_file="path/to/your.elf") + +# Use my_scope instead of x2cscope +var = my_scope.get_variable("myVar") +print(var.get_value()) +``` + +> **Note:** Creating your own connection while the GUI is connected to the same port will cause conflicts! + +--- + +## Scope Data Example + +```python +# Add scope channels +var1 = x2cscope.get_variable("channel1") +x2cscope.add_scope_channel(var1) + +# Request data +x2cscope.request_scope_data() + +# Wait and get data +import time +time.sleep(0.5) +if x2cscope.is_scope_data_ready(): + data = x2cscope.get_scope_channel_data() + print(data) +``` + +--- + +## Tips + +1. Always check if **x2cscope** is available before using it +2. Use `print()` statements - output appears in **Script Output** tab +3. Use `stop_requested()` in loops so the **Stop** button works +4. Enable "Log output to file" to save output to a file +5. The script runs in the same process as the GUI, so avoid blocking operations that take too long +6. Changes made to variables affect the actual hardware! diff --git a/pyx2cscope/gui/web/app.py b/pyx2cscope/gui/web/app.py index e51185ee..6f37a90b 100644 --- a/pyx2cscope/gui/web/app.py +++ b/pyx2cscope/gui/web/app.py @@ -25,11 +25,15 @@ def create_app(): """ app = Flask(__name__) + from pyx2cscope.gui.web.views.dashboard_view import dv_bp as dashboard_view from pyx2cscope.gui.web.views.scope_view import sv_bp as scope_view + from pyx2cscope.gui.web.views.script_view import script_bp as script_view from pyx2cscope.gui.web.views.watch_view import wv_bp as watch_view - app.register_blueprint(watch_view, url_prefix="/watch-view") - app.register_blueprint(scope_view, url_prefix="/scope-view") + app.register_blueprint(watch_view, url_prefix="/watch") + app.register_blueprint(scope_view, url_prefix="/scope") + app.register_blueprint(dashboard_view, url_prefix="/dashboard") + app.register_blueprint(script_view, url_prefix="/scripting") app.add_url_rule("/", view_func=index) app.add_url_rule("/serial-ports", view_func=list_serial_ports) @@ -66,23 +70,63 @@ def connect(): call {server_url}/connect to execute. """ - uart = request.form.get("uart") + interface_type = request.form.get("interfaceType") elf_file = request.files.get("elfFile") - if "default" not in uart and elf_file and elf_file.filename.endswith(".elf"): + + interface_kwargs = {} + + if interface_type == "CAN": + # CAN baudrate string to numeric mapping + baudrate_map = { + "125K": 125000, + "250K": 250000, + "500K": 500000, + "1M": 1000000, + } + can_bus_type = request.form.get("canBusType", "USB") + can_channel = int(request.form.get("canChannel", 1)) + can_baudrate = request.form.get("canBaudrate", "125K") + can_mode = request.form.get("canMode", "Standard") + can_tx_id = request.form.get("canTxId", "7F1") + can_rx_id = request.form.get("canRxId", "7F0") + + interface_kwargs = { + "bus": can_bus_type.lower(), + "channel": can_channel, + "baudrate": baudrate_map.get(can_baudrate, 125000), + "tx_id": int(can_tx_id, 16), + "rx_id": int(can_rx_id, 16), + "extended": can_mode == "Extended", + } + elif interface_type == "TCP_IP": + host = request.form.get("host", "localhost") + tcp_port = int(request.form.get("tcpPort", 12666)) + interface_kwargs = { + "host": host, + "tcp_port": tcp_port, + } + else: + # SERIAL + interface_arg_str = request.form.get("interfaceArgument") + interface_value_str = request.form.get("interfaceValue") + if interface_arg_str and interface_value_str: + interface_kwargs = {interface_arg_str: interface_value_str} + + if elf_file and elf_file.filename.endswith((".elf", ".pkl", ".yml")): web_lib_path = os.path.join(os.path.dirname(web.__file__), "upload") if not os.path.exists(web_lib_path): os.makedirs(web_lib_path) - file_name = os.path.join(web_lib_path, "elf_file.elf") + file_name = os.path.join(web_lib_path, os.path.basename(elf_file.filename)) try: elf_file.save(file_name) - web_scope.connect(port=uart) + web_scope.connect(**interface_kwargs) web_scope.set_file(file_name) return jsonify({"status": "success"}) except RuntimeError as e: return jsonify({"status": "error", "msg": str(e)}), 401 except ValueError as e: return jsonify({"status": "error", "msg": str(e)}), 401 - return jsonify({"status": "error", "msg": "COM Port or ELF file invalid."}), 400 + return jsonify({"status": "error", "msg": "Interface argument or import file invalid."}), 400 def is_connected(): @@ -107,13 +151,16 @@ def variables_autocomplete(): Receiving at least 3 letters, the function will search on pyX2Cscope parsed variables to find similar matches, returning a list of possible candidates. Access this function over {server_url}/variables. + Use the query parameter ``sfr=true`` to search SFRs instead of firmware variables. """ query = request.args.get("q", "") + sfr = request.args.get("sfr", "false").lower() == "true" items = [] if web_scope.is_connected(): + var_list = web_scope.list_sfr() if sfr else web_scope.list_variables() items = [ {"id": var, "text": var} - for var in web_scope.list_variables() + for var in var_list if query.lower() in var.lower() ] return jsonify({"items": items}) @@ -132,12 +179,30 @@ def get_variables(): def open_browser(host="localhost", web_port=5000): """Open a new browser pointing to the Flask server. + Only opens if no clients are already connected (e.g., from a previous session). + Existing browser tabs will reconnect automatically via Socket.IO. + Args: host (str): the host address/name web_port (int): the host port. """ - socketio.sleep(1) - webbrowser.open("http://" + host + ":" + str(web_port)) + # Wait for any existing browser tabs to reconnect + socketio.sleep(2) + + # Check if any clients are already connected via Socket.IO + has_clients = False + try: + if hasattr(socketio.server, 'eio') and hasattr(socketio.server.eio, 'sockets'): + has_clients = len(socketio.server.eio.sockets) > 0 + except Exception: + pass + + if not has_clients: + url = "http://" + ("localhost" if host == "0.0.0.0" else host) + ":" + str(web_port) + webbrowser.open(url) + print("Browser opened: " + url) + else: + print("Browser tab already connected - refresh the page to reflect changes") def main(host="localhost", web_port=5000, new=True, *args, **kwargs): diff --git a/pyx2cscope/gui/web/scope.py b/pyx2cscope/gui/web/scope.py index 0f202db1..691436f7 100644 --- a/pyx2cscope/gui/web/scope.py +++ b/pyx2cscope/gui/web/scope.py @@ -25,6 +25,10 @@ def __init__(self): self.scope_sample_time = 1 self.scope_time_sampling = 50e-3 + self.dashboard_vars = {} # {var_name: Variable object} + self.dashboard_rate = 1.0 # Fixed at 1 second for dashboard polling + self.dashboard_next = time.time() + self.x2c_scope :X2CScope | None = None self._lock = extensions.create_lock() @@ -60,7 +64,7 @@ def _get_scope_variable_as_dict(self, variable): "trigger": 0, "enable": 1, "variable": variable, - "color": colors[len(self.scope_vars)], + "color": colors[len(self.scope_vars) % len(colors)], "gain": 1, "offset": 0, "remove": 0, @@ -133,18 +137,19 @@ def clear_watch_var(self): """Clear all watch variables.""" self.watch_vars.clear() - def add_watch_var(self, var): + def add_watch_var(self, var, sfr: bool = False): """Add a variable to the watch list. Args: var (str): Variable name to add. + sfr (bool): Whether to retrieve a peripheral register (SFR) instead of a firmware variable. Returns: dict | None: Variable data dictionary if successful, None otherwise. """ var_dict = None if not any(_data["variable"].info.name == var for _data in self.watch_vars): - variable = self.x2c_scope.get_variable(var) + variable = self.x2c_scope.get_variable(var, sfr=sfr) if variable is not None: var_dict = self._get_watch_variable_as_dict(variable) self.watch_vars.append(var_dict) @@ -184,24 +189,83 @@ def watch_poll(self): return result + # Dashboard variable methods + def add_dashboard_var(self, name): + """Add a variable to the dashboard polling list. + + Args: + name (str): Variable name to add. + + Returns: + bool: True if variable was added successfully. + """ + if name not in self.dashboard_vars: + variable = self.x2c_scope.get_variable(name) + if variable is not None: + self.dashboard_vars[name] = variable + return True + return False + + def remove_dashboard_var(self, name): + """Remove a variable from the dashboard polling list. + + Args: + name (str): Variable name to remove. + """ + self.dashboard_vars.pop(name, None) + + def write_dashboard_var(self, name, value): + """Write a value to a device variable from a dashboard widget. + + Args: + name (str): Variable name. + value: Value to write. + """ + variable = self.dashboard_vars.get(name) + if variable is not None: + with self._lock: + variable.set_value(float(value)) + + def dashboard_poll(self): + """Poll all dashboard variables and return updated values. + + Returns: + dict: Dictionary of {var_name: value} for all dashboard variables. + """ + if not self.dashboard_vars: + return {} + current_time = time.time() + if current_time < self.dashboard_next: + return {} + self.dashboard_next = current_time + self.dashboard_rate + result = {} + with self._lock: + for name, variable in self.dashboard_vars.items(): + try: + result[name] = variable.get_value() + except Exception: + pass + return result + def clear_scope_var(self): """Clear all scope variables.""" with self._lock: self.scope_vars.clear() self.x2c_scope.clear_all_scope_channel() - def add_scope_var(self, var): + def add_scope_var(self, var, sfr: bool = False): """Add a variable to the scope channel list. Args: var (str): Variable name to add. + sfr (bool): Whether to retrieve a peripheral register (SFR) instead of a firmware variable. Returns: dict | None: Variable data dictionary if successful, None otherwise. """ var_dict = None if not any(data["variable"].info.name == var for data in self.scope_vars): - variable = self.x2c_scope.get_variable(var) + variable = self.x2c_scope.get_variable(var, sfr=sfr) if variable is not None: var_dict = self._get_scope_variable_as_dict(variable) self.scope_vars.append(var_dict) @@ -299,34 +363,50 @@ def scope_poll(self): """Poll scope data and return datasets when ready. Returns: - dict: Dictionary containing datasets and labels, or empty dict. + tuple: (scope_view_data, dashboard_scope_data) where scope_view_data is + a dict with datasets and labels (or empty dict), and dashboard_scope_data + is a dict of {var_name: [samples]} with raw data for all scope channels. """ with self._lock: if self.scope_trigger: if self.x2c_scope.is_scope_data_ready(): - datasets = self.get_scope_datasets() + channel_data = self.x2c_scope.get_scope_channel_data() + datasets = self._get_scope_datasets(channel_data, self.scope_vars) size = len(datasets[0]["data"]) if len(datasets) > 0 else 1000 labels = self.get_scope_chart_label(size) + # Build raw data dict for dashboard (gain/offset applied per channel) + dashboard_data = {} + for channel in self.scope_vars: + name = channel["variable"].info.name + if name in channel_data: + dashboard_data[name] = [ + sample * channel["gain"] + channel["offset"] + for sample in channel_data[name] + ] + if self.scope_burst: self.scope_burst = False self.scope_trigger = False else: self.x2c_scope.request_scope_data() - return {"datasets": datasets, "labels": labels} - return {} + return {"datasets": datasets, "labels": labels}, dashboard_data + return {}, {} - def get_scope_datasets(self): - """Get scope channel datasets. + @staticmethod + def _get_scope_datasets(channel_data, scope_vars): + """Build scope chart datasets from channel data. + + Args: + channel_data (dict): Raw channel data from X2CScope. + scope_vars (list): List of scope variable dictionaries. Returns: list: List of dataset dictionaries for each channel. """ data = [] - channel_data = self.x2c_scope.get_scope_channel_data() - for channel in self.scope_vars: - # if variable is disabled on scope_data, it is not available on channel_data + for channel in scope_vars: if channel["variable"].info.name in channel_data: variable = channel["variable"].info.name data_line = [ @@ -342,6 +422,15 @@ def get_scope_datasets(self): data.append(item) return data + def get_scope_datasets(self): + """Get scope channel datasets. + + Returns: + list: List of dataset dictionaries for each channel. + """ + channel_data = self.x2c_scope.get_scope_channel_data() + return self._get_scope_datasets(channel_data, self.scope_vars) + def get_scope_chart_label(self, size=100): """Generate time labels for scope chart. @@ -378,6 +467,14 @@ def list_variables(self): """ return self.x2c_scope.list_variables() + def list_sfr(self): + """List all available SFR (Special Function Register) names. + + Returns: + list: List of SFR names. + """ + return self.x2c_scope.list_sfr() + def disconnect(self): """Disconnect from X2CScope.""" self.x2c_scope.disconnect() diff --git a/pyx2cscope/gui/web/static/css/dashboard.css b/pyx2cscope/gui/web/static/css/dashboard.css new file mode 100644 index 00000000..e2fa0516 --- /dev/null +++ b/pyx2cscope/gui/web/static/css/dashboard.css @@ -0,0 +1,143 @@ +.dashboard-widget { + position: absolute; + background: white; + border: 2px solid #dee2e6; + border-radius: 8px; + padding: 12px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + min-width: 200px; + min-height: 100px; +} + +.dashboard-widget.edit-mode { + cursor: move; + overflow: auto; +} + +.dashboard-widget.edit-mode.selected { + resize: both; +} + +.dashboard-widget.view-mode { + cursor: default; + resize: none !important; + border-color: transparent; + box-shadow: none; + background: transparent; +} + +.dashboard-widget.edit-mode:not(.selected) { + border-style: dashed; + border-color: #adb5bd; + opacity: 0.9; +} + +.dashboard-widget.edit-mode:not(.selected):hover { + border-color: #6c757d; + opacity: 1; +} + +.dashboard-widget.selected { + border-color: #0d6efd; + border-style: solid; + box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.25); +} + +.widget-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid #dee2e6; +} + +.widget-title { + font-weight: 600; + color: #495057; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 4px; +} + +.widget-controls { + display: flex; + gap: 4px; +} + +.widget-controls .btn { + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; +} + +.widget-controls .btn:hover { + background: #f8f9fa; + border-radius: 4px; +} + +.widget-controls .btn .material-icons { + font-size: 18px; +} + +.view-mode .widget-controls { + display: none; +} + +.widget-content { + margin-top: 8px; +} + +.widget-content-only { + margin: 0; +} + +.dashboard-widget input[type="range"], +.dashboard-widget input[type="number"], +.dashboard-widget input[type="text"] { + width: 100%; +} + +.dashboard-widget .btn { + width: 100%; +} + +.value-display { + font-size: 1.25rem; + color: #212529; + text-align: center; + padding: 4px; + font-weight: 500; +} + +.gauge-container, +.plot-container { + width: 100%; + min-height: 150px; + height: 100%; +} + +.dashboard-widget.edit-mode.selected::after { + content: '⋰'; + position: absolute; + bottom: 5px; + right: 5px; + color: #adb5bd; + font-size: 16px; + pointer-events: none; +} + +.view-mode .dashboard-widget::after { + display: none; +} + +/* Label widgets don't block interactions with widgets underneath in view mode */ +.dashboard-widget.widget-type-label.view-mode { + pointer-events: none; +} \ No newline at end of file diff --git a/pyx2cscope/gui/web/static/js/dashboard_view.js b/pyx2cscope/gui/web/static/js/dashboard_view.js new file mode 100644 index 00000000..c6b9c5d4 --- /dev/null +++ b/pyx2cscope/gui/web/static/js/dashboard_view.js @@ -0,0 +1,1049 @@ +// Dashboard View JavaScript +// This file handles all dashboard widget functionality + +let dashboardWidgets = []; +let selectedWidget = null; +let isDashboardEditMode = true; +let draggedWidget = null; +let dragOffsetX = 0; +let dragOffsetY = 0; +let currentWidgetType = ''; +let widgetIdCounter = 0; +let dashboardSocket = null; +let scopeSocket = null; // Separate socket for scope-related commands + +// Track chart.js instances per-widget +let dashboardCharts = {}; + +// Initialize widget types registry (widgets register themselves here) +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +// Initialize dashboard when page loads (use 'load' to ensure widget scripts have registered) +window.addEventListener('load', function() { + initializeDashboard(); +}); + +function initializeDashboard() { + // Populate widget palette from registered widget types + populateWidgetPalette(); + + // Initialize Socket.IO if available + if (typeof io !== 'undefined') { + dashboardSocket = io('/dashboard'); + + dashboardSocket.on('connect', () => { + console.log('Dashboard connected to server'); + registerAllDashboardVariables(); + }); + + dashboardSocket.on('dashboard_data_update', (data) => { + // data is {var1: value1, var2: value2, ...} — for watch-like widgets only + for (let varName in data) { + updateDashboardWatchWidgets(varName, data[varName]); + } + }); + + dashboardSocket.on('dashboard_scope_update', (data) => { + // data is {var1: [...], var2: [...]} — for plot_scope widgets only + for (let varName in data) { + updateDashboardScopeWidgets(varName, data[varName]); + } + }); + + // Connect to scope-view namespace for sending scope trigger commands + scopeSocket = io('/scope-view'); + scopeSocket.on('connect', () => { + console.log('Dashboard connected to scope-view namespace'); + // Fetch current scope variables via HTTP + fetchScopeVariables(); + }); + + // Listen for scope table updates (variables added/removed) + scopeSocket.on('scope_table_update', (data) => { + console.log('Scope table update:', data); + // Refetch variables when scope table changes + fetchScopeVariables(); + }); + + // Listen for sample control updates + scopeSocket.on('sample_control_updated', (response) => { + if (response.status === 'success' && response.data) { + updateScopeControlSampleState(response.data); + } + }); + + // Listen for trigger control updates + scopeSocket.on('trigger_control_updated', (response) => { + if (response.status === 'success' && response.data) { + updateScopeControlTriggerState(response.data); + } + }); + + } + + // Set up file input for import + document.getElementById('dashboardFileInput').addEventListener('change', handleDashboardFileImport); + + // Set up canvas click handler for deselecting widgets + initCanvasClickHandler(); + + // Fetch scope variables via HTTP + fetchScopeVariables(); + + // Initialize UI for edit mode (default state) + initEditModeUI(); +} + +function initEditModeUI() { + const btn = document.getElementById('dashboardModeBtn'); + const icon = btn?.querySelector('.material-icons'); + const palette = document.getElementById('widgetPalette'); + const canvasCol = document.getElementById('dashboardCanvasCol'); + const canvas = document.getElementById('dashboardCanvas'); + + if (isDashboardEditMode) { + if (icon) { + icon.textContent = 'edit'; + icon.classList.remove('text-secondary'); + icon.classList.add('text-success'); + } + if (btn) btn.title = 'Edit Mode (Active)'; + if (palette) palette.style.display = 'block'; + if (canvasCol) { + canvasCol.classList.remove('col-12'); + canvasCol.classList.add('col-12', 'col-md-9', 'col-lg-10'); + } + if (canvas) { + canvas.classList.remove('view-mode'); + canvas.classList.add('edit-mode'); + } + } +} + +// Track scope variables list from scope-view +window.scopeVariablesList = []; + +// Fetch scope variables from server +function fetchScopeVariables() { + fetch('/scope/data') + .then(response => response.json()) + .then(data => { + if (data.data) { + window.scopeVariablesList = data.data.map(v => v.variable); + updateScopeControlVariables(); + } + }) + .catch(err => console.log('Could not fetch scope variables:', err)); +} + +// Update scope control widgets when variables change +function updateScopeControlVariables() { + dashboardWidgets + .filter(w => w.type === 'scope_control') + .forEach(widget => { + // Update trigger variable dropdown + const dropdown = document.getElementById(`scopeCtrlTriggerVar-${widget.id}`); + if (dropdown) { + // Use saved widget.triggerVar if dropdown is empty (initial load) + const currentValue = dropdown.value || widget.triggerVar || ''; + dropdown.innerHTML = ''; + (window.scopeVariablesList || []).forEach(v => { + const opt = document.createElement('option'); + opt.value = v; + opt.textContent = v; + if (v === currentValue) opt.selected = true; + dropdown.appendChild(opt); + }); + } + }); +} + +// Update scope control sample state +function updateScopeControlSampleState(data) { + if (data.triggerAction) { + scopeControlState = data.triggerAction; + } + dashboardWidgets + .filter(w => w.type === 'scope_control') + .forEach(widget => { + // Update sample time/freq if elements exist + const sampleTimeEl = document.getElementById('scopeCtrlSampleTime'); + const sampleFreqEl = document.getElementById('scopeCtrlSampleFreq'); + if (sampleTimeEl && data.sampleTime) sampleTimeEl.value = data.sampleTime; + if (sampleFreqEl && data.sampleFreq) sampleFreqEl.value = data.sampleFreq; + + // Update button states + const widgetEl = document.getElementById(`dashboard-widget-${widget.id}`); + if (widgetEl) { + const widgetDef = window.dashboardWidgetTypes[widget.type]; + if (widgetDef?.refresh) widgetDef.refresh(widget, widgetEl); + } + }); +} + +// Update scope control trigger state +function updateScopeControlTriggerState(data) { + dashboardWidgets + .filter(w => w.type === 'scope_control') + .forEach(widget => { + // Update trigger mode + if (data.trigger_mode !== undefined) { + const enableRadio = document.getElementById(`scopeCtrlTriggerEnable-${widget.id}`); + const disableRadio = document.getElementById(`scopeCtrlTriggerDisable-${widget.id}`); + if (enableRadio && disableRadio) { + enableRadio.checked = data.trigger_mode === '1' || data.trigger_mode === 1; + disableRadio.checked = data.trigger_mode === '0' || data.trigger_mode === 0; + } + } + // Update trigger edge + if (data.trigger_edge !== undefined) { + const risingRadio = document.getElementById(`scopeCtrlEdgeRising-${widget.id}`); + const fallingRadio = document.getElementById(`scopeCtrlEdgeFalling-${widget.id}`); + if (risingRadio && fallingRadio) { + risingRadio.checked = data.trigger_edge === '1' || data.trigger_edge === 1; + fallingRadio.checked = data.trigger_edge === '0' || data.trigger_edge === 0; + } + } + // Update trigger level (snake_case from backend) + if (data.trigger_level !== undefined) { + const levelEl = document.getElementById(`scopeCtrlTriggerLevel-${widget.id}`); + if (levelEl) levelEl.value = data.trigger_level; + } + // Update trigger delay (snake_case from backend) + if (data.trigger_delay !== undefined) { + const delayEl = document.getElementById(`scopeCtrlTriggerDelay-${widget.id}`); + if (delayEl) delayEl.value = data.trigger_delay; + } + }); +} + +// Sync scope control settings to backend after loading dashboard +function syncScopeControlToBackend() { + if (!scopeSocket || !scopeSocket.connected) { + // Retry after a short delay if socket not ready + setTimeout(syncScopeControlToBackend, 500); + return; + } + + // First: Register plot_scope variables with scope view + dashboardWidgets + .filter(w => w.type === 'plot_scope') + .forEach(widget => { + if (widget.variables) { + widget.variables.forEach(v => { + // Check if not already in scope view + if (!window.scopeVariablesList.includes(v)) { + scopeSocket.emit('add_scope_var', { var: v }); + } + }); + } + }); + + // Find scope_control widgets and sync their settings + const scopeControlWidget = dashboardWidgets.find(w => w.type === 'scope_control'); + if (scopeControlWidget) { + // Sync sample control settings + const sampleTime = scopeControlWidget.sampleTime || 1; + const sampleFreq = scopeControlWidget.sampleFreq || 20; + const sampleFormData = `triggerAction=off&sampleTime=${sampleTime}&sampleFreq=${sampleFreq}`; + scopeSocket.emit('update_sample_control', sampleFormData); + + // Sync trigger control settings + const triggerMode = scopeControlWidget.triggerMode || '0'; + const triggerEdge = scopeControlWidget.triggerEdge || '1'; + const triggerLevel = scopeControlWidget.triggerLevel || 0; + const triggerDelay = scopeControlWidget.triggerDelay || 0; + const triggerFormData = `trigger_mode=${triggerMode}&trigger_edge=${triggerEdge}&trigger_level=${triggerLevel}&trigger_delay=${triggerDelay}`; + scopeSocket.emit('update_trigger_control', triggerFormData); + + // Sync trigger variable selection (wait for variables to be registered first) + const triggerVar = scopeControlWidget.triggerVar || ''; + if (triggerVar) { + // Wait for scope variables to be registered, then set trigger + setTimeout(() => { + // Refetch scope variables to ensure we have the latest list + fetch('/scope/data') + .then(response => response.json()) + .then(data => { + if (data.data) { + const scopeVars = data.data.map(v => v.variable); + scopeVars.forEach(varName => { + scopeSocket.emit('update_scope_var', { + param: varName, + field: 'trigger', + value: varName === triggerVar ? '1' : '0' + }); + }); + // Re-send trigger control settings after setting trigger variable + // This ensures the backend properly initializes the trigger + setTimeout(() => { + scopeSocket.emit('update_trigger_control', triggerFormData); + }, 500); + } + }) + .catch(err => console.log('Could not sync trigger variable:', err)); + }, 1500); + } + } +} + +function populateWidgetPalette() { + const container = document.getElementById('widgetPaletteButtons'); + if (!container || !window.dashboardWidgetTypes) return; + + container.innerHTML = ''; + + for (const [type, def] of Object.entries(window.dashboardWidgetTypes)) { + const displayName = def.name || type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const btn = document.createElement('button'); + btn.className = 'btn btn-sm btn-outline-primary text-start'; + btn.onclick = () => showWidgetConfig(type); + btn.innerHTML = ` ${displayName}`; + container.appendChild(btn); + } +} + +function registerAllDashboardVariables() { + dashboardWidgets.forEach(widget => registerWidgetVariables(widget)); +} + +function removeAllDashboardVariables() { + dashboardWidgets.forEach(widget => unregisterWidgetVariables(widget)); +} + +function registerWidgetVariables(widget) { + if (!dashboardSocket || !dashboardSocket.connected) return; + + if (widget.type === 'plot_scope') { + // Register as shared scope channels so data flows when scope is triggered + widget.variables?.forEach(varName => { + dashboardSocket.emit('register_scope_channel', {var: varName}); + }); + } else if (widget.type === 'plot_logger') { + widget.variables?.forEach(varName => { + dashboardSocket.emit('add_dashboard_var', {var: varName}); + }); + } else if (widget.type !== 'label') { + dashboardSocket.emit('add_dashboard_var', {var: widget.variable}); + } +} + +function isVarUsedByOtherWidgets(widget, varName) { + return dashboardWidgets.some(w => { + if (w.id === widget.id) return false; + if (w.type === 'plot_logger' || w.type === 'plot_scope') { + return w.variables && w.variables.includes(varName); + } + return w.variable === varName; + }); +} + +function unregisterWidgetVariables(widget) { + if (!dashboardSocket || !dashboardSocket.connected) return; + + if (widget.type === 'plot_scope') { + widget.variables?.forEach(varName => { + if (!isVarUsedByOtherWidgets(widget, varName)) { + dashboardSocket.emit('unregister_scope_channel', {var: varName}); + } + }); + } else if (widget.type === 'plot_logger') { + widget.variables?.forEach(varName => { + if (!isVarUsedByOtherWidgets(widget, varName)) { + dashboardSocket.emit('remove_dashboard_var', {var: varName}); + } + }); + } else if (widget.type !== 'label') { + if (!isVarUsedByOtherWidgets(widget, widget.variable)) { + dashboardSocket.emit('remove_dashboard_var', {var: widget.variable}); + } + } +} + +function toggleDashboardMode() { + isDashboardEditMode = !isDashboardEditMode; + selectedWidget = null; // Deselect any selected widget when switching modes + const btn = document.getElementById('dashboardModeBtn'); + const icon = btn.querySelector('.material-icons'); + const palette = document.getElementById('widgetPalette'); + const canvasCol = document.getElementById('dashboardCanvasCol'); + const canvas = document.getElementById('dashboardCanvas'); + + if (isDashboardEditMode) { + icon.textContent = 'edit'; + icon.classList.remove('text-secondary'); + icon.classList.add('text-success'); + btn.title = 'Edit Mode (Active)'; + palette.style.display = 'block'; + canvasCol.classList.remove('col-12'); + canvasCol.classList.add('col-12', 'col-md-9', 'col-lg-10'); + canvas.classList.remove('view-mode'); + canvas.classList.add('edit-mode'); + } else { + icon.textContent = 'visibility'; + icon.classList.remove('text-success'); + icon.classList.add('text-secondary'); + btn.title = 'View Mode'; + palette.style.display = 'none'; + canvasCol.classList.remove('col-md-9', 'col-lg-10'); + canvasCol.classList.add('col-12'); + canvas.classList.remove('edit-mode'); + canvas.classList.add('view-mode'); + } + + // Update all widgets + dashboardWidgets.forEach(w => renderDashboardWidget(w)); +} + +function initWidgetVarSelect2(options = {}) { + const defaults = { + placeholder: "Select a variable", + allowClear: true, + dropdownParent: $('#widgetConfigModal'), + ajax: { + url: '/variables', + dataType: 'json', + delay: 250, + data: function(params) { + return { q: params.term, sfr: $('#widgetSfrToggle').is(':checked') }; + }, + processResults: function (data) { + return { results: data.items }; + }, + cache: false + }, + minimumInputLength: 3 + }; + return $.extend(true, {}, defaults, options); +} + +function reinitWidgetVarSelect2() { + if ($('#widgetVarName').data('select2')) { + $('#widgetVarName').val(null).trigger('change'); + $('#widgetVarName').select2('destroy'); + } + $('#widgetVarName').select2(initWidgetVarSelect2()); +} + +function showWidgetConfig(type, editWidget = null) { + currentWidgetType = type; + const extraConfig = document.getElementById('widgetExtraConfig'); + const varNameContainer = document.getElementById('widgetVarNameContainer'); + const modalTitle = document.querySelector('#widgetConfigModal .modal-title'); + + // Get widget type definition from modular system + const widgetDef = window.dashboardWidgetTypes[type]; + if (!widgetDef) { + console.error(`Unknown widget type: ${type}`); + return; + } + + extraConfig.innerHTML = ''; + modalTitle.textContent = editWidget ? 'Edit Widget Configuration' : 'Configure Widget'; + + // Destroy previous Select2 instances + if ($('#widgetVarName').data('select2')) { + $('#widgetVarName').select2('destroy'); + } + + // Show/hide the single variable selector based on widget type + if (widgetDef.requiresMultipleVariables || !widgetDef.requiresVariable) { + varNameContainer.style.display = 'none'; + } else { + varNameContainer.style.display = ''; + // Reset and populate for edit mode + $('#widgetVarName').empty(); + if (editWidget) { + $('#widgetVarName').append(new Option(editWidget.variable, editWidget.variable, true, true)); + } + } + + // Get widget-specific configuration HTML + if (widgetDef.getConfig) { + extraConfig.innerHTML = widgetDef.getConfig(editWidget); + } + + const modal = new bootstrap.Modal(document.getElementById('widgetConfigModal')); + modal.show(); + + // Initialize Select2 after modal is shown so dropdown renders correctly + $('#widgetConfigModal').one('shown.bs.modal', function() { + // Reset SFR toggle for each new config session + $('#widgetSfrToggle').prop('checked', false); + + if (widgetDef.requiresVariable && !widgetDef.requiresMultipleVariables) { + $('#widgetVarName').select2(initWidgetVarSelect2()); + if (editWidget) { + $('#widgetVarName').prop('disabled', true); + } + // Reinitialize select2 when SFR toggle changes + $('#widgetSfrToggle').off('change.widgetVarSelect2').on('change.widgetVarSelect2', function() { + if (!$('#widgetVarName').prop('disabled')) { + reinitWidgetVarSelect2(); + } + }); + } + if (widgetDef.initSelect2) { + widgetDef.initSelect2(editWidget); + } + }); + + // Store reference to widget being edited + window.editingWidget = editWidget; +} + +function addDashboardWidget() { + const editWidget = window.editingWidget; + const widgetDef = window.dashboardWidgetTypes[currentWidgetType]; + + if (!widgetDef) { + console.error(`Unknown widget type: ${currentWidgetType}`); + return; + } + + let widget; + if (editWidget) { + // Editing existing widget + widget = editWidget; + } else { + // Creating new widget + const varName = widgetDef.requiresMultipleVariables ? '' : ($('#widgetVarName').val() || ''); + if (!varName && widgetDef.requiresVariable) { + alert('Please select a variable name'); + return; + } + + widget = { + id: widgetIdCounter++, + type: currentWidgetType, + variable: varName, + icon: widgetDef.icon, + x: 50, + y: 50, + value: currentWidgetType === 'text' ? '' : 0 + }; + } + + // Call widget-specific save config + if (widgetDef.saveConfig) { + const result = widgetDef.saveConfig(widget); + if (result === false) return; // Config validation failed + } + + if (!editWidget) { + dashboardWidgets.push(widget); + registerWidgetVariables(widget); + } + + renderDashboardWidget(widget); + + // Close modal and clean up Select2 + const modal = bootstrap.Modal.getInstance(document.getElementById('widgetConfigModal')); + modal.hide(); + if ($('#widgetVarName').data('select2')) { + $('#widgetVarName').val(null).trigger('change'); + } + if ($('#widgetVariables').data('select2')) { + $('#widgetVariables').val(null).trigger('change'); + } + window.editingWidget = null; +} + +// Helper function to parse values that might be numbers, booleans, or strings +function parseValue(val) { + if (val === 'true') return true; + if (val === 'false') return false; + const num = parseFloat(val); + if (!isNaN(num)) return num; + return val; +} + +// Select a widget in edit mode +function selectDashboardWidget(id) { + if (!isDashboardEditMode) return; + + const prevSelectedId = selectedWidget; + + // Update selection state first (before re-rendering) + if (selectedWidget === id) { + selectedWidget = null; // Toggle off if clicking same widget + } else { + selectedWidget = id; + } + + // Re-render previous widget (now deselected) + if (prevSelectedId !== null && prevSelectedId !== id) { + const prevWidget = dashboardWidgets.find(w => w.id === prevSelectedId); + if (prevWidget) renderDashboardWidget(prevWidget); + } + + // Re-render clicked widget (selected or deselected) + const widget = dashboardWidgets.find(w => w.id === id); + if (widget) renderDashboardWidget(widget); +} + +// Deselect widget when clicking on canvas background +function initCanvasClickHandler() { + const canvas = document.getElementById('dashboardCanvas'); + if (canvas) { + canvas.addEventListener('click', (e) => { + if (e.target === canvas && selectedWidget !== null) { + const prevSelectedId = selectedWidget; + selectedWidget = null; // Update state first + const prevWidget = dashboardWidgets.find(w => w.id === prevSelectedId); + if (prevWidget) renderDashboardWidget(prevWidget); + } + }); + } +} + +// This is the major override for rendering widgets and using Chart.js for gauge/plot +function renderDashboardWidget(widget) { + let widgetEl = document.getElementById(`dashboard-widget-${widget.id}`); + const isSelected = selectedWidget === widget.id; + + if (!widgetEl) { + widgetEl = document.createElement('div'); + widgetEl.id = `dashboard-widget-${widget.id}`; + widgetEl.className = `dashboard-widget widget-type-${widget.type}`; + widgetEl.style.left = widget.x + 'px'; + widgetEl.style.top = widget.y + 'px'; + + // Set saved dimensions if available + if (widget.width) widgetEl.style.width = widget.width + 'px'; + if (widget.height) widgetEl.style.height = widget.height + 'px'; + + widgetEl.addEventListener('mousedown', startDashboardDrag); + widgetEl.addEventListener('click', (e) => { + e.stopPropagation(); + selectDashboardWidget(widget.id); + }); + + // Save dimensions on resize + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + const id = parseInt(entry.target.id.replace('dashboard-widget-', '')); + const w = dashboardWidgets.find(w => w.id === id); + if (w) { + w.width = entry.contentRect.width + 24; + w.height = entry.contentRect.height + 24; + } + } + }); + resizeObserver.observe(widgetEl); + + document.getElementById('dashboardCanvas').appendChild(widgetEl); + } + + // Update widget classes based on mode and selection + if (isDashboardEditMode) { + widgetEl.classList.add('edit-mode'); + widgetEl.classList.remove('view-mode'); + if (isSelected) { + widgetEl.classList.add('selected'); + } else { + widgetEl.classList.remove('selected'); + } + } else { + widgetEl.classList.remove('edit-mode', 'selected'); + widgetEl.classList.add('view-mode'); + } + + // Get widget definition from modular system + const widgetDef = window.dashboardWidgetTypes[widget.type]; + if (!widgetDef) { + console.error(`Unknown widget type: ${widget.type}`); + return; + } + + let content = ''; + const typeIcons = ``; + const displayName = widget.type === 'plot_logger' || widget.type === 'plot_scope' + ? widget.variables?.join(', ') || widget.variable + : widget.variable; + + // Show header only in edit mode when widget is selected + if (isDashboardEditMode && isSelected) { + const header = ` + + '; + } else { + // View mode or unselected edit mode: show only widget content + content = ``; + } + + widgetEl.innerHTML = content; + + // Call afterRender if widget has it + if (widgetDef.afterRender) { + widgetDef.afterRender(widget); + } +} + +// Chart.js plot rendering (line) +function renderDashboardPlot(widget, plotCanvas) { + if (dashboardCharts[widget.id]) { + dashboardCharts[widget.id].destroy(); + } + // Default colors if no custom settings + const defaultColors = ['#0d6efd', '#dc3545', '#198754', '#ffc107', '#0dcaf0', '#6f42c1', '#fd7e14', '#20c997']; + let datasets = []; + let maxLen = 0; + + if (widget.variables && widget.variables.length > 0) { + widget.variables.forEach((varName, idx) => { + const rawData = widget.data[varName] || []; + if (rawData.length > maxLen) maxLen = rawData.length; + + // Get per-variable settings (color, gain, offset) + const settings = widget.varSettings?.[varName] || {}; + const color = settings.color || defaultColors[idx % defaultColors.length]; + const gain = settings.gain !== undefined ? settings.gain : 1; + const offset = settings.offset !== undefined ? settings.offset : 0; + + // Apply gain and offset to data + const processedData = rawData.map(v => (v * gain) + offset); + + datasets.push({ + label: varName, + data: processedData, + borderColor: color, + backgroundColor: color, + tension: 0.1, + pointRadius: 0, + fill: false, + }); + }); + } + + // Generate X-axis labels + let labels = []; + let xLabel = widget.xLabel || 'Sample'; + + if (widget.type === 'plot_scope' && typeof generateTimeLabels === 'function') { + const timeData = generateTimeLabels(maxLen); + labels = timeData.labels; + xLabel = `Time (${timeData.unit})`; + } else { + labels = Array.from({length: maxLen}, (_, i) => i + 1); + } + + const yLabel = widget.yLabel || 'Value'; + const showLegend = widget.showLegend !== false; // default true + + dashboardCharts[widget.id] = new Chart(plotCanvas, { + type: 'line', + data: { + labels: labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: {display: showLegend, position: 'top'}, + zoom: (window.Chart && Chart.HasOwnProperty && Chart.hasOwnProperty('zoom')) ? { + pan: {enabled: true, modifierKey: 'ctrl'}, + zoom: {wheel: {enabled: true}, pinch: {enabled: true}, mode: 'xy'} + } : undefined + }, + animation: {duration: 0}, + scales: { + x: { + title: {display: true, text: xLabel}, + ticks: {autoSkip: true, maxTicksLimit: 20} + }, + y: { + title: {display: true, text: yLabel} + } + } + } + }); +} + +function updateDashboardVariable(varName, value) { + // Update local widget state + const widgets = dashboardWidgets.filter(w => { + if (w.type === 'plot_logger' || w.type === 'plot_scope') { + return w.variables && w.variables.includes(varName); + } + return w.variable === varName; + }); + + // Find the first matching widget with gain/offset to reverse for device write + const refWidget = widgets.find(w => w.variable === varName && w.type !== 'plot_logger' && w.type !== 'plot_scope'); + const rawValue = refWidget ? reverseGainOffset(value, refWidget) : value; + + widgets.forEach(widget => { + if (widget.type === 'plot_logger') { + if (!widget.data) widget.data = {}; + if (!widget.data[varName]) widget.data[varName] = []; + widget.data[varName].push(value); + if (widget.data[varName].length > widget.maxPoints) { + widget.data[varName].shift(); + } + } else if (widget.type === 'plot_scope') { + if (!widget.data) widget.data = {}; + widget.data[varName] = Array.isArray(value) ? value : [value]; + } else { + widget.value = value; + } + refreshWidgetInPlace(widget); + }); + + // Send raw (reversed) value to server via Socket.IO or HTTP + if (dashboardSocket && dashboardSocket.connected) { + dashboardSocket.emit('widget_interaction', { + variable: varName, + value: rawValue + }); + } else { + fetch('/dashboard-view/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ variable: varName, value: rawValue }) + }); + } +} + +// Apply gain/offset: displayed = raw * gain + offset +function applyGainOffset(rawValue, widget) { + const gain = widget.gain !== undefined ? widget.gain : 1; + const offset = widget.offset !== undefined ? widget.offset : 0; + return rawValue * gain + offset; +} + +// Reverse gain/offset for writing: raw = (displayed - offset) / gain +function reverseGainOffset(displayedValue, widget) { + const gain = widget.gain !== undefined ? widget.gain : 1; + const offset = widget.offset !== undefined ? widget.offset : 0; + return gain !== 0 ? (displayedValue - offset) / gain : displayedValue; +} + +// Separate routing — watch data only updates non-scope widgets +function updateDashboardWatchWidgets(varName, value) { + dashboardWidgets.forEach(widget => { + if (widget.type === 'plot_scope') return; // scope data handled separately + + // Check if widget should be updated based on its update rate setting + if (window.shouldUpdateWidget && !window.shouldUpdateWidget(widget)) return; + + if (widget.type === 'plot_logger') { + if (!widget.variables || !widget.variables.includes(varName)) return; + if (!widget.data) widget.data = {}; + if (!widget.data[varName]) widget.data[varName] = []; + widget.data[varName].push(applyGainOffset(value, widget)); + if (widget.data[varName].length > widget.maxPoints) { + widget.data[varName].shift(); + } + refreshWidgetInPlace(widget); + } else if (widget.variable === varName && widget.type !== 'label') { + widget.value = applyGainOffset(value, widget); + refreshWidgetInPlace(widget); + } + }); +} + +// Scope data only updates plot_scope widgets +function updateDashboardScopeWidgets(varName, value) { + dashboardWidgets.forEach(widget => { + if (widget.type !== 'plot_scope') return; + if (!widget.variables || !widget.variables.includes(varName)) return; + if (!widget.data) widget.data = {}; + widget.data[varName] = Array.isArray(value) ? value : [value]; + refreshWidgetInPlace(widget); + }); +} + +// In-place update without full re-render to avoid chart blinking +function refreshWidgetInPlace(widget) { + const widgetEl = document.getElementById(`dashboard-widget-${widget.id}`); + if (!widgetEl) { + renderDashboardWidget(widget); + return; + } + + const widgetDef = window.dashboardWidgetTypes[widget.type]; + if (widgetDef && widgetDef.refresh) { + widgetDef.refresh(widget, widgetEl); + } +} + +function deleteDashboardWidget(id) { + if (confirm('Delete this widget?')) { + const index = dashboardWidgets.findIndex(w => w.id === id); + if (index > -1) { + // Deselect if this widget is selected + if (selectedWidget === id) { + selectedWidget = null; + } + unregisterWidgetVariables(dashboardWidgets[index]); + dashboardWidgets.splice(index, 1); + const el = document.getElementById(`dashboard-widget-${id}`); + if (el) el.remove(); + // Also clean up any Chart.js instance for this widget + if (dashboardCharts[id]) { + dashboardCharts[id].destroy(); + delete dashboardCharts[id]; + } + } + } +} + +function startDashboardDrag(e) { + if (!isDashboardEditMode) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const resizeZone = 20; // px from bottom-right corner + + // Don't start drag if clicking near the resize handle (bottom-right corner) + if (e.clientX > rect.right - resizeZone && e.clientY > rect.bottom - resizeZone) { + return; + } + + draggedWidget = e.currentTarget; + + dragOffsetX = e.clientX - rect.left; + dragOffsetY = e.clientY - rect.top; + + document.addEventListener('mousemove', dashboardDrag); + document.addEventListener('mouseup', stopDashboardDrag); +} + +function dashboardDrag(e) { + if (!draggedWidget) return; + + const canvas = document.getElementById('dashboardCanvas').getBoundingClientRect(); + let x = e.clientX - canvas.left - dragOffsetX; + let y = e.clientY - canvas.top - dragOffsetY; + + draggedWidget.style.left = x + 'px'; + draggedWidget.style.top = y + 'px'; +} + +function stopDashboardDrag() { + if (draggedWidget) { + const id = parseInt(draggedWidget.id.replace('dashboard-widget-', '')); + const widget = dashboardWidgets.find(w => w.id === id); + if (widget) { + widget.x = parseInt(draggedWidget.style.left); + widget.y = parseInt(draggedWidget.style.top); + } + } + + draggedWidget = null; + document.removeEventListener('mousemove', dashboardDrag); + document.removeEventListener('mouseup', stopDashboardDrag); +} + +function saveDashboardLayout() { + fetch('/dashboard-view/save-layout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(dashboardWidgets) + }) + .then(r => r.json()) + .then(data => { + alert(data.message || 'Layout saved successfully'); + }) + .catch(err => { + console.error('Error saving layout:', err); + alert('Error saving layout'); + }); +} + +function loadDashboardLayout() { + fetch('/dashboard-view/load-layout') + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + removeAllDashboardVariables(); + dashboardWidgets = data.layout; + widgetIdCounter = Math.max(...dashboardWidgets.map(w => w.id), 0) + 1; + document.getElementById('dashboardCanvas').innerHTML = ''; + dashboardWidgets.forEach(w => renderDashboardWidget(w)); + registerAllDashboardVariables(); + syncScopeControlToBackend(); + alert('Layout loaded successfully'); + } else { + alert(data.message || 'No saved layout found'); + } + }) + .catch(err => { + console.error('Error loading layout:', err); + alert('No saved layout found'); + }); +} + +function exportDashboardLayout() { + const dataStr = JSON.stringify(dashboardWidgets, null, 2); + const dataBlob = new Blob([dataStr], {type: 'application/json'}); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = 'dashboard_layout.json'; + link.click(); + URL.revokeObjectURL(url); +} + +function clearDashboard() { + if (dashboardWidgets.length === 0) { + alert('Dashboard is already empty'); + return; + } + if (confirm('Are you sure you want to remove all widgets from the dashboard?')) { + // Unregister all variables + removeAllDashboardVariables(); + // Destroy all chart instances + for (const id in dashboardCharts) { + dashboardCharts[id].destroy(); + delete dashboardCharts[id]; + } + // Clear widgets array + dashboardWidgets = []; + selectedWidget = null; + // Clear the canvas + document.getElementById('dashboardCanvas').innerHTML = ''; + } +} + +function importDashboardLayout() { + document.getElementById('dashboardFileInput').click(); +} + +function handleDashboardFileImport(e) { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + try { + removeAllDashboardVariables(); + dashboardWidgets = JSON.parse(event.target.result); + widgetIdCounter = Math.max(...dashboardWidgets.map(w => w.id), 0) + 1; + document.getElementById('dashboardCanvas').innerHTML = ''; + dashboardWidgets.forEach(w => renderDashboardWidget(w)); + registerAllDashboardVariables(); + syncScopeControlToBackend(); + alert('Layout imported successfully'); + } catch (err) { + console.error('Import error:', err); + alert('Error importing layout: Invalid JSON file'); + } + }; + reader.readAsText(file); + } +} \ No newline at end of file diff --git a/pyx2cscope/gui/web/static/js/scope_view.js b/pyx2cscope/gui/web/static/js/scope_view.js index b9f1f440..06e452bd 100644 --- a/pyx2cscope/gui/web/static/js/scope_view.js +++ b/pyx2cscope/gui/web/static/js/scope_view.js @@ -106,19 +106,31 @@ function initScopeSelect(){ url: '/variables', dataType: 'json', delay: 250, + data: function(params) { + return { q: params.term, sfr: $('#scopeSfrToggle').is(':checked') }; + }, processResults: function (data) { return { results: data.items }; }, - cache: true + cache: false }, minimumInputLength: 3 }); - $('#scopeSearch').on('select2:select', function(e){ + $('#scopeSearch').off('select2:select').on('select2:select', function(e){ parameter = $('#scopeSearch').select2('data')[0]['text']; - socket_sv.emit("add_scope_var", {var: parameter}); + var sfr = $('#scopeSfrToggle').is(':checked'); + socket_sv.emit("add_scope_var", {var: parameter, sfr: sfr}); + }); + + $('#scopeSfrToggle').off('change.scopeSearch').on('change.scopeSearch', function() { + $('#scopeSearch').val(null).trigger('change'); + if ($('#scopeSearch').data('select2')) { + $('#scopeSearch').select2('destroy'); + } + initScopeSelect(); }); } @@ -379,7 +391,7 @@ function initScopeChart() { scopeChart.resetZoom(); }); - $('#chartExport').attr("href", "/scope-view/export") + $('#chartExport').attr("href", "/scope/export") } function initScopeForms(){ @@ -395,11 +407,16 @@ function initScopeForms(){ $(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'); @@ -411,7 +428,7 @@ function initScopeForms(){ }); // Add change event handlers for the radio buttons to update their visual state - $('input[name="triggerEnable"]').on('change', function() { + $('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 @@ -420,7 +437,7 @@ function initScopeForms(){ // Set up Save button click handler $("#scopeSave").on("click", function() { - window.location.href = '/scope-view/save'; + window.location.href = '/scope/save'; }); $("#scopeLoad").on("change", function(event) { @@ -429,7 +446,7 @@ function initScopeForms(){ formData.append('file', file); $.ajax({ - url: '/scope-view/load', // Replace with your server upload endpoint + url: '/scope/load', // Replace with your server upload endpoint type: 'POST', data: formData, contentType: false, @@ -453,7 +470,7 @@ $(document).ready(function () { initScopeChart(); scopeTable = $('#scopeTable').DataTable({ - ajax: '/scope-view/data', + ajax: '/scope/data', searching: false, paging: false, info: false, diff --git a/pyx2cscope/gui/web/static/js/script.js b/pyx2cscope/gui/web/static/js/script.js index ca6e9dba..6d1fbdb7 100644 --- a/pyx2cscope/gui/web/static/js/script.js +++ b/pyx2cscope/gui/web/static/js/script.js @@ -1,7 +1,26 @@ function connect(){ let formData = new FormData(); - formData.append('uart', $('#uart').val()); + const interfaceType = $('#interfaceType').val(); + formData.append('interfaceType', interfaceType); + + if (interfaceType === 'SERIAL') { + formData.append('interfaceArgument', 'port'); + formData.append('interfaceValue', $('#port').val()); + } + else if (interfaceType === 'TCP_IP') { + formData.append('host', $('#host').val()); + formData.append('tcpPort', $('#tcpPort').val()); + } + else if (interfaceType === 'CAN') { + formData.append('canBusType', $('#canBusType').val()); + formData.append('canChannel', $('#canChannel').val()); + formData.append('canBaudrate', $('#canBaudrate').val()); + formData.append('canMode', $('#canMode').val()); + formData.append('canTxId', $('#canTxId').val()); + formData.append('canRxId', $('#canRxId').val()); + } + formData.append('elfFile', $('#elfFile')[0].files[0]); $.ajax({ @@ -33,7 +52,7 @@ function disconnect(){ function load_uart() { $.getJSON('/serial-ports', function(data) { - uart = $('#uart'); + uart = $('#port'); uart.empty(); uart.append(''); data.forEach(function(item) { @@ -42,6 +61,25 @@ function load_uart() { }); } +function setInterfaceSetupFields() { + const interfaceType = $('#interfaceType').val(); + + $('#uartRow').addClass('d-none'); + $('#hostRow').addClass('d-none'); + $('#canRow').addClass('d-none'); + + if (interfaceType === 'SERIAL') { + $('#uartRow').removeClass('d-none'); + load_uart(); + } + else if (interfaceType === 'TCP_IP') { + $('#hostRow').removeClass('d-none'); + } + else if (interfaceType === 'CAN') { + $('#canRow').removeClass('d-none'); + } +} + function setConnectState(status) { if(status) { parameterCardEnabled = true; @@ -49,8 +87,10 @@ function setConnectState(status) { $('#setupView').addClass('collapse'); $('#watchView').removeClass('disabled'); $('#scopeView').removeClass('disabled'); + $('#dashboardView').removeClass('disabled'); $("#btnWatchView").prop("disabled", false); $("#btnScopeView").prop("disabled", false); + $("#btnDashboardView").prop("disabled", false); $("#btnConnect").prop("disabled", true); $("#btnConnect").html("Disconnect", true); $('#connection-status').html('Connected'); @@ -64,6 +104,7 @@ function setConnectState(status) { $('#setupView').removeClass('collapse'); $('#watchView').addClass('disabled'); $('#scopeView').addClass('disabled'); + $('#dashboardView').addClass('disabled'); $("#btnConnect").prop("disabled", false); $('#connection-status').html('Disconnected'); $('#btnConnect').html('Connect'); @@ -77,10 +118,12 @@ function setConnectState(status) { function initSetupCard(){ $('#update_com_port').on('click', load_uart); + $('#interfaceType').on('change', setInterfaceSetupFields); $('#btnConnect').on('click', function() { if($('#btnConnect').html() === "Connect") connect(); else disconnect(); }); + $('#connection-status').on('click', function() { if($('#connection-status').html() === "Disconnected") connect(); else disconnect(); @@ -124,45 +167,94 @@ function initQRCodes() { insertQRCode("scope-view"); $('#x2cModal').modal('show'); }); + $("#dashboardQRCode").on("click", function() { + $('#x2cModalTitle').html('Dashboard - Scan QR Code'); + insertQRCode("dashboard-view"); + $('#x2cModal').modal('show'); + }); + $("#scriptQRCode").on("click", function() { + $('#x2cModalTitle').html('Script - Scan QR Code'); + insertQRCode("script-view"); + $('#x2cModal').modal('show'); + }); } $(document).ready(function() { initSetupCard(); - load_uart(); + setInterfaceSetupFields(); initQRCodes(); // Toggles for views const toggleWatch = document.getElementById('toggleWatch'); const toggleScope = document.getElementById('toggleScope'); + const toggleDashboard = document.getElementById('toggleDashboard'); + const toggleScript = document.getElementById('toggleScript'); const watchCol = document.getElementById('watchCol'); const scopeCol = document.getElementById('scopeCol'); + const dashboardCol = document.getElementById('dashboardCol'); + const scriptCol = document.getElementById('scriptCol'); // Mobile view (tabs) // Mobile tab click handlers document.getElementById('tabWatch').addEventListener('click', function() { watchCol.classList.remove('d-none'); scopeCol.classList.add('d-none'); + dashboardCol.classList.add('d-none'); + scriptCol.classList.add('d-none'); this.classList.add('active'); document.getElementById('tabScope').classList.remove('active'); + document.getElementById('tabDashboard').classList.remove('active'); + document.getElementById('tabScript').classList.remove('active'); }); document.getElementById('tabScope').addEventListener('click', function() { watchCol.classList.add('d-none'); scopeCol.classList.remove('d-none'); + dashboardCol.classList.add('d-none'); + scriptCol.classList.add('d-none'); this.classList.add('active'); document.getElementById('tabWatch').classList.remove('active'); + document.getElementById('tabDashboard').classList.remove('active'); + document.getElementById('tabScript').classList.remove('active'); }); - // Desktop view (toggles) - // Uncheck toggles and hide views by default on desktop + document.getElementById('tabDashboard').addEventListener('click', function() { + scopeCol.classList.add('d-none'); + watchCol.classList.add('d-none'); + dashboardCol.classList.remove('d-none'); + scriptCol.classList.add('d-none'); + this.classList.add('active'); + document.getElementById('tabWatch').classList.remove('active'); + document.getElementById('tabScope').classList.remove('active'); + document.getElementById('tabScript').classList.remove('active'); + }); + + document.getElementById('tabScript').addEventListener('click', function() { + scopeCol.classList.add('d-none'); + watchCol.classList.add('d-none'); + dashboardCol.classList.add('d-none'); + scriptCol.classList.remove('d-none'); + this.classList.add('active'); + document.getElementById('tabWatch').classList.remove('active'); + document.getElementById('tabScope').classList.remove('active'); + document.getElementById('tabDashboard').classList.remove('active'); + }); + + // Hide all view cards by default - only setup card is visible toggleWatch.checked = false; toggleScope.checked = false; + toggleDashboard.checked = false; + toggleScript.checked = false; watchCol.classList.add('d-none'); scopeCol.classList.add('d-none'); + dashboardCol.classList.add('d-none'); + scriptCol.classList.add('d-none'); // Update toggle button states document.querySelector('label[for="toggleWatch"]').classList.remove('active'); document.querySelector('label[for="toggleScope"]').classList.remove('active'); + document.querySelector('label[for="toggleDashboard"]').classList.remove('active'); + document.querySelector('label[for="toggleScript"]').classList.remove('active'); // Toggle event listeners for desktop toggleWatch.addEventListener('change', () => { @@ -175,6 +267,16 @@ $(document).ready(function() { document.querySelector('label[for="toggleScope"]').classList.toggle('active', toggleScope.checked); }); + toggleDashboard.addEventListener('change', () => { + dashboardCol.classList.toggle('d-none', !toggleDashboard.checked); + document.querySelector('label[for="toggleDashboard"]').classList.toggle('active', toggleDashboard.checked); + }); + + toggleScript.addEventListener('change', () => { + scriptCol.classList.toggle('d-none', !toggleScript.checked); + document.querySelector('label[for="toggleScript"]').classList.toggle('active', toggleScript.checked); + }); + $.getJSON('/is-connected', function(data) { setConnectState(data.status); }); diff --git a/pyx2cscope/gui/web/static/js/scripting_view.js b/pyx2cscope/gui/web/static/js/scripting_view.js new file mode 100644 index 00000000..085b4eeb --- /dev/null +++ b/pyx2cscope/gui/web/static/js/scripting_view.js @@ -0,0 +1,258 @@ +// Scripting View JavaScript +// Handles script execution with real-time output streaming + +let scriptSocket = null; +let scriptFile = null; +let isScriptRunning = false; +let scriptLogContent = ''; // Buffer for log download + +function initScriptingView() { + // Initialize Socket.IO connection for scripting + if (typeof io !== 'undefined') { + scriptSocket = io('/scripting'); + + scriptSocket.on('connect', () => { + console.log('Scripting socket connected'); + logScriptMessage('Connected to scripting server'); + }); + + scriptSocket.on('script_output', (data) => { + appendScriptOutput(data.output); + }); + + scriptSocket.on('script_finished', (data) => { + onScriptFinished(data.exit_code); + }); + + scriptSocket.on('script_error', (data) => { + appendScriptOutput('\nError: ' + data.error + '\n'); + onScriptFinished(1); + }); + } + + // File input handler + $('#scriptFileInput').on('change', function(e) { + const file = e.target.files[0]; + if (file) { + scriptFile = file; + $('#scriptPath').val(file.name); + $('#btnExecuteScript').prop('disabled', false); + logScriptMessage('Selected script: ' + file.name); + } + }); + + // Browse button + $('#btnBrowseScript').on('click', function() { + $('#scriptFileInput').click(); + }); + + // Execute button + $('#btnExecuteScript').on('click', executeScript); + + // Stop button + $('#btnStopScript').on('click', stopScript); + + // Help button + $('#btnScriptHelp').on('click', showScriptHelp); + + // Download log button + $('#btnDownloadLog').on('click', downloadLog); + + // Clear buttons + $('#btnClearScriptOutput').on('click', function() { + $('#scriptOutputText').text(''); + scriptLogContent = ''; + updateDownloadButtonState(); + }); + + $('#btnClearScriptLog').on('click', function() { + $('#scriptLogText').text(''); + }); +} + +function executeScript() { + if (!scriptFile) { + alert('Please select a script file first'); + return; + } + + if (isScriptRunning) { + logScriptMessage('A script is already running'); + return; + } + + // Clear script output and log buffer + $('#scriptOutputText').text(''); + scriptLogContent = ''; + + // Update UI state + isScriptRunning = true; + $('#btnExecuteScript').prop('disabled', true); + $('#btnStopScript').prop('disabled', false); + $('#btnDownloadLog').prop('disabled', true); + setScriptStatus('Running...', 'primary'); + + // Add header to log + const timestamp = new Date().toISOString(); + scriptLogContent += '=' .repeat(60) + '\n'; + scriptLogContent += 'Script execution started: ' + timestamp + '\n'; + scriptLogContent += 'Script: ' + scriptFile.name + '\n'; + scriptLogContent += '='.repeat(60) + '\n\n'; + + logScriptMessage('Script started: ' + scriptFile.name); + + // Read and send script content + const reader = new FileReader(); + reader.onload = function(e) { + const scriptContent = e.target.result; + + if (scriptSocket && scriptSocket.connected) { + scriptSocket.emit('execute_script', { + filename: scriptFile.name, + content: scriptContent + }); + } else { + appendScriptOutput('Error: Not connected to server\n'); + onScriptFinished(1); + } + }; + reader.readAsText(scriptFile); +} + +function stopScript() { + if (!isScriptRunning) return; + + logScriptMessage('Stop requested...'); + setScriptStatus('Stopping...', 'warning'); + + if (scriptSocket && scriptSocket.connected) { + scriptSocket.emit('stop_script'); + } +} + +function onScriptFinished(exitCode) { + isScriptRunning = false; + $('#btnExecuteScript').prop('disabled', false); + $('#btnStopScript').prop('disabled', true); + + // Add footer to log + const timestamp = new Date().toISOString(); + scriptLogContent += '\n' + '='.repeat(60) + '\n'; + scriptLogContent += 'Script finished: ' + timestamp + '\n'; + scriptLogContent += 'Exit code: ' + exitCode + '\n'; + scriptLogContent += '='.repeat(60) + '\n'; + + if (exitCode === 0) { + setScriptStatus('Completed', 'success'); + logScriptMessage('Script finished successfully'); + } else { + setScriptStatus('Finished (code ' + exitCode + ')', 'warning'); + logScriptMessage('Script finished with exit code ' + exitCode); + } + + updateDownloadButtonState(); +} + +function updateDownloadButtonState() { + $('#btnDownloadLog').prop('disabled', scriptLogContent.length === 0); +} + +function downloadLog() { + if (!scriptLogContent) { + alert('No log content to download'); + return; + } + + // Generate filename based on script name + let logFileName = 'script_log.txt'; + if (scriptFile) { + const baseName = scriptFile.name.replace('.py', ''); + logFileName = baseName + '_log.txt'; + } + + // Create blob and download + const blob = new Blob([scriptLogContent], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = logFileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + logScriptMessage('Log downloaded: ' + logFileName); +} + +function setScriptStatus(text, variant) { + const statusEl = $('#scriptStatus'); + statusEl.text(text); + statusEl.removeClass('bg-secondary bg-primary bg-success bg-warning bg-danger'); + statusEl.addClass('bg-' + variant); +} + +function appendScriptOutput(text) { + const outputEl = $('#scriptOutputText'); + outputEl.append(text); + // Auto-scroll to bottom + outputEl.scrollTop(outputEl[0].scrollHeight); + + // Also append to log buffer + scriptLogContent += text; +} + +function logScriptMessage(message) { + const timestamp = new Date().toLocaleTimeString(); + const formatted = '[' + timestamp + '] ' + message + '\n'; + const logEl = $('#scriptLogText'); + logEl.append(formatted); + // Auto-scroll to bottom + logEl.scrollTop(logEl[0].scrollHeight); +} + +function showScriptHelp() { + // Load help content + $.get('/scripting/help', function(data) { + $('#scriptHelpContent').html(data.html || formatHelpMarkdown(data.markdown)); + const modal = new bootstrap.Modal(document.getElementById('scriptHelpModal')); + modal.show(); + }).fail(function() { + $('#scriptHelpContent').html('Could not load help content.
'); + const modal = new bootstrap.Modal(document.getElementById('scriptHelpModal')); + modal.show(); + }); +} + +function formatHelpMarkdown(markdown) { + // Simple markdown to HTML conversion + if (!markdown) return 'No help content available.
'; + + let html = markdown + // Code blocks + .replace(/```python\n([\s\S]*?)```/g, '$1')
+ .replace(/```\n?([\s\S]*?)```/g, '$1')
+ // Inline code
+ .replace(/`([^`]+)`/g, '$1')
+ // Headers
+ .replace(/^### (.+)$/gm, '$1') + // Line breaks + .replace(/\n\n/g, '
')
+ .replace(/\n/g, '
');
+
+ return '
' + html + '
'; +} + +// Initialize when document is ready +$(document).ready(function() { + initScriptingView(); +}); diff --git a/pyx2cscope/gui/web/static/js/watch_view.js b/pyx2cscope/gui/web/static/js/watch_view.js index dad0d8ed..fb2ee5d3 100644 --- a/pyx2cscope/gui/web/static/js/watch_view.js +++ b/pyx2cscope/gui/web/static/js/watch_view.js @@ -82,7 +82,7 @@ function setParameterTableListeners(){ // Set up Save button click handler $("#parameterSave").on("click", function() { - window.location.href = '/watch-view/save'; + window.location.href = '/watch/save'; }); $('#parameterLoad').on('change', function(event) { var file = event.target.files[0]; @@ -90,7 +90,7 @@ function setParameterTableListeners(){ formData.append('file', file); $.ajax({ - url: '/watch-view/load', // Replace with your server upload endpoint + url: '/watch/load', // Replace with your server upload endpoint type: 'POST', data: formData, contentType: false, @@ -108,6 +108,7 @@ function setParameterTableListeners(){ } function initParameterSelect(){ + var sfr = $('#parameterSfrToggle').is(':checked'); $('#parameterSearch').select2({ placeholder: "Select a variable", dropdownAutoWidth : true, @@ -116,19 +117,31 @@ function initParameterSelect(){ url: '/variables', dataType: 'json', delay: 250, + data: function(params) { + return { q: params.term, sfr: $('#parameterSfrToggle').is(':checked') }; + }, processResults: function (data) { return { results: data.items }; }, - cache: true + cache: false }, minimumInputLength: 3 }); - $('#parameterSearch').on('select2:select', function(e){ + $('#parameterSearch').off('select2:select').on('select2:select', function(e){ parameter = $('#parameterSearch').select2('data')[0]['text']; - socket_wv.emit("add_watch_var", {var: parameter}); + sfr = $('#parameterSfrToggle').is(':checked'); + socket_wv.emit("add_watch_var", {var: parameter, sfr: sfr}); + }); + + $('#parameterSfrToggle').off('change.parameterSearch').on('change.parameterSearch', function() { + $('#parameterSearch').val(null).trigger('change'); + if ($('#parameterSearch').data('select2')) { + $('#parameterSearch').select2('destroy'); + } + initParameterSelect(); }); } @@ -169,7 +182,7 @@ $(document).ready(function () { setParameterRefreshInterval(); parameterTable = $('#parameterTable').DataTable({ - ajax: '/watch-view/data', + ajax: '/watch/data', searching: false, paging: false, info: false, diff --git a/pyx2cscope/gui/web/static/widgets/button/widget.js b/pyx2cscope/gui/web/static/widgets/button/widget.js new file mode 100644 index 00000000..a716a436 --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/button/widget.js @@ -0,0 +1,139 @@ +/** + * Button Widget - Push button with toggle mode support + * + * Features: + * - Momentary or Toggle mode + * - Configurable press/release values + * - Color change on press (toggle mode) + * - Supports touch and mouse events + */ + +function handleDashboardButtonPress(id) { + const widget = dashboardWidgets.find(w => w.id === id); + if (!widget) return; + + if (widget.toggleMode) { + // Toggle mode: switch state — needs full re-render for button color change + widget.buttonState = !widget.buttonState; + const value = widget.buttonState ? widget.pressValue : widget.releaseValue || 0; + updateDashboardVariable(widget.variable, value); + renderDashboardWidget(widget); // button color change requires re-render + } else { + // Momentary mode: send press value + updateDashboardVariable(widget.variable, widget.pressValue); + } +} + +function handleDashboardButtonRelease(id) { + const widget = dashboardWidgets.find(w => w.id === id); + // Don't handle release for toggle mode or if release is not enabled + if (!widget || widget.toggleMode || !widget.releaseWrite) return; + + // Momentary mode: send release value + updateDashboardVariable(widget.variable, widget.releaseValue); +} + +function createButtonWidget(widget) { + const btnColor = widget.toggleMode && widget.buttonState + ? widget.pressedColor + : widget.buttonColor; + + return ` + + `; +} + +function getButtonConfig(editWidget) { + return ` +