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 -![WatchPlot](https://raw.githubusercontent.com/X2Cscope/pyx2cscope/refs/heads/main/doc/images/gui_watch_plot.jpg) -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 -![ScopeView](https://raw.githubusercontent.com/X2Cscope/pyx2cscope/refs/heads/main/doc/images/gui_scope_view.jpg) - -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 -![WatchView](https://raw.githubusercontent.com/X2Cscope/pyx2cscope/refs/heads/main/doc/images/gui_watch_view.jpg) - -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( + "

Select a View

" + "

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 = `${def.icon} ${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 = `${widget.icon}`; + 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 = ` +
+ ${typeIcons} ${displayName} +
+ + +
+
+
+ `; + content = header + widgetDef.create(widget) + '
'; + } else { + // View mode or unselected edit mode: show only widget content + content = `
${widgetDef.create(widget)}
`; + } + + 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
') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Bold + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Horizontal rules + .replace(/^---$/gm, '
') + // Blockquotes + .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 ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ${window.getUpdateRateConfigHTML ? window.getUpdateRateConfigHTML(editWidget, 0) : ''} + `; +} + +function saveButtonConfig(widget) { + if (window.saveUpdateRateConfig) window.saveUpdateRateConfig(widget); + widget.displayName = document.getElementById('widgetDisplayName').value; + widget.buttonColor = document.getElementById('widgetButtonColor').value; + widget.pressValue = parseValue(document.getElementById('widgetPressValue').value); + widget.toggleMode = document.getElementById('widgetToggleMode').value === 'true'; + widget.releaseWrite = document.getElementById('widgetReleaseWrite').value === 'true'; + widget.releaseValue = parseValue(document.getElementById('widgetReleaseValue').value); + widget.buttonState = widget.buttonState || false; // Track toggle state + if (widget.toggleMode) { + widget.pressedColor = document.getElementById('widgetPressedColor').value; + } +} + +function refreshButtonWidget(widget, widgetEl) { + // Button changes require full re-render for color changes + // This is intentionally empty - button updates handled by full render +} + +// Register widget type +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +window.dashboardWidgetTypes.button = { + icon: 'radio_button_checked', + create: createButtonWidget, + getConfig: getButtonConfig, + saveConfig: saveButtonConfig, + refresh: refreshButtonWidget, + requiresVariable: true, + supportsGainOffset: false +}; diff --git a/pyx2cscope/gui/web/static/widgets/gauge/widget.js b/pyx2cscope/gui/web/static/widgets/gauge/widget.js new file mode 100644 index 00000000..9ee74cc9 --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/gauge/widget.js @@ -0,0 +1,219 @@ +/** + * Gauge Widget - Semi-circular gauge display using Chart.js + * + * Features: + * - Semi-circular doughnut chart visualization + * - Configurable color zones (low/mid/high) + * - Custom threshold values + * - Percent or number display mode + * - Supports gain/offset transformation + */ + +// Get gauge color based on value and configurable thresholds +function getGaugeColor(value, widget) { + const range = widget.max - widget.min; + const low = widget.lowThreshold !== undefined ? widget.lowThreshold : widget.min + range * 0.6; + const high = widget.highThreshold !== undefined ? widget.highThreshold : widget.min + range * 0.8; + if (value >= high) return widget.highColor; + if (value >= low) return widget.midColor; + return widget.lowColor; +} + +// Get gauge display text based on display mode +function getGaugeDisplayText(value, percent, widget) { + if (widget.displayMode === 'number') { + return Number.isInteger(value) ? value.toString() : value.toFixed(2); + } + return (percent * 100).toFixed(1) + '%'; +} + +// Chart.js gauge via doughnut chart with overlay labels +function renderDashboardGauge(widget, gaugeCanvas) { + if (dashboardCharts[widget.id]) { + dashboardCharts[widget.id].destroy(); + } + + let value = widget.value; + let percent = ((value - widget.min) / (widget.max - widget.min)); + percent = Math.max(0, Math.min(1, percent)); + + const color = getGaugeColor(value, widget); + + const config = { + type: 'doughnut', + data: { + datasets: [{ + data: [percent, 1 - percent], + backgroundColor: [color, '#e9ecef'], + borderWidth: 0 + }] + }, + options: { + aspectRatio: 2, + circumference: 180, + rotation: -90, + plugins: { + legend: { display: false }, + tooltip: { enabled: false } + } + } + }; + + dashboardCharts[widget.id] = new Chart(gaugeCanvas, config); + + // Position the overlay labels + setTimeout(() => { + const overlay = document.getElementById(`gauge-label-${widget.id}`); + const canvas = gaugeCanvas; + if (overlay && canvas) { + const rect = canvas.getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2 + rect.height / 3; + overlay.style.position = 'absolute'; + overlay.style.left = centerX + 'px'; + overlay.style.top = centerY + 'px'; + overlay.style.transform = 'translate(-50%, -50%)'; + overlay.style.textAlign = 'center'; + overlay.style.pointerEvents = 'none'; + } + }, 50); +} + +function createGaugeWidget(widget) { + return ` +
+ +
+
${widget.value}
+
${widget.displayName || widget.variable || 'Value'}
+
+
+ `; +} + +function getGaugeConfig(editWidget) { + return ` +
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ ${window.getUpdateRateConfigHTML ? window.getUpdateRateConfigHTML(editWidget) : ''} + `; +} + +function saveGaugeConfig(widget) { + if (window.saveUpdateRateConfig) window.saveUpdateRateConfig(widget); + widget.displayName = document.getElementById('widgetDisplayName').value; + widget.min = parseFloat(document.getElementById('widgetMinValue').value); + widget.max = parseFloat(document.getElementById('widgetMaxValue').value); + const lowVal = document.getElementById('widgetLowThreshold').value; + const highVal = document.getElementById('widgetHighThreshold').value; + widget.lowThreshold = lowVal !== '' ? parseFloat(lowVal) : undefined; + widget.lowColor = document.getElementById('widgetLowColor').value; + widget.midColor = document.getElementById('widgetMidColor').value; + widget.highColor = document.getElementById('widgetHighColor').value; + widget.highThreshold = highVal !== '' ? parseFloat(highVal) : undefined; + widget.displayMode = document.getElementById('widgetDisplayMode').value; + widget.gain = parseFloat(document.getElementById('widgetGain').value) || 1; + widget.offset = parseFloat(document.getElementById('widgetOffset').value) || 0; +} + +function afterRenderGaugeWidget(widget) { + setTimeout(() => { + const gaugeCanvas = document.getElementById(`dashboard-gauge-${widget.id}`); + if (gaugeCanvas && typeof Chart !== 'undefined') { + renderDashboardGauge(widget, gaugeCanvas); + } + }, 100); +} + +function refreshGaugeWidget(widget, widgetEl) { + const chart = dashboardCharts[widget.id]; + if (!chart) { + afterRenderGaugeWidget(widget); + return; + } + const percent = Math.max(0, Math.min(1, (widget.value - widget.min) / (widget.max - widget.min))); + const color = getGaugeColor(widget.value, widget); + const displayText = getGaugeDisplayText(widget.value, percent, widget); + chart.data.datasets[0].data = [percent, 1 - percent]; + chart.data.datasets[0].backgroundColor = [color, '#e9ecef']; + chart.update('none'); + + // Update overlay labels + const overlay = document.getElementById(`gauge-label-${widget.id}`); + if (overlay) { + const valueEl = overlay.querySelector('.gauge-value'); + const varEl = overlay.querySelector('.gauge-variable'); + if (valueEl) valueEl.textContent = displayText; + if (varEl) varEl.textContent = widget.displayName || widget.variable || 'Value'; + } +} + +// Register widget type +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +window.dashboardWidgetTypes.gauge = { + icon: 'speed', + create: createGaugeWidget, + getConfig: getGaugeConfig, + saveConfig: saveGaugeConfig, + afterRender: afterRenderGaugeWidget, + refresh: refreshGaugeWidget, + requiresVariable: true, + supportsGainOffset: true +}; diff --git a/pyx2cscope/gui/web/static/widgets/label/widget.js b/pyx2cscope/gui/web/static/widgets/label/widget.js new file mode 100644 index 00000000..72b0b06d --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/label/widget.js @@ -0,0 +1,70 @@ +/** + * Label Widget - Static text display + * + * Features: + * - Customizable text + * - Font size selection (small/medium/large/xlarge) + * - Text alignment (left/center/right) + */ + +function createLabelWidget(widget) { + const fontSizes = { small: '0.875rem', medium: '1rem', large: '1.5rem', xlarge: '2rem' }; + const fontSize = fontSizes[widget.fontSize] || fontSizes.medium; + return ` +
+ ${widget.labelText} +
+ `; +} + +function getLabelConfig(editWidget) { + return ` +
+ + +
+
+ + +
+
+ + +
+ `; +} + +function saveLabelConfig(widget) { + widget.labelText = document.getElementById('widgetLabelText').value; + widget.fontSize = document.getElementById('widgetFontSize').value; + widget.textAlign = document.getElementById('widgetTextAlign').value; + widget.variable = 'label_' + widget.id; // Generate unique variable name +} + +function refreshLabelWidget(widget, widgetEl) { + // Labels are static, no refresh needed +} + +// Register widget type +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +window.dashboardWidgetTypes.label = { + icon: 'label', + create: createLabelWidget, + getConfig: getLabelConfig, + saveConfig: saveLabelConfig, + refresh: refreshLabelWidget, + requiresVariable: false, + supportsGainOffset: false +}; diff --git a/pyx2cscope/gui/web/static/widgets/number/widget.js b/pyx2cscope/gui/web/static/widgets/number/widget.js new file mode 100644 index 00000000..3e2cfaea --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/number/widget.js @@ -0,0 +1,59 @@ +/** + * Number Widget - Numeric input field + * + * Features: + * - Direct number entry + * - Supports gain/offset transformation + */ + +function createNumberWidget(widget) { + return ` + + `; +} + +function getNumberConfig(editWidget) { + return ` +
+ + +
+
+ + +
+ ${window.getUpdateRateConfigHTML ? window.getUpdateRateConfigHTML(editWidget) : ''} + `; +} + +function saveNumberConfig(widget) { + if (window.saveUpdateRateConfig) window.saveUpdateRateConfig(widget); + widget.gain = parseFloat(document.getElementById('widgetGain').value) || 1; + widget.offset = parseFloat(document.getElementById('widgetOffset').value) || 0; +} + +function refreshNumberWidget(widget, widgetEl) { + const input = widgetEl.querySelector('input[type="number"]'); + if (input && document.activeElement !== input) input.value = widget.value; +} + +// Register widget type +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +window.dashboardWidgetTypes.number = { + icon: 'pin', + create: createNumberWidget, + getConfig: getNumberConfig, + saveConfig: saveNumberConfig, + refresh: refreshNumberWidget, + requiresVariable: true, + supportsGainOffset: true +}; diff --git a/pyx2cscope/gui/web/static/widgets/plot_logger/widget.js b/pyx2cscope/gui/web/static/widgets/plot_logger/widget.js new file mode 100644 index 00000000..62cb74a6 --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/plot_logger/widget.js @@ -0,0 +1,248 @@ +/** + * Plot Logger Widget - Scrolling time-series plot using Chart.js + * + * Features: + * - Multiple variables on one plot + * - Configurable max data points (scrolling window) + * - Auto-scaling + * - Per-variable color, gain, and offset + * - Show/hide legend option + */ + +const plotLoggerDefaultColors = ['#0d6efd', '#dc3545', '#198754', '#ffc107', '#0dcaf0', '#6f42c1', '#fd7e14', '#20c997']; + +function createPlotLoggerWidget(widget) { + return ` + + `; +} + +function getPlotLoggerConfig(editWidget) { + // Build variable settings HTML if variables exist + let varSettingsHtml = ''; + if (editWidget?.variables && editWidget.variables.length > 0) { + varSettingsHtml = buildPlotLoggerVariableSettingsHtml(editWidget); + } + + const showLegend = editWidget?.showLegend !== false; // default true + + return ` +
+ + + Search and select variables, then configure each below +
+
${varSettingsHtml}
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ ${window.getUpdateRateConfigHTML ? window.getUpdateRateConfigHTML(editWidget) : ''} + `; +} + +function buildPlotLoggerVariableSettingsHtml(widget) { + if (!widget?.variables || widget.variables.length === 0) return ''; + + let html = ''; + widget.variables.forEach((varName, idx) => { + const settings = widget.varSettings?.[varName] || {}; + const color = settings.color || plotLoggerDefaultColors[idx % plotLoggerDefaultColors.length]; + const gain = settings.gain !== undefined ? settings.gain : 1; + const offset = settings.offset !== undefined ? settings.offset : 0; + + html += ` +
+
+
${varName}
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ `; + }); + return html; +} + +function updatePlotLoggerVariableSettingsUI() { + const selectedVars = $('#widgetVariables').val() || []; + const container = document.getElementById('variableSettingsContainer'); + if (!container) return; + + // Build a temporary widget object with current selections + const tempWidget = { + variables: selectedVars, + varSettings: {} + }; + + // Preserve existing settings from current inputs + selectedVars.forEach((varName, idx) => { + const colorEl = document.getElementById(`varColor-${idx}`); + const gainEl = document.getElementById(`varGain-${idx}`); + const offsetEl = document.getElementById(`varOffset-${idx}`); + if (colorEl || gainEl || offsetEl) { + tempWidget.varSettings[varName] = { + color: colorEl?.value || plotLoggerDefaultColors[idx % plotLoggerDefaultColors.length], + gain: parseFloat(gainEl?.value) || 1, + offset: parseFloat(offsetEl?.value) || 0 + }; + } else { + tempWidget.varSettings[varName] = { + color: plotLoggerDefaultColors[idx % plotLoggerDefaultColors.length], + gain: 1, + offset: 0 + }; + } + }); + + container.innerHTML = buildPlotLoggerVariableSettingsHtml(tempWidget); +} + +function savePlotLoggerConfig(widget) { + if (window.saveUpdateRateConfig) window.saveUpdateRateConfig(widget); + widget.variables = $('#widgetVariables').val() || []; + if (widget.variables.length === 0) { + alert('Please enter at least one variable name'); + return false; + } + + // Initialize data storage, preserving existing data + if (!widget.data) widget.data = {}; + widget.variables.forEach(v => { + if (!widget.data[v]) widget.data[v] = []; + }); + + // Save per-variable settings + widget.varSettings = {}; + widget.variables.forEach((varName, idx) => { + const colorEl = document.getElementById(`varColor-${idx}`); + const gainEl = document.getElementById(`varGain-${idx}`); + const offsetEl = document.getElementById(`varOffset-${idx}`); + widget.varSettings[varName] = { + color: colorEl?.value || plotLoggerDefaultColors[idx % plotLoggerDefaultColors.length], + gain: parseFloat(gainEl?.value) || 1, + offset: parseFloat(offsetEl?.value) || 0 + }; + }); + + widget.maxPoints = parseInt(document.getElementById('widgetMaxPoints').value) || 50; + widget.showLegend = document.getElementById('widgetShowLegend').value === 'true'; + widget.xLabel = document.getElementById('widgetXLabel').value || 'Sample'; + widget.yLabel = document.getElementById('widgetYLabel').value || 'Value'; + return true; +} + +function afterRenderPlotLoggerWidget(widget) { + setTimeout(() => { + const plotCanvas = document.getElementById(`dashboard-plot-${widget.id}`); + if (plotCanvas && typeof Chart !== 'undefined') { + renderDashboardPlot(widget, plotCanvas); + } + }, 100); +} + +function refreshPlotLoggerWidget(widget, widgetEl) { + const chart = dashboardCharts[widget.id]; + if (!chart) { + afterRenderPlotLoggerWidget(widget); + return; + } + if (widget.variables) { + widget.variables.forEach((varName, idx) => { + if (chart.data.datasets[idx]) { + const settings = widget.varSettings?.[varName] || { gain: 1, offset: 0 }; + const rawData = widget.data[varName] || []; + // Apply gain and offset + chart.data.datasets[idx].data = rawData.map(v => (v * settings.gain) + settings.offset); + } + }); + const maxLen = Math.max(0, ...widget.variables.map(v => (widget.data[v]?.length || 0))); + chart.data.labels = Array.from({length: maxLen}, (_, i) => i + 1); + } + chart.update('none'); +} + +// Initialize Select2 for variables +function initPlotLoggerSelect2(editWidget) { + $('#widgetVariables').select2({ + placeholder: "Search and select variables", + allowClear: true, + dropdownParent: $('#widgetConfigModal'), + ajax: { + url: '/variables', + dataType: 'json', + delay: 250, + processResults: function (data) { + return { results: data.items }; + }, + cache: true + }, + minimumInputLength: 3, + multiple: true + }); + + // Pre-populate existing variables when editing + if (editWidget?.variables) { + editWidget.variables.forEach(v => { + $('#widgetVariables').append(new Option(v, v, true, true)); + }); + $('#widgetVariables').trigger('change'); + } + + // Update variable settings when selection changes + $('#widgetVariables').on('change', function() { + updatePlotLoggerVariableSettingsUI(); + }); +} + +// Register widget type +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +window.dashboardWidgetTypes.plot_logger = { + icon: 'timeline', + create: createPlotLoggerWidget, + getConfig: getPlotLoggerConfig, + saveConfig: savePlotLoggerConfig, + afterRender: afterRenderPlotLoggerWidget, + refresh: refreshPlotLoggerWidget, + initSelect2: initPlotLoggerSelect2, + requiresVariable: false, + requiresMultipleVariables: true, + supportsGainOffset: false +}; diff --git a/pyx2cscope/gui/web/static/widgets/plot_scope/widget.js b/pyx2cscope/gui/web/static/widgets/plot_scope/widget.js new file mode 100644 index 00000000..59930d3c --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/plot_scope/widget.js @@ -0,0 +1,297 @@ +/** + * Plot Scope Widget - Triggered oscilloscope-style plot using Chart.js + * + * Features: + * - Multiple variables on one plot + * - Triggered data capture (replaces entire buffer on update) + * - Auto-scaling + * - Per-variable color, gain, and offset + * - Time-based X-axis from scope control settings + * - Show/hide legend option + * + * Note: Trigger configuration is managed in Scope View. + * Use Scope Control widget to start/stop sampling. + */ + +const plotScopeDefaultColors = ['#0d6efd', '#dc3545', '#198754', '#ffc107', '#0dcaf0', '#6f42c1', '#fd7e14', '#20c997']; + +function createPlotScopeWidget(widget) { + return ` + + `; +} + +function getPlotScopeConfig(editWidget) { + // Build variable settings HTML if variables exist + let varSettingsHtml = ''; + if (editWidget?.variables && editWidget.variables.length > 0) { + varSettingsHtml = buildPlotScopeVariableSettingsHtml(editWidget); + } + + const showLegend = editWidget?.showLegend !== false; // default true + + return ` +
+ + + Select variables, then configure each below +
+
${varSettingsHtml}
+
+
+ + +
+
+ + +
+
+
+ + Note: X-axis time is calculated from Scope Control settings (Time factor × 1/Frequency). + Use the Scope Control widget to adjust sampling parameters. + +
+ `; +} + +function buildPlotScopeVariableSettingsHtml(widget) { + if (!widget?.variables || widget.variables.length === 0) return ''; + + let html = ''; + widget.variables.forEach((varName, idx) => { + const settings = widget.varSettings?.[varName] || {}; + const color = settings.color || plotScopeDefaultColors[idx % plotScopeDefaultColors.length]; + const gain = settings.gain !== undefined ? settings.gain : 1; + const offset = settings.offset !== undefined ? settings.offset : 0; + + html += ` +
+
+
${varName}
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ `; + }); + return html; +} + +function updatePlotScopeVariableSettingsUI() { + const selectedVars = $('#widgetVariables').val() || []; + const container = document.getElementById('variableSettingsContainer'); + if (!container) return; + + // Build a temporary widget object with current selections + const tempWidget = { + variables: selectedVars, + varSettings: {} + }; + + // Preserve existing settings from current inputs + selectedVars.forEach((varName, idx) => { + const colorEl = document.getElementById(`varColor-${idx}`); + const gainEl = document.getElementById(`varGain-${idx}`); + const offsetEl = document.getElementById(`varOffset-${idx}`); + if (colorEl || gainEl || offsetEl) { + tempWidget.varSettings[varName] = { + color: colorEl?.value || plotScopeDefaultColors[idx % plotScopeDefaultColors.length], + gain: parseFloat(gainEl?.value) || 1, + offset: parseFloat(offsetEl?.value) || 0 + }; + } else { + tempWidget.varSettings[varName] = { + color: plotScopeDefaultColors[idx % plotScopeDefaultColors.length], + gain: 1, + offset: 0 + }; + } + }); + + container.innerHTML = buildPlotScopeVariableSettingsHtml(tempWidget); +} + +function savePlotScopeConfig(widget) { + widget.variables = $('#widgetVariables').val() || []; + if (widget.variables.length === 0) { + alert('Please select at least one variable'); + return false; + } + + // Initialize data storage, preserving existing data + if (!widget.data) widget.data = {}; + widget.variables.forEach(v => { + if (!widget.data[v]) widget.data[v] = []; + }); + + // Save per-variable settings + widget.varSettings = {}; + widget.variables.forEach((varName, idx) => { + const colorEl = document.getElementById(`varColor-${idx}`); + const gainEl = document.getElementById(`varGain-${idx}`); + const offsetEl = document.getElementById(`varOffset-${idx}`); + widget.varSettings[varName] = { + color: colorEl?.value || plotScopeDefaultColors[idx % plotScopeDefaultColors.length], + gain: parseFloat(gainEl?.value) || 1, + offset: parseFloat(offsetEl?.value) || 0 + }; + }); + + widget.yLabel = document.getElementById('widgetYLabel').value || 'Value'; + widget.showLegend = document.getElementById('widgetShowLegend').value === 'true'; + + // Register new variables with scope view + if (scopeSocket && scopeSocket.connected) { + const currentScopeVars = window.scopeVariablesList || []; + widget.variables.forEach(v => { + // Only add if not already in scope view + if (!currentScopeVars.includes(v)) { + scopeSocket.emit('add_scope_var', { var: v }); + } + }); + } + + return true; +} + +function afterRenderPlotScopeWidget(widget) { + setTimeout(() => { + const plotCanvas = document.getElementById(`dashboard-plot-${widget.id}`); + if (plotCanvas && typeof Chart !== 'undefined') { + renderDashboardPlot(widget, plotCanvas); + } + }, 100); +} + +function refreshPlotScopeWidget(widget, widgetEl) { + const chart = dashboardCharts[widget.id]; + if (!chart) { + afterRenderPlotScopeWidget(widget); + return; + } + if (widget.variables) { + widget.variables.forEach((varName, idx) => { + if (chart.data.datasets[idx]) { + const settings = widget.varSettings?.[varName] || { gain: 1, offset: 0 }; + const rawData = widget.data[varName] || []; + // Apply gain and offset + chart.data.datasets[idx].data = rawData.map(v => (v * settings.gain) + settings.offset); + } + }); + // Update X-axis labels with time values + const maxLen = Math.max(0, ...widget.variables.map(v => (widget.data[v]?.length || 0))); + const timeData = generateTimeLabels(maxLen); + chart.data.labels = timeData.labels; + // Update X-axis title with unit + if (chart.options.scales.x) { + chart.options.scales.x.title.text = `Time (${timeData.unit})`; + } + } + chart.update('none'); +} + +// Generate time labels based on scope control settings +// Returns { labels: number[], unit: string } +function generateTimeLabels(numSamples) { + // Get sample time and frequency from scope control widget + const scopeControlWidget = dashboardWidgets.find(w => w.type === 'scope_control'); + const sampleTime = scopeControlWidget?.sampleTime || 1; + const sampleFreq = scopeControlWidget?.sampleFreq || 20; // in KHz + + // Calculate time per sample: (1 / freq_Hz) * sampleTime + // freq is in KHz, so freq_Hz = sampleFreq * 1000 + const timePerSampleUs = (1 / (sampleFreq * 1000)) * sampleTime * 1000000; // in microseconds + const totalTimeUs = (numSamples - 1) * timePerSampleUs; + + // Determine best unit based on total time + let unit, divisor; + if (totalTimeUs >= 1000000) { + unit = 's'; + divisor = 1000000; + } else if (totalTimeUs >= 1000) { + unit = 'ms'; + divisor = 1000; + } else { + unit = 'µs'; + divisor = 1; + } + + const labels = []; + for (let i = 0; i < numSamples; i++) { + const timeUs = i * timePerSampleUs; + labels.push(parseFloat((timeUs / divisor).toFixed(2))); + } + + return { labels, unit }; +} + +// Initialize Select2 for variables +function initPlotScopeSelect2(editWidget) { + $('#widgetVariables').select2({ + placeholder: "Search and select variables", + allowClear: true, + dropdownParent: $('#widgetConfigModal'), + ajax: { + url: '/variables', + dataType: 'json', + delay: 250, + processResults: function (data) { + return { results: data.items }; + }, + cache: true + }, + minimumInputLength: 3, + multiple: true + }); + + // Pre-populate existing variables when editing + if (editWidget?.variables) { + editWidget.variables.forEach(v => { + $('#widgetVariables').append(new Option(v, v, true, true)); + }); + $('#widgetVariables').trigger('change'); + } + + // Update variable settings when selection changes + $('#widgetVariables').on('change', function() { + updatePlotScopeVariableSettingsUI(); + }); +} + +// Register widget type +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +window.dashboardWidgetTypes.plot_scope = { + icon: 'show_chart', + create: createPlotScopeWidget, + getConfig: getPlotScopeConfig, + saveConfig: savePlotScopeConfig, + afterRender: afterRenderPlotScopeWidget, + refresh: refreshPlotScopeWidget, + initSelect2: initPlotScopeSelect2, + requiresVariable: false, + requiresMultipleVariables: true, + supportsGainOffset: false +}; diff --git a/pyx2cscope/gui/web/static/widgets/scope_control/widget.js b/pyx2cscope/gui/web/static/widgets/scope_control/widget.js new file mode 100644 index 00000000..230fc44c --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/scope_control/widget.js @@ -0,0 +1,295 @@ +/** + * Scope Control Widget - Full scope control panel + * + * Features: + * - Sample/Stop/Burst buttons + * - Sample time and frequency settings + * - Trigger configuration (enable, edge, level, delay) + * - Trigger variable selection + * - Status indicator + */ + +let scopeControlState = 'off'; // 'on', 'off', 'shot' + +function handleScopeControlAction(action) { + if (isDashboardEditMode) return; + + scopeControlState = action; + + // Get current settings from the widget + const sampleTime = document.getElementById('scopeCtrlSampleTime')?.value || 1; + const sampleFreq = document.getElementById('scopeCtrlSampleFreq')?.value || 20; + + // Send to scope-view namespace + if (scopeSocket && scopeSocket.connected) { + const formData = `triggerAction=${action}&sampleTime=${sampleTime}&sampleFreq=${sampleFreq}`; + scopeSocket.emit('update_sample_control', formData); + } + + // Update button states and store values in widget objects + dashboardWidgets + .filter(w => w.type === 'scope_control') + .forEach(w => { + // Store current values in widget for persistence + w.sampleTime = parseInt(sampleTime); + w.sampleFreq = parseInt(sampleFreq); + + const widgetEl = document.getElementById(`dashboard-widget-${w.id}`); + if (widgetEl) { + refreshScopeControlWidget(w, widgetEl); + } + }); +} + +function updateScopeControlTrigger(widgetId) { + if (isDashboardEditMode) return; + + const widget = dashboardWidgets.find(w => w.id === widgetId); + if (!widget || !scopeSocket || !scopeSocket.connected) return; + + const triggerMode = document.querySelector(`input[name="scopeCtrlTriggerMode-${widgetId}"]:checked`)?.value || '0'; + const triggerEdge = document.querySelector(`input[name="scopeCtrlTriggerEdge-${widgetId}"]:checked`)?.value || '1'; + const triggerLevel = document.getElementById(`scopeCtrlTriggerLevel-${widgetId}`)?.value || 0; + const triggerDelay = document.getElementById(`scopeCtrlTriggerDelay-${widgetId}`)?.value || 0; + const triggerVar = document.getElementById(`scopeCtrlTriggerVar-${widgetId}`)?.value || ''; + + // Store values in widget for persistence + widget.triggerMode = triggerMode; + widget.triggerEdge = triggerEdge; + widget.triggerLevel = parseFloat(triggerLevel); + widget.triggerDelay = parseInt(triggerDelay); + widget.triggerVar = triggerVar; + + // Update trigger variable selection on scope variables + const scopeVars = window.scopeVariablesList || []; + if (triggerVar && scopeVars.length > 0) { + scopeVars.forEach(varName => { + scopeSocket.emit('update_scope_var', { + param: varName, + field: 'trigger', + value: varName === triggerVar ? '1' : '0' + }); + }); + } + + // Send trigger control settings (use snake_case to match backend) + const formData = `trigger_mode=${triggerMode}&trigger_edge=${triggerEdge}&trigger_level=${triggerLevel}&trigger_delay=${triggerDelay}`; + scopeSocket.emit('update_trigger_control', formData); +} + +function updateScopeSampleSettings(widgetId) { + if (isDashboardEditMode) return; + + const sampleTime = document.getElementById('scopeCtrlSampleTime')?.value || 1; + const sampleFreq = document.getElementById('scopeCtrlSampleFreq')?.value || 20; + + // Store values in all scope_control widgets for persistence + dashboardWidgets + .filter(w => w.type === 'scope_control') + .forEach(w => { + w.sampleTime = parseInt(sampleTime); + w.sampleFreq = parseInt(sampleFreq); + }); + + if (scopeSocket && scopeSocket.connected) { + const formData = `triggerAction=${scopeControlState}&sampleTime=${sampleTime}&sampleFreq=${sampleFreq}`; + scopeSocket.emit('update_sample_control', formData); + } +} + +function createScopeControlWidget(widget) { + const isRunning = scopeControlState === 'on'; + const isBurst = scopeControlState === 'shot'; + const isStopped = scopeControlState === 'off'; + + const triggerMode = widget.triggerMode || '0'; + const triggerEdge = widget.triggerEdge || '1'; + const triggerLevel = widget.triggerLevel || 0; + const triggerDelay = widget.triggerDelay || 0; + const triggerVar = widget.triggerVar || ''; + const sampleTime = widget.sampleTime || 1; + const sampleFreq = widget.sampleFreq || 20; + + // Build trigger variable options from scope-view variables + let triggerVarOptions = ''; + const scopeVars = window.scopeVariablesList || []; + scopeVars.forEach(v => { + triggerVarOptions += ``; + }); + + return ` +
+ +
+ Sample Control +
+ + + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ Trigger Control +
+ + + + +
+
+ +
+ + +
+ +
+ Edge +
+ + + + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + + ${isRunning ? 'Running' : isBurst ? 'Burst' : 'Stopped'} + + +
+
+ `; +} + +function getScopeControlConfig(editWidget) { + return ` +
+ + Note: This widget controls scope sampling and displays variables + configured in the Scope View page. Add variables there first, then use this + widget to control sampling and select which variable to trigger on. + +
+ `; +} + +function saveScopeControlConfig(widget) { + widget.sampleTime = widget.sampleTime || 1; + widget.sampleFreq = widget.sampleFreq || 20; + widget.triggerMode = widget.triggerMode || '0'; + widget.triggerEdge = widget.triggerEdge || '1'; + widget.triggerLevel = widget.triggerLevel || 0; + widget.triggerDelay = widget.triggerDelay || 0; + widget.triggerVar = widget.triggerVar || ''; + return true; +} + +function refreshScopeControlWidget(widget, widgetEl) { + // Update button states only, preserve input values + const btns = widgetEl.querySelectorAll('.btn-group button'); + if (btns.length >= 3) { + const isRunning = scopeControlState === 'on'; + const isStopped = scopeControlState === 'off'; + const isBurst = scopeControlState === 'shot'; + + btns[0].className = `btn btn-${isRunning ? 'success' : 'outline-success'}`; + btns[1].className = `btn btn-${isStopped ? 'danger' : 'outline-danger'}`; + btns[2].className = `btn btn-${isBurst ? 'primary' : 'outline-primary'}`; + } + + // Update status badge + const badge = widgetEl.querySelector('.badge'); + if (badge) { + const isRunning = scopeControlState === 'on'; + const isBurst = scopeControlState === 'shot'; + badge.className = `badge ${isRunning ? 'bg-success' : isBurst ? 'bg-primary' : 'bg-secondary'}`; + badge.textContent = isRunning ? 'Running' : isBurst ? 'Burst' : 'Stopped'; + } +} + +// Register widget type +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +window.dashboardWidgetTypes.scope_control = { + icon: 'settings_remote', + create: createScopeControlWidget, + getConfig: getScopeControlConfig, + saveConfig: saveScopeControlConfig, + refresh: refreshScopeControlWidget, + requiresVariable: false, + supportsGainOffset: false +}; diff --git a/pyx2cscope/gui/web/static/widgets/slider/widget.js b/pyx2cscope/gui/web/static/widgets/slider/widget.js new file mode 100644 index 00000000..45f0602f --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/slider/widget.js @@ -0,0 +1,80 @@ +/** + * Slider Widget - Range slider control + * + * Features: + * - Configurable min/max/step values + * - Real-time value display + * - Supports gain/offset transformation + */ + +function createSliderWidget(widget) { + return ` +
${widget.value}
+ + `; +} + +function getSliderConfig(editWidget) { + return ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ${window.getUpdateRateConfigHTML ? window.getUpdateRateConfigHTML(editWidget, 0) : ''} + `; +} + +function saveSliderConfig(widget) { + if (window.saveUpdateRateConfig) window.saveUpdateRateConfig(widget); + widget.min = parseFloat(document.getElementById('widgetMinValue').value); + widget.max = parseFloat(document.getElementById('widgetMaxValue').value); + widget.step = parseFloat(document.getElementById('widgetStepValue').value); + widget.gain = parseFloat(document.getElementById('widgetGain').value) || 1; + widget.offset = parseFloat(document.getElementById('widgetOffset').value) || 0; +} + +function refreshSliderWidget(widget, widgetEl) { + const display = widgetEl.querySelector('.value-display'); + const input = widgetEl.querySelector('input[type="range"]'); + if (display) display.textContent = widget.value; + if (input) input.value = widget.value; +} + +// Register widget type +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +window.dashboardWidgetTypes.slider = { + icon: 'tune', + create: createSliderWidget, + getConfig: getSliderConfig, + saveConfig: saveSliderConfig, + refresh: refreshSliderWidget, + requiresVariable: true, + supportsGainOffset: true +}; diff --git a/pyx2cscope/gui/web/static/widgets/switch/widget.js b/pyx2cscope/gui/web/static/widgets/switch/widget.js new file mode 100644 index 00000000..48a31e77 --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/switch/widget.js @@ -0,0 +1,82 @@ +/** + * Switch Widget - Toggle switch control + * + * Features: + * - On/Off toggle switch (iOS-style) + * - Configurable on/off values + * - Display name label + * - Visual state indication + */ + +function handleSwitchToggle(id) { + const widget = dashboardWidgets.find(w => w.id === id); + if (!widget) return; + + widget.switchState = !widget.switchState; + const value = widget.switchState ? widget.onValue : widget.offValue; + updateDashboardVariable(widget.variable, value); + renderDashboardWidget(widget); +} + +function createSwitchWidget(widget) { + const isOn = widget.switchState || false; + + return ` +
+ +
+ `; +} + +function getSwitchConfig(editWidget) { + return ` +
+ + +
+
+ + +
+
+ + +
+ ${window.getUpdateRateConfigHTML ? window.getUpdateRateConfigHTML(editWidget, 0) : ''} + `; +} + +function saveSwitchConfig(widget) { + if (window.saveUpdateRateConfig) window.saveUpdateRateConfig(widget); + widget.displayName = document.getElementById('widgetDisplayName').value; + widget.onValue = parseValue(document.getElementById('widgetOnValue').value); + widget.offValue = parseValue(document.getElementById('widgetOffValue').value); + widget.switchState = widget.switchState || false; +} + +function refreshSwitchWidget(widget, widgetEl) { + const checkbox = widgetEl.querySelector('input[type="checkbox"]'); + if (checkbox) { + checkbox.checked = widget.switchState || false; + } +} + +// Register widget type +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +window.dashboardWidgetTypes.switch = { + icon: 'toggle_on', + create: createSwitchWidget, + getConfig: getSwitchConfig, + saveConfig: saveSwitchConfig, + refresh: refreshSwitchWidget, + requiresVariable: true, + supportsGainOffset: false +}; diff --git a/pyx2cscope/gui/web/static/widgets/text/widget.js b/pyx2cscope/gui/web/static/widgets/text/widget.js new file mode 100644 index 00000000..b7407b80 --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/text/widget.js @@ -0,0 +1,58 @@ +/** + * Text Widget - Text input field + * + * Features: + * - String/text entry + * - Supports gain/offset transformation + */ + +function createTextWidget(widget) { + return ` + + `; +} + +function getTextConfig(editWidget) { + return ` +
+ + +
+
+ + +
+ ${window.getUpdateRateConfigHTML ? window.getUpdateRateConfigHTML(editWidget) : ''} + `; +} + +function saveTextConfig(widget) { + if (window.saveUpdateRateConfig) window.saveUpdateRateConfig(widget); + widget.gain = parseFloat(document.getElementById('widgetGain').value) || 1; + widget.offset = parseFloat(document.getElementById('widgetOffset').value) || 0; +} + +function refreshTextWidget(widget, widgetEl) { + const input = widgetEl.querySelector('input[type="text"]'); + if (input && document.activeElement !== input) input.value = widget.value; +} + +// Register widget type +if (typeof window.dashboardWidgetTypes === 'undefined') { + window.dashboardWidgetTypes = {}; +} + +window.dashboardWidgetTypes.text = { + icon: 'text_fields', + create: createTextWidget, + getConfig: getTextConfig, + saveConfig: saveTextConfig, + refresh: refreshTextWidget, + requiresVariable: true, + supportsGainOffset: true +}; diff --git a/pyx2cscope/gui/web/static/widgets/widget_loader.js b/pyx2cscope/gui/web/static/widgets/widget_loader.js new file mode 100644 index 00000000..5ec16887 --- /dev/null +++ b/pyx2cscope/gui/web/static/widgets/widget_loader.js @@ -0,0 +1,126 @@ +/** + * Widget Loader - Dynamically loads modular widget definitions + * + * Add this script BEFORE your existing dashboard_view.js + * + * This loads widget files from /static/widgets/ folder structure: + * widgets/ + * button/ + * widget.js + * slider/ + * widget.js + * ...etc + */ + +// Global registry for widget types +window.dashboardWidgetTypes = {}; + +// Track last update time per widget for update rate control +window.widgetLastUpdateTime = {}; + +/** + * Update Rate Configuration Helper + * + * Update rate values: + * - 0: Off (no automatic updates) + * - > 0: Interval in seconds + */ + +// Get update rate configuration HTML for widget config modal +// defaultRate: used when editWidget has no updateRate saved yet (0 = off, 1/2/5/... = interval) +function getUpdateRateConfigHTML(editWidget, defaultRate) { + const fallback = defaultRate !== undefined ? defaultRate : 1; + const currentRate = editWidget?.updateRate !== undefined ? editWidget.updateRate : fallback; + return ` +
+ + +
How often to read the variable value
+
+ `; +} + +// Save update rate from widget config modal +function saveUpdateRateConfig(widget) { + const rateValue = document.getElementById('widgetUpdateRate')?.value; + if (rateValue !== undefined) { + widget.updateRate = parseFloat(rateValue); + } +} + +// Check if widget should be updated based on its update rate +function shouldUpdateWidget(widget) { + // Update rate: 0 = off, > 0 = interval in seconds + const updateRate = widget.updateRate !== undefined ? widget.updateRate : 1; + + // Off - never auto-update + if (updateRate === 0) { + return false; + } + + // Interval-based update + const now = Date.now(); + const lastUpdate = window.widgetLastUpdateTime[widget.id] || 0; + const intervalMs = updateRate * 1000; + + if (now - lastUpdate >= intervalMs) { + window.widgetLastUpdateTime[widget.id] = now; + return true; + } + + return false; +} + +// Make helpers available globally +window.getUpdateRateConfigHTML = getUpdateRateConfigHTML; +window.saveUpdateRateConfig = saveUpdateRateConfig; +window.shouldUpdateWidget = shouldUpdateWidget; + +// List of widgets to load +const widgetsList = [ + 'button', + 'switch', + 'slider', + 'gauge', + 'number', + 'text', + 'label', + 'plot_logger', + 'plot_scope', + 'scope_control' +]; + +// Load all widgets on page load +document.addEventListener('DOMContentLoaded', async function() { + console.log('Loading modular widgets...'); + + for (const widgetType of widgetsList) { + try { + // Load widget.js file + await loadScript(`/static/widgets/${widgetType}/widget.js`); + console.log(`Loaded widget: ${widgetType}`); + } catch (error) { + console.error(`Failed to load widget ${widgetType}:`, error); + } + } + + console.log('All widgets loaded:', Object.keys(window.dashboardWidgetTypes)); +}); + +// Helper function to load script +function loadScript(url) { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = url; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); +} diff --git a/pyx2cscope/gui/web/templates/dashboard_toolbar.html b/pyx2cscope/gui/web/templates/dashboard_toolbar.html new file mode 100644 index 00000000..d4b4b25b --- /dev/null +++ b/pyx2cscope/gui/web/templates/dashboard_toolbar.html @@ -0,0 +1,22 @@ +   + + delete_sweep + + + download + + + upload + + + folder_open + + + save + + + visibility + + + + diff --git a/pyx2cscope/gui/web/templates/dashboard_view.html b/pyx2cscope/gui/web/templates/dashboard_view.html new file mode 100644 index 00000000..106a5fef --- /dev/null +++ b/pyx2cscope/gui/web/templates/dashboard_view.html @@ -0,0 +1,55 @@ +
+ + + +
+ + + +
+
+ +
+
+
+
+ + + \ No newline at end of file diff --git a/pyx2cscope/gui/web/templates/header.html b/pyx2cscope/gui/web/templates/header.html index 5edcf995..0f400c87 100644 --- a/pyx2cscope/gui/web/templates/header.html +++ b/pyx2cscope/gui/web/templates/header.html @@ -6,4 +6,5 @@ + \ No newline at end of file diff --git a/pyx2cscope/gui/web/templates/index.html b/pyx2cscope/gui/web/templates/index.html index 85b53a8f..95546f84 100644 --- a/pyx2cscope/gui/web/templates/index.html +++ b/pyx2cscope/gui/web/templates/index.html @@ -17,29 +17,67 @@
- + - + + + + +
-
+
+
+
+ Scripting + + open_in_new + + qr_code_2 +
+
+ {% include 'scripting.html' %} +
+
+
+
+
+
+ Dashboard + + open_in_new + + qr_code_2 + {% include 'dashboard_toolbar.html' %} +
+
+ {% include 'dashboard_view.html' %} +
+
+
+
WatchView - + open_in_new qr_code_2 @@ -49,11 +87,11 @@
-
+
ScopeView - + open_in_new qr_code_2 @@ -72,4 +110,7 @@ + + + {% endblock scripts %} \ No newline at end of file diff --git a/pyx2cscope/gui/web/templates/index_dashboard.html b/pyx2cscope/gui/web/templates/index_dashboard.html new file mode 100644 index 00000000..cede1df0 --- /dev/null +++ b/pyx2cscope/gui/web/templates/index_dashboard.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+
+ Dashboard View + {% include 'dashboard_toolbar.html' %} +
+
+ {% include 'dashboard_view.html' %} +
+
+
+
+
+{% endblock content %} + +{% block scripts %} + + +{% endblock scripts %} \ No newline at end of file diff --git a/pyx2cscope/gui/web/templates/index_sv.html b/pyx2cscope/gui/web/templates/index_scope.html similarity index 100% rename from pyx2cscope/gui/web/templates/index_sv.html rename to pyx2cscope/gui/web/templates/index_scope.html diff --git a/pyx2cscope/gui/web/templates/index_scripting.html b/pyx2cscope/gui/web/templates/index_scripting.html new file mode 100644 index 00000000..2f11c68b --- /dev/null +++ b/pyx2cscope/gui/web/templates/index_scripting.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+ Scripting +
+
+ {% include 'scripting.html' %} +
+
+
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/pyx2cscope/gui/web/templates/index_wv.html b/pyx2cscope/gui/web/templates/index_watch.html similarity index 100% rename from pyx2cscope/gui/web/templates/index_wv.html rename to pyx2cscope/gui/web/templates/index_watch.html diff --git a/pyx2cscope/gui/web/templates/sample_control.html b/pyx2cscope/gui/web/templates/sample_control.html index 7b027aca..281abcf2 100644 --- a/pyx2cscope/gui/web/templates/sample_control.html +++ b/pyx2cscope/gui/web/templates/sample_control.html @@ -13,7 +13,7 @@
- +
:1 @@ -21,7 +21,7 @@
- +
KHz diff --git a/pyx2cscope/gui/web/templates/scripting.html b/pyx2cscope/gui/web/templates/scripting.html new file mode 100644 index 00000000..4e133ee9 --- /dev/null +++ b/pyx2cscope/gui/web/templates/scripting.html @@ -0,0 +1,85 @@ +
+ +
+
+ +
+ + + + +
+
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ Ready +
+
+ + + +
+ +
+

+            
+ +
+
+ +
+

+            
+ +
+
+
+ + +
+
+ + Scripts have access to 'x2cscope' variable when connected. Click Help for examples and documentation. + +
+
+
+ + + diff --git a/pyx2cscope/gui/web/templates/setup.html b/pyx2cscope/gui/web/templates/setup.html index 48db3023..f5eef85b 100644 --- a/pyx2cscope/gui/web/templates/setup.html +++ b/pyx2cscope/gui/web/templates/setup.html @@ -1,9 +1,20 @@
+
+
+ + +
+
+ -
+
- -
@@ -14,6 +25,60 @@
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
diff --git a/pyx2cscope/gui/web/templates/source_config.html b/pyx2cscope/gui/web/templates/source_config.html index 20f67d13..9ff6a770 100644 --- a/pyx2cscope/gui/web/templates/source_config.html +++ b/pyx2cscope/gui/web/templates/source_config.html @@ -1,6 +1,10 @@
-
- +
+ +
+ + +
diff --git a/pyx2cscope/gui/web/templates/watch_view.html b/pyx2cscope/gui/web/templates/watch_view.html index 30eafcc1..7f4d42b3 100644 --- a/pyx2cscope/gui/web/templates/watch_view.html +++ b/pyx2cscope/gui/web/templates/watch_view.html @@ -1,7 +1,11 @@
-
- +
+ +
+ + +
diff --git a/pyx2cscope/gui/web/views/dashboard_view.py b/pyx2cscope/gui/web/views/dashboard_view.py new file mode 100644 index 00000000..91373385 --- /dev/null +++ b/pyx2cscope/gui/web/views/dashboard_view.py @@ -0,0 +1,94 @@ +"""Dashboard View Blueprint. This module handles all URLs called over {server_url}/dashboard-view. + +Calling the URL {server_url}/dashboard-view will render the dashboard-view page. +Attention: this page should be called only after a successful setup connection on the {server_url} +""" +import json +import os + +from flask import Blueprint, jsonify, render_template, request + +from pyx2cscope.gui import web +from pyx2cscope.gui.web.scope import web_scope + +dv_bp = Blueprint("dashboard_view", __name__) + + +def index(): + """Dashboard View URL entry point. Calling the page {url}/dashboard-view will render the dashboard view page.""" + return render_template("index_dashboard.html", title="Dashboard - pyX2Cscope") + + +def get_data(): + """Return the dashboard variable data. + + Calling the link {dashboard-view-url}/data will return all current widget variable values. + """ + result = {} + for name, variable in web_scope.dashboard_vars.items(): + try: + result[name] = variable.get_value() + except Exception: + result[name] = 0 + return jsonify(result) + + +def update_variable(): + """Update a widget variable value. + + Calling the link {dashboard-view-url}/update with POST data will update the variable. + Expected JSON: {"variable": "var_name", "value": value} + """ + try: + data = request.json + var_name = data.get('variable') + value = data.get('value') + + if var_name: + web_scope.write_dashboard_var(var_name, value) + return jsonify({'status': 'success'}) + return jsonify({'status': 'error', 'message': 'Variable name required'}), 400 + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +def save_layout(): + """Save dashboard layout to file. + + Calling the link {dashboard-view-url}/save-layout will save the current layout to a JSON file. + """ + try: + layout = request.json + web_lib_path = os.path.join(os.path.dirname(web.__file__), "upload") + os.path.join(web_lib_path, "dashboard_layout.json") + with open(os.path.join(web_lib_path, "dashboard_layout.json"), 'w') as f: + json.dump(layout, f, indent=2) + return jsonify({'status': 'success', 'message': 'Layout saved successfully'}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +def load_layout(): + """Load dashboard layout from file. + + Calling the link {dashboard-view-url}/load-layout will load the saved layout. + """ + try: + web_lib_path = os.path.join(os.path.dirname(web.__file__), "upload") + dashboard_file = os.path.join(web_lib_path, "dashboard_layout.json") + if os.path.exists(dashboard_file): + with open(dashboard_file, 'r') as f: + layout = json.load(f) + return jsonify({'status': 'success', 'layout': layout}) + else: + return jsonify({'status': 'error', 'message': 'No saved layout found'}), 404 + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +# Register URL rules +dv_bp.add_url_rule("/", view_func=index, methods=["GET"]) +dv_bp.add_url_rule("/data", view_func=get_data, methods=["GET"]) +dv_bp.add_url_rule("/update", view_func=update_variable, methods=["POST"]) +dv_bp.add_url_rule("/save-layout", view_func=save_layout, methods=["POST"]) +dv_bp.add_url_rule("/load-layout", view_func=load_layout, methods=["GET"]) \ No newline at end of file diff --git a/pyx2cscope/gui/web/views/scope_view.py b/pyx2cscope/gui/web/views/scope_view.py index 4c8e4245..651fe648 100644 --- a/pyx2cscope/gui/web/views/scope_view.py +++ b/pyx2cscope/gui/web/views/scope_view.py @@ -10,11 +10,11 @@ from pyx2cscope.gui import web from pyx2cscope.gui.web.scope import web_scope -sv_bp = Blueprint("scope_view", __name__, template_folder="templates") +sv_bp = Blueprint("scope_view", __name__) def index(): """Scope View url entry point. Calling the page {url}/scope-view will render the scope view page.""" - return render_template("index_sv.html", title="ScopeView - pyX2Cscope") + return render_template("index_scope.html", title="ScopeView - pyX2Cscope") def get_data(): diff --git a/pyx2cscope/gui/web/views/script_view.py b/pyx2cscope/gui/web/views/script_view.py new file mode 100644 index 00000000..b31742c8 --- /dev/null +++ b/pyx2cscope/gui/web/views/script_view.py @@ -0,0 +1,30 @@ +"""Script View Blueprint - handles scripting-related HTTP endpoints.""" + +from flask import Blueprint, jsonify, render_template + +from pyx2cscope.gui.resources import get_resource_path + +script_bp = Blueprint("script_view", __name__) + + +@script_bp.route("/") +def script_view(): + """Render the standalone script view page.""" + return render_template("index_scripting.html", title="Script View") + + +@script_bp.route("/help") +def script_help(): + """Return the script help content as markdown.""" + help_content = _load_help_content() + return jsonify({"markdown": help_content}) + + +def _load_help_content() -> str: + """Load help content from the shared resources 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}" diff --git a/pyx2cscope/gui/web/views/watch_view.py b/pyx2cscope/gui/web/views/watch_view.py index 5afe3bec..0dab9dda 100644 --- a/pyx2cscope/gui/web/views/watch_view.py +++ b/pyx2cscope/gui/web/views/watch_view.py @@ -10,11 +10,11 @@ from pyx2cscope.gui import web from pyx2cscope.gui.web.scope import web_scope -wv_bp = Blueprint("watch_view", __name__, template_folder="templates") +wv_bp = Blueprint("watch_view", __name__) def index(): """Watch View url entry point. Calling the page {url}/watch-view will render the watch view page.""" - return render_template("index_wv.html", title="WatchView - pyX2Cscope") + return render_template("index_watch.html", title="WatchView - pyX2Cscope") def get_data(): diff --git a/pyx2cscope/gui/web/ws_handlers.py b/pyx2cscope/gui/web/ws_handlers.py index 82ddafcf..063fc39c 100644 --- a/pyx2cscope/gui/web/ws_handlers.py +++ b/pyx2cscope/gui/web/ws_handlers.py @@ -15,7 +15,8 @@ def background_x2cscope_task(): """Background x2cScope thread.""" while True: watch_values = web_scope.watch_poll() - scope_values = web_scope.scope_poll() + scope_values, dashboard_scope_data = web_scope.scope_poll() + dashboard_values = web_scope.dashboard_poll() if watch_values: socketio.emit("watch_data_update", watch_values, namespace="/watch-view") if scope_values: @@ -24,6 +25,10 @@ def background_x2cscope_task(): socketio.emit("sample_control_updated", { "status": "success", "data": {"triggerAction": "off"} }, namespace="/scope-view") + if dashboard_values: + socketio.emit("dashboard_data_update", dashboard_values, namespace="/dashboard") + if dashboard_scope_data: + socketio.emit("dashboard_scope_update", dashboard_scope_data, namespace="/dashboard") socketio.sleep(0.1) @socketio.on("connect") @@ -94,11 +99,12 @@ def handle_add_watch_var(data): """Handle add watch variable event. Args: - data (dict): Dictionary containing the variable name. + data (dict): Dictionary containing the variable name and optional sfr flag. """ var = data.get("var") + sfr = bool(data.get("sfr", False)) if var: - web_scope.add_watch_var(var) + web_scope.add_watch_var(var, sfr=sfr) emit("watch_table_update", {"var": var}, broadcast=True) @socketio.on("remove_watch_var", namespace="/watch-view") @@ -119,11 +125,12 @@ def handle_add_scope_var(data): """Handle add scope variable event. Args: - data (dict): Dictionary containing the variable name. + data (dict): Dictionary containing the variable name and optional sfr flag. """ var = data.get("var") + sfr = bool(data.get("sfr", False)) if var: - web_scope.add_scope_var(var) + web_scope.add_scope_var(var, sfr=sfr) emit("scope_table_update", {"var": var}, broadcast=True) @socketio.on("remove_scope_var", namespace="/scope-view") @@ -182,10 +189,219 @@ def handle_update_trigger_control(data): Args: data (str): URL-encoded query string with trigger control parameters. """ - data = {k: int(v[0]) if v else 0 for k, v in parse_qs(data).items()} - web_scope.scope_set_trigger(**data) + parsed_data = {k: (float(v[0]) if k == 'trigger_level' else int(v[0])) if v else 0 for k, v in parse_qs(data).items()} + web_scope.scope_set_trigger(**parsed_data) emit("trigger_control_updated", { "status": "success", "message": "Trigger settings updated successfully", - "data": data + "data": parsed_data }, broadcast=True) + +# Dashboard handlers +@socketio.on("connect", namespace="/dashboard") +def handle_connect_dashboard(): + """Handle client connection to the dashboard namespace.""" + if os.environ.get('DEBUG') == 'true': + print("Client connected (dashboard)") + if not hasattr(socketio, "bg_thread"): + socketio.bg_thread = socketio.start_background_task(background_x2cscope_task) + +@socketio.on("disconnect", namespace="/dashboard") +def handle_disconnect_dashboard(): + """Handle client disconnection from the dashboard namespace.""" + if os.environ.get('DEBUG') == 'true': + print("Client disconnected (dashboard)") + +@socketio.on("add_dashboard_var", namespace="/dashboard") +def handle_add_dashboard_var(data): + """Handle adding a variable to dashboard polling. + + Args: + data (dict): Dictionary containing the variable name. + """ + var = data.get("var") + if var: + web_scope.add_dashboard_var(var) + +@socketio.on("remove_dashboard_var", namespace="/dashboard") +def handle_remove_dashboard_var(data): + """Handle removing a variable from dashboard polling. + + Args: + data (dict): Dictionary containing the variable name. + """ + var = data.get("var") + if var: + web_scope.remove_dashboard_var(var) + +@socketio.on("register_scope_channel", namespace="/dashboard") +def handle_register_scope_channel(data): + """Register a scope channel from a dashboard plot_scope widget. + + Uses the shared scope_vars pool (max 8 channels, shared with scope-view). + + Args: + data (dict): Dictionary containing the variable name. + """ + var = data.get("var") + if var: + web_scope.add_scope_var(var) + # Notify scope-view so its table stays in sync + socketio.emit("scope_table_update", {"var": var}, namespace="/scope-view") + +@socketio.on("unregister_scope_channel", namespace="/dashboard") +def handle_unregister_scope_channel(data): + """Register a scope channel from a dashboard plot_scope widget. + + Uses the shared scope_vars pool (max 8 channels, shared with scope-view). + + Args: + data (dict): Dictionary containing the variable name. + """ + var = data.get("var") + if var: + web_scope.remove_scope_var(var) + # Notify scope-view so its table stays in sync + socketio.emit("scope_table_update", {"var": var}, namespace="/scope-view") + +@socketio.on("widget_interaction", namespace="/dashboard") +def handle_widget_interaction(data): + """Handle a dashboard widget user interaction (write value to device). + + Args: + data (dict): Dictionary containing variable name and value. + """ + var = data.get("variable") + value = data.get("value") + if var and value is not None: + web_scope.write_dashboard_var(var, value) + + +# ============================================================================= +# Scripting handlers +# ============================================================================= + +import io +import threading +import traceback +from contextlib import redirect_stderr, redirect_stdout + +# Global script execution state +_script_worker = None +_script_stop_requested = False + + +class ScriptOutputCapture(io.StringIO): + """Captures stdout/stderr and emits to socket.""" + + def __init__(self, socketio_instance, namespace): + """Initialize the output capture with socketio instance.""" + super().__init__() + self._socketio = socketio_instance + self._namespace = namespace + + def write(self, text): + """Write text to the socket output.""" + if text: + self._socketio.emit("script_output", {"output": text}, namespace=self._namespace) + return len(text) if text else 0 + + def flush(self): + """Flush the output buffer (no-op for socket output).""" + pass + + +def _is_stop_requested(): + """Check if script stop has been requested.""" + return _script_stop_requested + + +def _execute_script_thread(script_content, filename, namespace): + """Execute script in a background thread.""" + global _script_worker # noqa: PLW0603 + + exit_code = 0 + stdout_capture = ScriptOutputCapture(socketio, namespace) + stderr_capture = ScriptOutputCapture(socketio, namespace) + + try: + # Get x2cscope instance + x2cscope = web_scope.x2c_scope if web_scope.is_connected() else None + + # Create namespace for script execution + script_namespace = { + "__name__": "__main__", + "__file__": filename, + "x2cscope": x2cscope, + "stop_requested": _is_stop_requested, + } + + # Execute with captured output + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + exec(compile(script_content, filename, "exec"), script_namespace) + + except SystemExit as e: + exit_code = e.code if isinstance(e.code, int) else 1 + except StopIteration: + exit_code = 0 + except Exception as e: + socketio.emit("script_output", {"output": f"\nScript error: {e}\n"}, namespace=namespace) + socketio.emit("script_output", {"output": traceback.format_exc()}, namespace=namespace) + exit_code = 1 + + _script_worker = None + socketio.emit("script_finished", {"exit_code": exit_code}, namespace=namespace) + + +@socketio.on("connect", namespace="/scripting") +def handle_connect_scripting(): + """Handle client connection to the scripting namespace.""" + if os.environ.get('DEBUG') == 'true': + print("Client connected (scripting)") + + +@socketio.on("disconnect", namespace="/scripting") +def handle_disconnect_scripting(): + """Handle client disconnection from the scripting namespace.""" + if os.environ.get('DEBUG') == 'true': + print("Client disconnected (scripting)") + + +@socketio.on("execute_script", namespace="/scripting") +def handle_execute_script(data): + """Handle script execution request. + + Args: + data (dict): Dictionary containing script content and options. + """ + global _script_worker, _script_stop_requested # noqa: PLW0603 + + if _script_worker is not None and _script_worker.is_alive(): + emit("script_error", {"error": "A script is already running"}) + return + + script_content = data.get("content", "") + filename = data.get("filename", "script.py") + + if not script_content: + emit("script_error", {"error": "No script content provided"}) + return + + # Reset stop flag + _script_stop_requested = False + + # Start script execution in background thread + _script_worker = threading.Thread( + target=_execute_script_thread, + args=(script_content, filename, "/scripting"), + daemon=True + ) + _script_worker.start() + + +@socketio.on("stop_script", namespace="/scripting") +def handle_stop_script(): + """Handle script stop request.""" + global _script_stop_requested # noqa: PLW0603 + _script_stop_requested = True + emit("script_output", {"output": "\n[Stop requested - waiting for script to check stop_requested()...]\n"}) diff --git a/pyx2cscope/parser/elf_parser.py b/pyx2cscope/parser/elf_parser.py index c5fd7416..618c27f1 100644 --- a/pyx2cscope/parser/elf_parser.py +++ b/pyx2cscope/parser/elf_parser.py @@ -41,23 +41,45 @@ def __init__(self, elf_path: str): self.dwarf_info = {} self.elf_file = None self.variable_map = {} + self.register_map = {} self.symbol_table = {} self._load_elf_file() - self._map_variables() self._load_symbol_table() + self._map_variables() + self._map_registers() self._close_elf_file() - def get_var_info(self, name: str) -> Optional[VariableInfo]: + def get_register_info(self, name: str) -> Optional[VariableInfo]: + """Return the VariableInfo associated with a given register name, or None if not found. + + Args: + name (str): The name of the register (e.g. "U1STA", "U1STAbits.URXDA"). + + Returns: + Optional[VariableInfo]: The information of the register, if available. + """ + return self.register_map.get(name) + + def get_register_list(self) -> List[str]: + """Return a sorted list of all MCU peripheral register names found in the ELF. + + Returns: + List[str]: A sorted list of register names. + """ + return sorted(self.register_map.keys()) + + def get_var_info(self, name: str, sfr: bool = False) -> Optional[VariableInfo]: """Return the VariableInfo associated with a given variable name, or None if not found. Args: name (str): The name of the variable. + sfr (bool): Whether to retrieve a peripheral register (SFR) or a firmware variable. Returns: Optional[VariableInfo]: The information of the variable, if available. """ - return self.variable_map.get(name) + return self.register_map.get(name) if sfr else self.variable_map.get(name) def get_var_list(self) -> List[str]: """Return a list of all variable names available in the ELF file. @@ -91,6 +113,19 @@ def _map_variables(self) -> Dict[str, VariableInfo]: Dict[str, VariableInfo]: A dictionary of variable names to VariableInfo objects. """ + @abstractmethod + def _map_registers(self) -> Dict[str, VariableInfo]: + """Abstract method to map MCU peripheral registers from DWARF / symbol table. + + Implementations should populate ``self.register_map`` with entries for every + peripheral register (SFR) found, including their bitfield sub-entries where + available. The same ``VariableInfo`` dataclass is reused: ``bit_size`` and + ``bit_offset`` are non-zero for individual bit/bitfield members. + + Returns: + Dict[str, VariableInfo]: A dictionary of register names to VariableInfo objects. + """ + @abstractmethod def _close_elf_file(self): """Abstract method to close any open file connection after parsing is done.""" @@ -117,6 +152,9 @@ def _load_symbol_table(self): def _map_variables(self) -> Dict[str, VariableInfo]: return {} + def _map_registers(self) -> Dict[str, VariableInfo]: + return {} + def _close_elf_file(self): pass diff --git a/pyx2cscope/parser/generic_parser.py b/pyx2cscope/parser/generic_parser.py index d6478a98..6a3772ff 100644 --- a/pyx2cscope/parser/generic_parser.py +++ b/pyx2cscope/parser/generic_parser.py @@ -1,4 +1,4 @@ -"""This module provides functionalities for parsing ELF files compatible with 32-bit architectures. +"""This module provides functionalities for parsing ELF files. It focuses on extracting structure members and variable information from DWARF debugging information. """ @@ -27,6 +27,7 @@ def __init__(self, elf_path): self.die_variable = None self.var_name = None self.address = None + self.is_sfr = False # True when the current DIE is a peripheral register (DW_AT_external) def _load_elf_file(self): try: @@ -52,6 +53,7 @@ def _get_die_variable(self, die_struct): self.die_variable = None self.var_name = None self.address = None + self.is_sfr = False # In DIE structure, a variable to be considered valid, has under # its attributes the attribute DW_AT_specification or DW_AT_location @@ -64,14 +66,11 @@ def _get_die_variable(self, die_struct): self.die_variable = spec_die elif die_struct.attributes.get("DW_AT_location") and die_struct.attributes.get("DW_AT_name") is not None: self.die_variable = die_struct - # YA/EP We are not sure if we need to catch external variables. - # probably they are already being detected anywhere else as static or global - # variables, so this step may be avoided here. - # We let the code here in case we want to process them anyway. - # elif die_variable.attributes.get("DW_AT_external") and die_variable.attributes.get("DW_AT_name") is not None: - # self.var_name = die_variable.attributes.get("DW_AT_name").value.decode("utf-8") - # self.die_variable = die_variable - # self._extract_address(die_variable) + elif die_struct.attributes.get("DW_AT_external") and die_struct.attributes.get("DW_AT_name") is not None: + if die_struct.tag != "DW_TAG_variable": + return + self.die_variable = die_struct + self.is_sfr = True else: return @@ -79,25 +78,28 @@ def _get_die_variable(self, die_struct): self.address = self._extract_address(die_struct) def _process_die(self, die): - """Process a DIE structure containing the variable and its.""" + """Process a DIE structure containing the variable and its members. + + Firmware variables (no DW_AT_external) are stored in variable_map. + Peripheral registers / SFRs (DW_AT_external, address from symbol table) are + stored in register_map, including any bitfield sub-entries. + """ self._get_die_variable(die) if self.address is None: return members = {} self._process_end_die(members, self.die_variable, self.var_name, 0) - # Uncomment and use if base type processing is needed - # base_type_die = self._get_base_type_die(self.die_variable) - # self._process_end_die(members, base_type_die, self.var_name, 0) + target_map = self.register_map if self.is_sfr else self.variable_map for member_name, member_data in members.items(): - self.variable_map[member_name] = VariableInfo( - name = member_name, - byte_size = member_data["byte_size"], - bit_size = member_data["bit_size"], - bit_offset = member_data["bit_offset"], - type = member_data["type"], - address = self.address + member_data["address_offset"], + target_map[member_name] = VariableInfo( + name=member_name, + byte_size=member_data["byte_size"], + bit_size=member_data["bit_size"], + bit_offset=member_data["bit_offset"], + type=member_data["type"], + address=self.address + member_data["address_offset"], array_size=member_data["array_size"], valid_values=member_data["valid_values"], ) @@ -171,7 +173,7 @@ def _load_symbol_table(self): for section in self.elf_file.iter_sections(): if isinstance(section, SymbolTableSection): for symbol in section.iter_symbols(): - if symbol["st_info"].type == "STT_OBJECT": + if symbol["st_info"].type == "STT_OBJECT" or symbol["st_info"].bind == "STB_GLOBAL": self.symbol_table[symbol.name] = symbol["st_value"] def _fetch_address_from_symtab(self, variable_name): @@ -391,36 +393,46 @@ def _get_dwarf_die_by_offset(self, offset): return die return None + def _map_registers(self) -> dict[str, VariableInfo]: + """No-op: register_map is populated as part of _map_variables() in a single pass. + + Both firmware variables and peripheral registers (SFRs) are processed together + in ``_map_variables()``. The ``self.is_sfr`` flag set in ``_get_die_variable()`` + determines which map each entry is written to inside ``_process_die()``. + """ + return self.register_map + def _map_variables(self) -> dict[str, VariableInfo]: """Maps all variables in the ELF file.""" self.variable_map.clear() + self.register_map.clear() for cu in self.dwarf_info.iter_CUs(): for die in filter(lambda d: d.tag == "DW_TAG_variable", cu.iter_DIEs()): self.expression_parser = DWARFExprParser(die.cu.structs) self._process_die(die) - # #Update type _Bool to bool - # for var_info in self.variable_map.values(): - # if var_info.type == "_Bool": - # var_info.type = "bool" - return self.variable_map if __name__ == "__main__": - # logging.basicConfig(level=logging.DEBUG) - #elf_file = r"C:\Users\m67250\Downloads\pmsm (1)\mclv-48v-300w-an1292-dspic33ak512mc510_v1.0.0\pmsm.X\dist\default\production\pmsm.X.production.elf" - # elf_file = r"C:\Users\m67250\OneDrive - Microchip Technology Inc\Desktop\Training_Domel\motorbench_demo_domel.X\dist\default\production\motorbench_demo_domel.X.production.elf" - #elf_file = r"C:\Users\m67250\Downloads\mcapp_pmsm_zsmtlf(1)\mcapp_pmsm_zsmtlf\project\mcapp_pmsm.X\dist\default\production\mcapp_pmsm.X.production.elf" - elf_file = r"..\..\tests\data\qspin_foc_same54.elf" + + # elf_file = r"..\..\tests\data\qspin_foc_same54.elf" + elf_file = r"..\..\..\tests\data\dsPIC33ak128mc106_foc.elf" elf_reader = GenericParser(elf_file) variable_map = elf_reader._map_variables() + register_map = elf_reader._map_registers() print(variable_map) print(len(variable_map)) print("'''''''''''''''''''''''''''''''''''''''' ") - counter = 0 - + + print("Array variables:") for var_info in variable_map.values(): if var_info.array_size > 0: - print(var_info) \ No newline at end of file + print(var_info) + print("'''''''''''''''''''''''''''''''''''''''' ") + + print("register variables:") + print(register_map) + print(len(register_map)) + print("'''''''''''''''''''''''''''''''''''''''' ") diff --git a/pyx2cscope/utils.py b/pyx2cscope/utils.py index f9985257..a0b3302e 100644 --- a/pyx2cscope/utils.py +++ b/pyx2cscope/utils.py @@ -19,6 +19,7 @@ def get_config_file() -> ConfigParser: config_file = "config.ini" default_path = {"path": "path_to_your_elf_file"} default_com = {"com_port": "your_com_port, ex:COM3"} + default_host_ip = {"host_ip": "your_host_ip, ex:192.168.1.100"} config = ConfigParser() if os.path.exists(config_file): config.read(config_file) @@ -26,6 +27,7 @@ def get_config_file() -> ConfigParser: # Create the config file with the default value config["ELF_FILE"] = default_path config["COM_PORT"] = default_com + config["HOST_IP"] = default_host_ip with open(config_file, "w") as configfile: config.write(configfile) logging.debug(f"Config file '{config_file}' created with default values") @@ -60,3 +62,17 @@ def get_com_port(key="com_port") -> str: if not config["COM_PORT"][key] or "your" in config["COM_PORT"][key]: return "" return config["COM_PORT"][key] + +def get_host_address(key="host_ip") -> str: + """Gets the Host IP Address from the configuration. + + Args: + key (str): The key for the Host IP Address in the configuration. Default is "host_ip". + + Returns: + str: The IP address. + """ + config = get_config_file() + if not config["HOST_IP"][key] or "your" in config["HOST_IP"][key]: + return "" + return config["HOST_IP"][key] diff --git a/pyx2cscope/variable/variable_factory.py b/pyx2cscope/variable/variable_factory.py index 5a5f4936..1fa11699 100644 --- a/pyx2cscope/variable/variable_factory.py +++ b/pyx2cscope/variable/variable_factory.py @@ -176,17 +176,26 @@ def get_var_list(self) -> list[str]: """ return self.parser.get_var_list() - def get_variable(self, name: str) -> Variable | None: + def get_sfr_list(self) -> list[str]: + """Get a list of SFR (Special Function Register) names available in the ELF file. + + Returns: + list[str]: A list of SFR names. + """ + return self.parser.get_register_list() + + def get_variable(self, name: str, sfr: bool = False) -> Variable | None: """Retrieve a Variable object based on its name. Args: name (str): Name of the variable to retrieve. + sfr (bool): Whether to retrieve a peripheral register (SFR) or a firmware variable. Returns: Variable: The Variable object, if found. None otherwise. """ try: - variable_info = self.parser.get_var_info(name) + variable_info = self.parser.get_var_info(name, sfr=sfr) if variable_info is None: logging.error(f"Variable '{name}' not found!") return None diff --git a/pyx2cscope/x2cscope.py b/pyx2cscope/x2cscope.py index 83548f36..30f61101 100644 --- a/pyx2cscope/x2cscope.py +++ b/pyx2cscope/x2cscope.py @@ -62,7 +62,7 @@ class TriggerConfig: """ variable: Variable - trigger_level: int = 0 + trigger_level: float = 0 trigger_mode: int = 1 trigger_delay: int = 0 trigger_edge: int = 0 @@ -125,16 +125,25 @@ def list_variables(self) -> List[str]: """ return self.variable_factory.get_var_list() - def get_variable(self, name: str) -> Variable: + def list_sfr(self) -> List[str]: + """List all available SFR (Special Function Register) names. + + Returns: + List[str]: A list of SFR names. + """ + return self.variable_factory.get_sfr_list() + + def get_variable(self, name: str, sfr: bool = False) -> Variable: """Retrieve a variable by its name. Args: name (str): The name of the variable to retrieve. + sfr (bool): Whether to retrieve a peripheral register (SFR) or a firmware variable. Returns: Variable: The requested variable object. """ - return self.variable_factory.get_variable(name) + return self.variable_factory.get_variable(name, sfr=sfr) def get_variable_raw(self, variable_info: VariableInfo) -> Variable: """Retrieve a variable by its definition encapsulated by VariableInfo Dataclass. @@ -281,7 +290,6 @@ def is_scope_data_ready(self) -> bool: bool: True if the scope data is ready, False otherwise. """ scope_data = self.lnet.load_parameters() - logging.debug(scope_data) return ( scope_data.scope_state == 0 or scope_data.data_array_pointer == scope_data.data_array_used_length @@ -327,21 +335,19 @@ def _read_array_chunks(self) -> List[bytearray]: """ chunk_data = [] data_type = 1 # It will always be 1 for array data - chunk_size = ( - 253 # Full chunk excluding CRC and Service-ID, total bytes 255 (0xFF) - ) + chunk_size = 253 # Full chunk excluding Service-ID and Error-ID, total bytes 255 (0xFF) num_chunks = self._calc_sda_used_length() // chunk_size chunk_rest = self._calc_sda_used_length() % chunk_size loop = num_chunks if chunk_rest == 0 else num_chunks + 1 for i in range(int(loop)): current_address = self.lnet.scope_data.data_array_address + i * chunk_size + data_size = chunk_size if i < int(num_chunks) else int(chunk_rest) try: - # Read the chunk of data - data_size = chunk_size if i < int(num_chunks) else int(chunk_rest) data = self.lnet.get_ram_array(current_address, data_size, data_type) chunk_data.extend(data) except Exception as e: logging.error(f"Error reading chunk {i}: {str(e)}") + return chunk_data def read_array(self, data_type: int) -> List[bytearray]: diff --git a/quality.txt b/quality.txt index ea46981c..16b9c030 100644 Binary files a/quality.txt and b/quality.txt differ diff --git a/requirements.txt b/requirements.txt index d48fa07a..457a8cc0 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/scripts/build.py b/scripts/build.py index 764e26e6..86ca19a9 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -84,12 +84,17 @@ def create_zip_archive(source_dir, version): if os.path.exists(zip_path): os.remove(zip_path) - # Create zip file. + # Create zip file while preserving directory structure with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: - for root, _dirs, files in os.walk(source_dir): + # Get the parent directory of source_dir to maintain relative paths + base_dir = os.path.dirname(source_dir) + for root, dirs, files in os.walk(source_dir): + # Calculate the relative path from the base directory + rel_path = os.path.relpath(root, base_dir) for file in files: file_path = os.path.join(root, file) - arcname = os.path.join("pyX2Cscope", os.path.basename(file)) + # Create the path inside the zip file + arcname = os.path.join(rel_path, file) zipf.write(file_path, arcname) print(f"Created archive: {zip_path}") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..23328cbc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,192 @@ +"""Shared pytest fixtures for all tests. + +This module provides common fixtures for CLI, Qt GUI, and Web GUI testing. +""" + +import os + +import pytest + +from tests import data +from tests.utils.serial_stub import SerialStub, fake_serial + +# Test data paths +TEST_DATA_DIR = os.path.dirname(data.__file__) +TEST_ELF_FILE = os.path.join(TEST_DATA_DIR, "mc_foc_sl_fip_dspic33ck_mclv48v300w.elf") +TEST_ELF_FILE_32BIT = os.path.join(TEST_DATA_DIR, "qspin_foc_same54.elf") + + +@pytest.fixture +def elf_file_path(): + """Provide path to test ELF file (16-bit).""" + return TEST_ELF_FILE + + +@pytest.fixture +def elf_file_path_32bit(): + """Provide path to test ELF file (32-bit).""" + return TEST_ELF_FILE_32BIT + + +@pytest.fixture +def mock_serial_16bit(mocker): + """Mock serial connection for 16-bit device.""" + fake_serial(mocker, 16) + + +@pytest.fixture +def mock_serial_32bit(mocker): + """Mock serial connection for 32-bit device.""" + fake_serial(mocker, 32) + + +@pytest.fixture +def serial_stub_16bit(): + """Create a SerialStub instance for 16-bit device.""" + return SerialStub(uc_width=16) + + +@pytest.fixture +def serial_stub_32bit(): + """Create a SerialStub instance for 32-bit device.""" + return SerialStub(uc_width=32) + + +# ============================================================================ +# Flask/Web GUI Fixtures +# ============================================================================ + +@pytest.fixture +def flask_app(): + """Create Flask test application.""" + from pyx2cscope.gui.web.app import create_app + + app = create_app() + app.config["TESTING"] = True + app.config["WTF_CSRF_ENABLED"] = False + return app + + +@pytest.fixture +def flask_client(flask_app): + """Create Flask test client.""" + return flask_app.test_client() + + +@pytest.fixture +def flask_app_context(flask_app): + """Create Flask application context.""" + with flask_app.app_context(): + yield flask_app + + +@pytest.fixture +def web_scope_instance(): + """Create a fresh WebScope instance for testing.""" + from pyx2cscope.gui.web.scope import WebScope + + return WebScope() + + +# ============================================================================ +# Qt GUI Fixtures (requires pytest-qt) +# ============================================================================ + +@pytest.fixture +def qt_app(request): + """Create QApplication instance for Qt testing. + + This fixture handles the QApplication lifecycle and ensures + only one instance exists during testing. + """ + # Skip if PyQt5 not available or running headless without display + pytest.importorskip("PyQt5") + + from PyQt5.QtWidgets import QApplication + + # Check if QApplication already exists + app = QApplication.instance() + if app is None: + # Set offscreen platform for headless testing + if "QT_QPA_PLATFORM" not in os.environ: + os.environ["QT_QPA_PLATFORM"] = "offscreen" + app = QApplication([]) + + yield app + + # Cleanup is handled by pytest-qt or the test itself + + +@pytest.fixture +def mock_x2cscope(mocker): + """Create a mock X2CScope instance.""" + from unittest.mock import MagicMock + + mock = MagicMock() + mock.is_connected.return_value = False + mock.get_variable_list.return_value = [] + mock.get_device_info.return_value = { + "processor_id": "test", + "uc_width": 16, + "date": "2024-01-01", + "time": "12:00:00", + "app_version": "1.0.0", + } + return mock + + +# ============================================================================ +# CLI Fixtures +# ============================================================================ + +@pytest.fixture +def cli_args_default(): + """Default CLI arguments (Qt mode).""" + return [] + + +@pytest.fixture +def cli_args_web(): + """CLI arguments for web mode.""" + return ["-w"] + + +@pytest.fixture +def cli_args_web_with_port(): + """CLI arguments for web mode with custom port.""" + return ["-w", "-wp", "8080", "--host", "0.0.0.0"] + + +@pytest.fixture +def cli_args_with_elf(elf_file_path): + """CLI arguments with ELF file path.""" + return ["-e", elf_file_path, "-p", "COM1"] + + +# ============================================================================ +# Environment Fixtures +# ============================================================================ + +@pytest.fixture +def headless_env(monkeypatch): + """Set up headless environment for GUI testing.""" + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + # Disable GUI elements that require display + monkeypatch.setenv("DISPLAY", "") + + +@pytest.fixture(autouse=True) +def reset_web_scope(): + """Reset WebScope singleton state between tests.""" + yield + # Clean up web_scope state after each test + try: + from pyx2cscope.gui.web.scope import web_scope + + web_scope.clear_watch_var() + web_scope.clear_scope_var() + web_scope.dashboard_vars.clear() + if web_scope.is_connected(): + web_scope.disconnect() + except Exception: + pass # Ignore if web module not loaded diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..468e4e51 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,316 @@ +"""Unit tests for CLI argument parsing and execution modes. + +Tests cover: +- Argument parsing via subprocess (end-to-end) +- Version flag +- Qt mode (default) +- Web mode (-w flag) +- Logging configuration +- Execution mode selection +""" + +import argparse +import subprocess +import sys + +import pytest + +import pyx2cscope + + +def _create_argument_parser(): + """Create a fresh argument parser matching __main__.py for testing. + + This avoids importing __main__ which executes module-level code. + """ + parser = argparse.ArgumentParser( + prog="pyX2Cscope", + description="Microchip python implementation of X2Cscope and LNet protocol.", + epilog="For documentation visit https://x2cscope.github.io/pyx2cscope/.", + ) + + parser.add_argument( + "-l", + "--log-level", + default="ERROR", + type=str, + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Configure the logging level, INFO is the default value.", + ) + parser.add_argument( + "-c", "--log-console", action="store_true", help="Output log to the console." + ) + parser.add_argument("-e", "--elf", help="Path to elf-file, i.e. -e my_elf.elf.") + parser.add_argument( + "-p", "--port", help="The serial COM port to be used. Use together with -e" + ) + parser.add_argument( + "-q", + "--qt", + action="store_false", + help="Start the Qt user interface, pyx2cscope.gui.qt.main_window.MainWindow", + ) + parser.add_argument( + "-w", + "--web", + action="store_true", + help="Start the Web user interface, pyx2cscope.gui.web.app.", + ) + parser.add_argument( + "-wp", + "--web-port", + type=int, + default="5000", + help="Configure the Web Server port. Use together with -w", + ) + parser.add_argument( + "--host", + type=str, + default="localhost", + help="Configure the Web Server address. Use together with -w", + ) + parser.add_argument( + "-v", + "--version", + action="version", + version="%(prog)s " + pyx2cscope.__version__, + ) + + return parser + + +class TestCLIArgumentParsing: + """Tests for CLI argument parsing.""" + + def test_parse_default_arguments(self): + """Test default argument values.""" + parser = _create_argument_parser() + args, unknown = parser.parse_known_args([]) + + assert args.log_level == "ERROR" + assert args.log_console is False + assert args.elf is None + assert args.port is None + assert args.qt is True # Default (store_false means True when not provided) + assert args.web is False + assert args.web_port == 5000 # noqa: PLR2004 + assert args.host == "localhost" + + def test_parse_web_mode_arguments(self): + """Test -w flag enables web mode.""" + parser = _create_argument_parser() + args, unknown = parser.parse_known_args(["-w"]) + + assert args.web is True + assert args.qt is True # -q not provided + + def test_parse_web_mode_with_port(self): + """Test web mode with custom port.""" + parser = _create_argument_parser() + args, unknown = parser.parse_known_args(["-w", "-wp", "8080"]) + + assert args.web is True + assert args.web_port == 8080 # noqa: PLR2004 + + def test_parse_web_mode_with_host(self): + """Test web mode with custom host.""" + parser = _create_argument_parser() + args, unknown = parser.parse_known_args(["-w", "--host", "0.0.0.0"]) + + assert args.web is True + assert args.host == "0.0.0.0" + + def test_parse_elf_and_port(self, elf_file_path): + """Test ELF file and port arguments.""" + parser = _create_argument_parser() + args, unknown = parser.parse_known_args(["-e", elf_file_path, "-p", "COM3"]) + + assert args.elf == elf_file_path + assert args.port == "COM3" + + def test_parse_log_level_debug(self): + """Test log level argument.""" + parser = _create_argument_parser() + args, unknown = parser.parse_known_args(["-l", "DEBUG"]) + + assert args.log_level == "DEBUG" + + def test_parse_log_level_info(self): + """Test log level INFO.""" + parser = _create_argument_parser() + args, unknown = parser.parse_known_args(["-l", "INFO"]) + + assert args.log_level == "INFO" + + def test_parse_log_console(self): + """Test log console flag.""" + parser = _create_argument_parser() + args, unknown = parser.parse_known_args(["-c"]) + + assert args.log_console is True + + def test_parse_qt_disabled(self): + """Test -q flag disables Qt mode.""" + parser = _create_argument_parser() + args, unknown = parser.parse_known_args(["-q"]) + + assert args.qt is False + + def test_parse_unknown_args_passed_through(self): + """Test that unknown arguments are passed through.""" + parser = _create_argument_parser() + args, unknown = parser.parse_known_args(["--unknown-arg", "value"]) + + assert "--unknown-arg" in unknown + assert "value" in unknown + + +class TestCLIVersionFlag: + """Tests for version flag via subprocess.""" + + def test_version_flag_short(self): + """Test -v flag shows version.""" + result = subprocess.run( + [sys.executable, "-m", "pyx2cscope", "-v"], + capture_output=True, + text=True, + timeout=10, check=False, + ) + + assert result.returncode == 0 + assert pyx2cscope.__version__ in result.stdout + + def test_version_flag_long(self): + """Test --version flag shows version.""" + result = subprocess.run( + [sys.executable, "-m", "pyx2cscope", "--version"], + capture_output=True, + text=True, + timeout=10, check=False, + ) + + assert result.returncode == 0 + assert pyx2cscope.__version__ in result.stdout + + +class TestCLIHelpFlag: + """Tests for help flag via subprocess.""" + + def test_help_flag_short(self): + """Test -h flag shows help.""" + result = subprocess.run( + [sys.executable, "-m", "pyx2cscope", "-h"], + capture_output=True, + text=True, + timeout=10, check=False, + ) + + assert result.returncode == 0 + assert "pyX2Cscope" in result.stdout + assert "--web" in result.stdout + assert "--elf" in result.stdout + + def test_help_flag_long(self): + """Test --help flag shows help.""" + result = subprocess.run( + [sys.executable, "-m", "pyx2cscope", "--help"], + capture_output=True, + text=True, + timeout=10, check=False, + ) + + assert result.returncode == 0 + assert "pyX2Cscope" in result.stdout + + +class TestCLIExecutionModes: + """Tests for CLI execution mode selection.""" + + def test_qt_mode_called_by_default(self, mocker): + """Test Qt GUI is launched by default.""" + mock_execute_qt = mocker.patch("pyx2cscope.gui.execute_qt") + mock_execute_web = mocker.patch("pyx2cscope.gui.execute_web") + + args = argparse.Namespace( + qt=True, web=False, log_level="ERROR", log_console=False + ) + unknown_args = [] + + # Simulate the execution logic from __main__ + if args.qt and not args.web: + from pyx2cscope import gui + + gui.execute_qt(unknown_args, **args.__dict__) + + mock_execute_qt.assert_called_once() + mock_execute_web.assert_not_called() + + def test_web_mode_called_with_w_flag(self, mocker): + """Test Web GUI is launched with -w flag.""" + _mock_execute_qt = mocker.patch("pyx2cscope.gui.execute_qt") + mock_execute_web = mocker.patch("pyx2cscope.gui.execute_web") + + args = argparse.Namespace( + qt=True, web=True, log_level="ERROR", log_console=False + ) + + # Simulate the execution logic from __main__ + if args.web: + from pyx2cscope import gui + + gui.execute_web(**args.__dict__) + + mock_execute_web.assert_called_once() + + def test_no_gui_when_both_disabled(self, mocker): + """Test no GUI is launched when both are disabled.""" + mock_execute_qt = mocker.patch("pyx2cscope.gui.execute_qt") + mock_execute_web = mocker.patch("pyx2cscope.gui.execute_web") + + args = argparse.Namespace( + qt=False, web=False, log_level="ERROR", log_console=False + ) + unknown_args = [] + + # Simulate the execution logic from __main__ + if args.qt and not args.web: + from pyx2cscope import gui + + gui.execute_qt(unknown_args, **args.__dict__) + + if args.web: + from pyx2cscope import gui + + gui.execute_web(**args.__dict__) + + mock_execute_qt.assert_not_called() + mock_execute_web.assert_not_called() + + +class TestCLILogging: + """Tests for CLI logging configuration.""" + + def test_set_logger_called_with_level(self, mocker): + """Test that set_logger is called with correct level.""" + mock_set_logger = mocker.patch("pyx2cscope.set_logger") + + # Simulate logger setup + pyx2cscope.set_logger(level="DEBUG", console=True) + + mock_set_logger.assert_called_once_with(level="DEBUG", console=True) + + def test_log_levels_valid(self): + """Test all valid log levels are accepted.""" + valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + parser = _create_argument_parser() + + for level in valid_levels: + args, _ = parser.parse_known_args(["-l", level]) + assert args.log_level == level + + def test_invalid_log_level_rejected(self): + """Test invalid log level is rejected.""" + parser = _create_argument_parser() + + with pytest.raises(SystemExit): + parser.parse_args(["-l", "INVALID"]) diff --git a/tests/test_pyx2cscope_class.py b/tests/test_pyx2cscope_class.py index 8ca2b881..4615fd97 100644 --- a/tests/test_pyx2cscope_class.py +++ b/tests/test_pyx2cscope_class.py @@ -29,6 +29,27 @@ def test_missing_elf_file_32(self, mocker): with pytest.raises(Exception, match=r"Error loading ELF file"): X2CScope(elf_file="wrong_elf_file.elf", port="COM0") + def test_missing_interface(self, mocker): + """Check class behavior in case of non define COMM interface. + + In case a COMM interface is not defined, a Serial Interface is used as default. + """ + fake_serial(mocker, 16) + with warnings.catch_warnings(record=True) as w: + # Cause all warnings to always be triggered + warnings.simplefilter("always") + + # This should now generate a warning instead of raising an exception + scope = X2CScope(elf_file=self.elf_file) + + # Verify the warning was raised + assert len(w) == 2 # noqa: PLR2004 + assert issubclass(w[-1].category, Warning) is True + assert "No interface select, setting Serial as default." in str(w[0].message) + + # Clean up + scope.disconnect() + def test_missing_com_port(self, mocker): """Check class behavior in case of non COM-Port initialization. @@ -44,7 +65,7 @@ def test_missing_com_port(self, mocker): scope = X2CScope(elf_file=self.elf_file) # Verify the warning was raised - assert len(w) == 1 + assert len(w) == 2 # noqa: PLR2004 assert issubclass(w[-1].category, Warning) is True assert "No port provided, using default COM1" in str(w[-1].message) diff --git a/tests/test_qt_gui.py b/tests/test_qt_gui.py new file mode 100644 index 00000000..e2682761 --- /dev/null +++ b/tests/test_qt_gui.py @@ -0,0 +1,430 @@ +"""Unit tests for Qt GUI components. + +Tests run headless using QT_QPA_PLATFORM=offscreen. +Tests cover: +- AppState model (state management, thread safety) +- ConnectionManager (port enumeration, connection handling) +- ConfigManager (save/load configuration) +- Widget creation and initialization +- Signal/slot connections +""" + +import os +from unittest.mock import MagicMock + +import pytest + +# Set headless mode before importing Qt modules +os.environ["QT_QPA_PLATFORM"] = "offscreen" + + +class TestAppStateModel: + """Tests for AppState model.""" + + @pytest.fixture + def app_state(self): + """Create AppState instance for testing.""" + from pyx2cscope.gui.qt.models.app_state import AppState + + return AppState() + + def test_initial_state(self, app_state): + """Test AppState initializes with correct defaults.""" + assert app_state.is_connected() is False + assert app_state.get_variable_list() == [] + assert len(app_state._watch_vars) == app_state.MAX_WATCH_VARS + assert len(app_state._scope_channels) == app_state.MAX_SCOPE_CHANNELS + + def test_connection_signal_emitted(self, app_state, qtbot): + """Test connection_changed signal is emitted.""" + with qtbot.waitSignal(app_state.connection_changed, timeout=1000): + app_state.connection_changed.emit(True) + + def test_watch_variable_defaults(self, app_state): + """Test watch variables have correct defaults.""" + for watch_var in app_state._watch_vars: + assert watch_var.name == "" + assert watch_var.value == 0.0 + assert watch_var.scaling == 1.0 + assert watch_var.offset == 0.0 + assert watch_var.live is False + + def test_scope_channel_defaults(self, app_state): + """Test scope channels have correct defaults.""" + for channel in app_state._scope_channels: + assert channel.name == "" + assert channel.trigger is False + assert channel.gain == 1.0 + assert channel.visible is True + + def test_set_watch_variable(self, app_state): + """Test setting watch variable properties.""" + from pyx2cscope.gui.qt.models.app_state import WatchVariable + + watch_var = WatchVariable(name="test_var", value=10.0) + app_state.set_watch_var(0, watch_var) + retrieved = app_state.get_watch_var(0) + + assert retrieved.name == "test_var" + assert retrieved.value == 10.0 # noqa: PLR2004 + + def test_set_watch_variable_bounds(self, app_state): + """Test watch variable index bounds checking.""" + from pyx2cscope.gui.qt.models.app_state import WatchVariable + + # Should not raise for valid indices + app_state.set_watch_var(0, WatchVariable(name="var0")) + app_state.set_watch_var(4, WatchVariable(name="var4")) + + # Get should return empty WatchVariable for invalid index + result = app_state.get_watch_var(999) + assert result.name == "" # Empty default + + def test_set_scope_channel(self, app_state): + """Test setting scope channel properties.""" + from pyx2cscope.gui.qt.models.app_state import ScopeChannel + + channel = ScopeChannel(name="channel_var") + app_state.set_scope_channel(0, channel) + retrieved = app_state.get_scope_channel(0) + + assert retrieved.name == "channel_var" + + def test_trigger_settings_defaults(self, app_state): + """Test trigger settings have correct defaults.""" + trigger = app_state.get_trigger_settings() + + assert trigger.mode == "Auto" + assert trigger.edge == "Rising" + assert trigger.level == 0.0 + assert trigger.delay == 0 + + def test_device_info_defaults(self, app_state): + """Test device info has correct defaults.""" + info = app_state.get_device_info() + + assert info.processor_id == "" + assert info.uc_width == "" + + def test_clear_state_on_disconnect(self, app_state): + """Test state is properly cleared on disconnect.""" + from pyx2cscope.gui.qt.models.app_state import WatchVariable + + # Set some state + app_state.set_watch_var(0, WatchVariable(name="test", value=100.0)) + + # Setting x2cscope to None clears connection + app_state.set_x2cscope(None) + + assert app_state.is_connected() is False + + +class TestWatchVariableDataclass: + """Tests for WatchVariable dataclass.""" + + def test_scaled_value_calculation(self): + """Test scaled value is calculated correctly.""" + from pyx2cscope.gui.qt.models.app_state import WatchVariable + + watch_var = WatchVariable( + name="test", value=10.0, scaling=2.0, offset=5.0 + ) + + # scaled_value = (value * scaling) + offset = (10 * 2) + 5 = 25 + assert watch_var.scaled_value == 25.0 # noqa: PLR2004 + + def test_scaled_value_with_defaults(self): + """Test scaled value with default scaling and offset.""" + from pyx2cscope.gui.qt.models.app_state import WatchVariable + + watch_var = WatchVariable(name="test", value=10.0) + + # With default scaling=1.0 and offset=0.0 + assert watch_var.scaled_value == 10.0 # noqa: PLR2004 + + def test_var_ref_property(self): + """Test var_ref property get/set.""" + from pyx2cscope.gui.qt.models.app_state import WatchVariable + + watch_var = WatchVariable(name="test") + mock_ref = MagicMock() + + watch_var.var_ref = mock_ref + assert watch_var.var_ref is mock_ref + + +class TestScopeChannelDataclass: + """Tests for ScopeChannel dataclass.""" + + def test_default_values(self): + """Test ScopeChannel default values.""" + from pyx2cscope.gui.qt.models.app_state import ScopeChannel + + channel = ScopeChannel() + + assert channel.name == "" + assert channel.trigger is False + assert channel.gain == 1.0 + assert channel.visible is True + + def test_custom_values(self): + """Test ScopeChannel with custom values.""" + from pyx2cscope.gui.qt.models.app_state import ScopeChannel + + channel = ScopeChannel( + name="test_channel", trigger=True, gain=2.5, visible=False + ) + + assert channel.name == "test_channel" + assert channel.trigger is True + assert channel.gain == 2.5 # noqa: PLR2004 + assert channel.visible is False + + +class TestTriggerSettingsDataclass: + """Tests for TriggerSettings dataclass.""" + + def test_default_values(self): + """Test TriggerSettings default values.""" + from pyx2cscope.gui.qt.models.app_state import TriggerSettings + + trigger = TriggerSettings() + + assert trigger.mode == "Auto" + assert trigger.edge == "Rising" + assert trigger.level == 0.0 + assert trigger.delay == 0 + assert trigger.variable is None + + +class TestConnectionManager: + """Tests for ConnectionManager.""" + + @pytest.fixture + def connection_manager(self): + """Create ConnectionManager instance.""" + from pyx2cscope.gui.qt.controllers.connection_manager import ( + ConnectionManager, + ) + from pyx2cscope.gui.qt.models.app_state import AppState + + app_state = AppState() + return ConnectionManager(app_state) + + def test_initial_state(self, connection_manager): + """Test ConnectionManager initializes correctly.""" + assert connection_manager is not None + + def test_refresh_ports(self, connection_manager, mocker): + """Test port refresh returns list of ports.""" + # Mock serial.tools.list_ports + mock_port = MagicMock() + mock_port.device = "COM1" + mock_port.description = "Test Port" + + mocker.patch( + "serial.tools.list_ports.comports", return_value=[mock_port] + ) + + ports = connection_manager.refresh_ports() + + assert len(ports) >= 0 # May be empty on some systems + + def test_connection_manager_has_app_state(self, connection_manager): + """Test ConnectionManager has access to AppState.""" + assert connection_manager._app_state is not None + + +class TestConnectionManagerConnections: + """Tests for ConnectionManager connection methods.""" + + @pytest.fixture + def connection_manager(self): + """Create ConnectionManager instance.""" + from pyx2cscope.gui.qt.controllers.connection_manager import ( + ConnectionManager, + ) + from pyx2cscope.gui.qt.models.app_state import AppState + + app_state = AppState() + return ConnectionManager(app_state) + + def test_connect_uart_creates_x2cscope( + self, connection_manager, elf_file_path, mocker, mock_serial_16bit + ): + """Test UART connection creates X2CScope instance.""" + result = connection_manager.connect_uart( + port="COM1", baud_rate=115200, elf_file=elf_file_path + ) + + assert result is True + + def test_disconnect_clears_state(self, connection_manager): + """Test disconnect clears connection state.""" + connection_manager.disconnect() + + assert connection_manager._app_state.is_connected() is False + + +class TestConfigManager: + """Tests for ConfigManager.""" + + @pytest.fixture + def config_manager(self): + """Create ConfigManager instance.""" + from pyx2cscope.gui.qt.controllers.config_manager import ConfigManager + + return ConfigManager() + + def test_config_manager_creation(self, config_manager): + """Test ConfigManager can be created.""" + assert config_manager is not None + + def test_config_manager_has_signals(self, config_manager): + """Test ConfigManager has required signals.""" + assert hasattr(config_manager, "config_loaded") + assert hasattr(config_manager, "config_saved") + assert hasattr(config_manager, "error_occurred") + + +class TestQtWidgetCreation: + """Tests for Qt widget creation (headless).""" + + @pytest.fixture + def qt_application(self): + """Create QApplication for widget testing.""" + from PyQt5.QtWidgets import QApplication + + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + def test_main_window_creation(self, qt_application, mocker): + """Test MainWindow can be created.""" + # Mock X2CScope to prevent real connection attempts + mocker.patch("pyx2cscope.x2cscope.X2CScope") + + from pyx2cscope.gui.qt.main_window import MainWindow + + window = MainWindow() + + assert window is not None + window.close() + + def test_setup_tab_creation(self, qt_application): + """Test SetupTab can be created.""" + from pyx2cscope.gui.qt.models.app_state import AppState + from pyx2cscope.gui.qt.tabs.setup_tab import SetupTab + + app_state = AppState() + tab = SetupTab(app_state) + + assert tab is not None + + def test_scope_view_tab_creation(self, qt_application): + """Test ScopeViewTab can be created.""" + from pyx2cscope.gui.qt.models.app_state import AppState + from pyx2cscope.gui.qt.tabs.scope_view_tab import ScopeViewTab + + app_state = AppState() + tab = ScopeViewTab(app_state) + + assert tab is not None + + def test_watch_view_tab_creation(self, qt_application): + """Test WatchViewTab can be created.""" + from pyx2cscope.gui.qt.models.app_state import AppState + from pyx2cscope.gui.qt.tabs.watch_view_tab import WatchViewTab + + app_state = AppState() + tab = WatchViewTab(app_state) + + assert tab is not None + + def test_scripting_tab_creation(self, qt_application): + """Test ScriptingTab can be created.""" + from pyx2cscope.gui.qt.models.app_state import AppState + from pyx2cscope.gui.qt.tabs.scripting_tab import ScriptingTab + + app_state = AppState() + tab = ScriptingTab(app_state) + + assert tab is not None + + +class TestDataPoller: + """Tests for DataPoller worker thread.""" + + @pytest.fixture + def data_poller(self): + """Create DataPoller instance.""" + from pyx2cscope.gui.qt.models.app_state import AppState + from pyx2cscope.gui.qt.workers.data_poller import DataPoller + + app_state = AppState() + return DataPoller(app_state) + + def test_data_poller_creation(self, data_poller): + """Test DataPoller can be created.""" + assert data_poller is not None + + def test_data_poller_initial_state(self, data_poller): + """Test DataPoller has correct initial state.""" + assert data_poller._running is False + + def test_set_polling_enabled(self, data_poller): + """Test polling can be enabled/disabled.""" + data_poller.set_watch_polling_enabled(True) + data_poller.set_scope_polling_enabled(True) + + # Verify state (actual attribute names may vary) + assert data_poller._watch_polling_enabled is True + assert data_poller._scope_polling_enabled is True + + def test_stop_polling(self, data_poller): + """Test polling can be stopped.""" + data_poller.stop() + assert data_poller._running is False + + +class TestSignalSlotConnections: + """Tests for signal/slot connections.""" + + @pytest.fixture + def qt_application(self): + """Create QApplication for signal testing.""" + from PyQt5.QtWidgets import QApplication + + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + def test_app_state_signals_exist(self): + """Test AppState has required signals.""" + from pyx2cscope.gui.qt.models.app_state import AppState + + app_state = AppState() + + # Check signals exist + assert hasattr(app_state, "connection_changed") + assert hasattr(app_state, "device_info_updated") + assert hasattr(app_state, "variable_list_updated") + + def test_connection_changed_signal_callback(self, qt_application, qtbot): + """Test connection_changed signal can be connected.""" + from pyx2cscope.gui.qt.models.app_state import AppState + + app_state = AppState() + callback_called = [] + + def callback(connected): + callback_called.append(connected) + + app_state.connection_changed.connect(callback) + + with qtbot.waitSignal(app_state.connection_changed, timeout=1000): + app_state.connection_changed.emit(True) + + assert True in callback_called diff --git a/tests/test_web_gui.py b/tests/test_web_gui.py new file mode 100644 index 00000000..b0e68632 --- /dev/null +++ b/tests/test_web_gui.py @@ -0,0 +1,518 @@ +"""Unit tests for Web GUI components. + +Tests cover: +- Flask routes (REST endpoints) +- WebScope state management +- Socket.IO handlers (mocked) +- Variable management +- Configuration save/load +""" + +import json +from unittest.mock import MagicMock + +import pytest + +# HTTP status codes for test assertions +HTTP_OK = 200 + + +class TestFlaskAppCreation: + """Tests for Flask application creation.""" + + def test_create_app_returns_flask_app(self): + """Test create_app returns a Flask application.""" + from pyx2cscope.gui.web.app import create_app + + app = create_app() + + assert app is not None + assert app.name == "pyx2cscope.gui.web.app" + + def test_app_has_required_routes(self, flask_app): + """Test app has all required routes registered.""" + rules = [rule.rule for rule in flask_app.url_map.iter_rules()] + + # Main routes + assert "/" in rules + assert "/serial-ports" in rules + assert "/connect" in rules + assert "/disconnect" in rules + assert "/is-connected" in rules + assert "/variables" in rules + + def test_app_testing_mode(self, flask_app): + """Test app is in testing mode.""" + assert flask_app.config["TESTING"] is True + + +class TestFlaskRoutes: + """Tests for Flask REST endpoints.""" + + def test_index_route(self, flask_client): + """Test index route returns HTML.""" + response = flask_client.get("/") + + assert response.status_code == HTTP_OK + assert b"html" in response.data.lower() + + def test_serial_ports_route(self, flask_client, mocker): + """Test serial-ports route returns list.""" + # Mock serial port enumeration + mock_port = MagicMock() + mock_port.device = "COM1" + mock_port.description = "Test Port" + mocker.patch( + "serial.tools.list_ports.comports", return_value=[mock_port] + ) + + response = flask_client.get("/serial-ports") + + assert response.status_code == HTTP_OK + data = json.loads(response.data) + assert isinstance(data, list) + + def test_is_connected_route_disconnected(self, flask_client): + """Test is-connected route returns False when disconnected.""" + response = flask_client.get("/is-connected") + + assert response.status_code == HTTP_OK + data = json.loads(response.data) + # Response might be {"status": False} or {"connected": False} + assert data.get("status", data.get("connected", None)) is False + + def test_disconnect_route(self, flask_client, mocker): + """Test disconnect route.""" + # Mock the web_scope to avoid AttributeError when not connected + from pyx2cscope.gui.web.scope import web_scope + + mocker.patch.object(web_scope, "disconnect", return_value=None) + + response = flask_client.get("/disconnect") + assert response.status_code == HTTP_OK + + def test_variables_route_not_connected(self, flask_client): + """Test variables route when not connected.""" + response = flask_client.get("/variables?q=test") + + assert response.status_code == HTTP_OK + data = json.loads(response.data) + assert "items" in data + assert data["items"] == [] + + +class TestWatchViewRoutes: + """Tests for watch view routes.""" + + def test_watch_data_route(self, flask_client): + """Test watch data route returns JSON.""" + response = flask_client.get("/watch/data") + + assert response.status_code == HTTP_OK + data = json.loads(response.data) + assert "data" in data + + def test_watch_view_route(self, flask_client): + """Test watch view page route.""" + # Use trailing slash or follow redirects + response = flask_client.get("/watch/", follow_redirects=True) + + assert response.status_code == HTTP_OK + + +class TestScopeViewRoutes: + """Tests for scope view routes.""" + + def test_scope_data_route(self, flask_client): + """Test scope data route returns JSON.""" + response = flask_client.get("/scope/data") + + assert response.status_code == HTTP_OK + data = json.loads(response.data) + assert "data" in data + + def test_scope_view_route(self, flask_client): + """Test scope view page route.""" + # Use trailing slash or follow redirects + response = flask_client.get("/scope/", follow_redirects=True) + + assert response.status_code == HTTP_OK + + def test_scope_export_no_data(self, flask_client, mocker): + """Test scope export with no data.""" + # Mock to avoid AttributeError when not connected + from pyx2cscope.gui.web.scope import web_scope + + # get_scope_datasets returns a list of dicts with 'data' and 'label' keys + mocker.patch.object(web_scope, "get_scope_datasets", return_value=[]) + mocker.patch.object( + web_scope, "get_scope_chart_label", return_value=[0.0] * 100 + ) + + response = flask_client.get("/scope/export") + + # Should return CSV even if empty + assert response.status_code == HTTP_OK + + +class TestDashboardRoutes: + """Tests for dashboard routes.""" + + def test_dashboard_data_route(self, flask_client): + """Test dashboard data route.""" + response = flask_client.get("/dashboard/data") + + assert response.status_code == HTTP_OK + data = json.loads(response.data) + assert isinstance(data, dict) + + def test_dashboard_load_layout_no_file(self, flask_client): + """Test load layout when no file exists.""" + response = flask_client.get("/dashboard/load-layout") + + assert response.status_code == HTTP_OK + data = json.loads(response.data) + # Should return empty or default layout + assert isinstance(data, (dict, list)) + + +class TestScriptViewRoutes: + """Tests for script view routes.""" + + def test_script_help_route(self, flask_client): + """Test script help route returns markdown.""" + response = flask_client.get("/scripting/help") + + assert response.status_code == HTTP_OK + data = json.loads(response.data) + assert "markdown" in data + + +class TestWebScopeClass: + """Tests for WebScope state management.""" + + @pytest.fixture + def web_scope(self): + """Create fresh WebScope instance.""" + from pyx2cscope.gui.web.scope import WebScope + + return WebScope() + + def test_initial_state(self, web_scope): + """Test WebScope initializes with correct defaults.""" + assert web_scope.watch_vars == [] + assert web_scope.scope_vars == [] + assert web_scope.dashboard_vars == {} + assert web_scope.x2c_scope is None + assert web_scope.watch_rate == 1 + + def test_is_connected_false_initially(self, web_scope): + """Test is_connected returns False initially.""" + assert web_scope.is_connected() is False + + def test_set_watch_rate_valid(self, web_scope): + """Test setting valid watch rate.""" + web_scope.set_watch_rate(2.5) + assert web_scope.watch_rate == 2.5 # noqa: PLR2004 + + def test_set_watch_rate_invalid_too_high(self, web_scope): + """Test setting watch rate above max is ignored.""" + original_rate = web_scope.watch_rate + web_scope.set_watch_rate(10.0) # Above MAX_WATCH_RATE + assert web_scope.watch_rate == original_rate + + def test_set_watch_rate_invalid_negative(self, web_scope): + """Test setting negative watch rate is ignored.""" + original_rate = web_scope.watch_rate + web_scope.set_watch_rate(-1.0) + assert web_scope.watch_rate == original_rate + + def test_set_watch_rate_invalid_type(self, web_scope): + """Test setting non-numeric watch rate is ignored.""" + original_rate = web_scope.watch_rate + web_scope.set_watch_rate("invalid") + assert web_scope.watch_rate == original_rate + + def test_clear_watch_var(self, web_scope): + """Test clearing watch variables.""" + web_scope.watch_vars = [{"test": "data"}] + web_scope.clear_watch_var() + assert web_scope.watch_vars == [] + + def test_clear_scope_var(self, web_scope, mocker): + """Test clearing scope variables.""" + # Mock x2c_scope to avoid AttributeError + mock_x2c = MagicMock() + web_scope.x2c_scope = mock_x2c + + web_scope.scope_vars = [{"test": "data"}] + web_scope.clear_scope_var() + assert web_scope.scope_vars == [] + + def test_set_watch_refresh(self, web_scope): + """Test setting watch refresh flag.""" + assert web_scope.watch_refresh == 0 + web_scope.set_watch_refresh() + assert web_scope.watch_refresh == 1 + + def test_scope_trigger_defaults(self, web_scope): + """Test scope trigger defaults.""" + assert web_scope.scope_trigger is False + assert web_scope.scope_burst is False + + def test_scope_sample_time_default(self, web_scope): + """Test scope sample time default.""" + assert web_scope.scope_sample_time == 1 + + +class TestWebScopeVariableManagement: + """Tests for WebScope variable management with mocked X2CScope.""" + + @pytest.fixture + def web_scope_connected(self, mocker): + """Create WebScope with mocked X2CScope connection.""" + from pyx2cscope.gui.web.scope import WebScope + + web_scope = WebScope() + + # Create mock X2CScope + mock_x2c = MagicMock() + mock_x2c.is_connected.return_value = True + + # Create mock variable + mock_var = MagicMock() + mock_var.info.name = "test_var" + mock_var.get_value.return_value = 42.0 + mock_var.__class__.__name__ = "FloatVariable" + + mock_x2c.get_variable.return_value = mock_var + mock_x2c.list_variables.return_value = ["test_var", "other_var"] + + web_scope.x2c_scope = mock_x2c + return web_scope + + def test_is_connected_true(self, web_scope_connected): + """Test is_connected returns True when connected.""" + assert web_scope_connected.is_connected() is True + + def test_list_variables(self, web_scope_connected): + """Test list_variables returns variable names.""" + variables = web_scope_connected.list_variables() + assert "test_var" in variables + assert "other_var" in variables + + def test_add_watch_var(self, web_scope_connected): + """Test adding watch variable.""" + result = web_scope_connected.add_watch_var("test_var") + + assert result is not None + assert len(web_scope_connected.watch_vars) == 1 + + def test_add_watch_var_duplicate_prevented(self, web_scope_connected): + """Test duplicate watch variables are not added.""" + web_scope_connected.add_watch_var("test_var") + web_scope_connected.add_watch_var("test_var") # Try to add again + + assert len(web_scope_connected.watch_vars) == 1 + + def test_remove_watch_var(self, web_scope_connected): + """Test removing watch variable.""" + web_scope_connected.add_watch_var("test_var") + assert len(web_scope_connected.watch_vars) == 1 + + web_scope_connected.remove_watch_var("test_var") + assert len(web_scope_connected.watch_vars) == 0 + + def test_add_scope_var(self, web_scope_connected): + """Test adding scope variable.""" + result = web_scope_connected.add_scope_var("test_var") + + assert result is not None + assert len(web_scope_connected.scope_vars) == 1 + + def test_add_scope_var_max_limit(self, web_scope_connected): + """Test scope variables can be added (max limit may not be enforced in WebScope).""" + # Add 8 variables + for i in range(8): + mock_var = MagicMock() + mock_var.info.name = f"var_{i}" + mock_var.__class__.__name__ = "FloatVariable" + web_scope_connected.x2c_scope.get_variable.return_value = mock_var + web_scope_connected.add_scope_var(f"var_{i}") + + # Verify 8 were added + assert len(web_scope_connected.scope_vars) == 8 # noqa: PLR2004 + + # Note: WebScope doesn't enforce a max limit in the web GUI + # The x2c_scope backend enforces the limit when actually using scope + + def test_remove_scope_var(self, web_scope_connected): + """Test removing scope variable.""" + web_scope_connected.add_scope_var("test_var") + assert len(web_scope_connected.scope_vars) == 1 + + web_scope_connected.remove_scope_var("test_var") + assert len(web_scope_connected.scope_vars) == 0 + + +class TestWebScopeScaledValue: + """Tests for WebScope scaled value calculation.""" + + def test_update_watch_fields_float(self): + """Test scaled value calculation for float.""" + from pyx2cscope.gui.web.scope import WebScope + + data = { + "value": 10.0, + "scaling": 2.0, + "offset": 5.0, + "type": "float", + } + + WebScope._update_watch_fields(data) + + # scaled_value = (value * scaling) + offset = (10 * 2) + 5 = 25 + assert data["scaled_value"] == 25.0 # noqa: PLR2004 + + def test_update_watch_fields_integer(self): + """Test scaled value calculation for integer.""" + from pyx2cscope.gui.web.scope import WebScope + + data = { + "value": 10, + "scaling": 2.0, + "offset": 5.0, + "type": "int", + } + + WebScope._update_watch_fields(data) + + assert data["scaled_value"] == 25.0 # noqa: PLR2004 + + def test_variable_to_json(self): + """Test variable_to_json conversion.""" + from pyx2cscope.gui.web.scope import WebScope + + mock_var = MagicMock() + mock_var.info.name = "test_var" + + data = { + "variable": mock_var, + "value": 10.0, + "scaling": 1.0, + } + + result = WebScope.variable_to_json(data) + + assert result["variable"] == "test_var" + assert result["value"] == 10.0 # noqa: PLR2004 + + +class TestWebScopeDashboard: + """Tests for WebScope dashboard functionality.""" + + @pytest.fixture + def web_scope_connected(self, mocker): + """Create WebScope with mocked X2CScope connection.""" + from pyx2cscope.gui.web.scope import WebScope + + web_scope = WebScope() + + mock_x2c = MagicMock() + mock_x2c.is_connected.return_value = True + + mock_var = MagicMock() + mock_var.info.name = "dashboard_var" + mock_var.get_value.return_value = 100.0 + mock_x2c.get_variable.return_value = mock_var + + web_scope.x2c_scope = mock_x2c + return web_scope + + def test_add_dashboard_var(self, web_scope_connected): + """Test adding dashboard variable.""" + web_scope_connected.add_dashboard_var("dashboard_var") + + assert "dashboard_var" in web_scope_connected.dashboard_vars + + def test_remove_dashboard_var(self, web_scope_connected): + """Test removing dashboard variable.""" + web_scope_connected.add_dashboard_var("dashboard_var") + web_scope_connected.remove_dashboard_var("dashboard_var") + + assert "dashboard_var" not in web_scope_connected.dashboard_vars + + def test_remove_nonexistent_dashboard_var(self, web_scope_connected): + """Test removing non-existent dashboard variable doesn't raise.""" + # Should not raise + web_scope_connected.remove_dashboard_var("nonexistent") + + +class TestWebScopeThreadSafety: + """Tests for WebScope thread safety.""" + + def test_lock_exists(self): + """Test WebScope has a lock for thread safety.""" + from pyx2cscope.gui.web.scope import WebScope + + web_scope = WebScope() + assert web_scope._lock is not None + + def test_lock_is_used_in_watch_operations(self, mocker): + """Test lock is acquired during watch operations.""" + from pyx2cscope.gui.web.scope import WebScope + + web_scope = WebScope() + + # Mock X2CScope + mock_x2c = MagicMock() + mock_var = MagicMock() + mock_var.info.name = "test_var" + mock_var.get_value.return_value = 1.0 + mock_var.__class__.__name__ = "FloatVariable" + mock_x2c.get_variable.return_value = mock_var + web_scope.x2c_scope = mock_x2c + + # Add variable and read it + web_scope.add_watch_var("test_var") + + # The operations should complete without deadlock + assert len(web_scope.watch_vars) == 1 + + +class TestConnectEndpoint: + """Tests for connect endpoint.""" + + def test_connect_missing_elf(self, flask_client): + """Test connect fails without ELF file.""" + response = flask_client.post( + "/connect", + data={"interface": "SERIAL", "port": "COM1"}, + content_type="multipart/form-data", + ) + + # Should fail or return error + assert response.status_code in [200, 400, 500] + + def test_connect_serial_interface(self, flask_client, mocker, elf_file_path): + """Test connect with serial interface.""" + # Mock X2CScope to prevent real connection + mock_x2c = MagicMock() + mocker.patch( + "pyx2cscope.gui.web.scope.X2CScope", return_value=mock_x2c + ) + + with open(elf_file_path, "rb") as elf_file: + response = flask_client.post( + "/connect", + data={ + "interface": "SERIAL", + "port": "COM1", + "baud_rate": "115200", + "elf_file": (elf_file, "test.elf"), + }, + content_type="multipart/form-data", + ) + + # Connection attempt may fail with 400 if validation fails, 200 on success, or 500 on error + assert response.status_code in [200, 400, 500]