mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-13 00:20:57 +02:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d91a61ae45 | |||
| 0419b63e3f | |||
| b4ad75aade | |||
| 82ce27a700 | |||
| fe8e6d9427 | |||
| f8cd8d0d06 | |||
| 821b61bcc0 | |||
| d9afe31d61 | |||
| 8df2576390 | |||
| 240c6dd439 | |||
| 6039d070b7 | |||
| 04fc10213d | |||
| c3d6eb009f | |||
| 669a84cb21 | |||
| 1211a66577 | |||
| a2374f00b0 | |||
| 92dc947e68 | |||
| 574fd051c1 | |||
| 8070d60370 | |||
| 23a232da9c | |||
| c7d40ca82c | |||
| 1c38d7a6ff | |||
| 75afac2fc7 | |||
| 4ad8b7cb22 | |||
| 8c9d06e9d6 | |||
| 781f7cc055 | |||
| 2dac1c38c1 | |||
| a00bb0fe58 | |||
| 435873b539 | |||
| cb253e5998 | |||
| 17fa18d9d2 | |||
| f7210b88ea | |||
| cb2ccb02ff | |||
| cacf98cb9a | |||
| 5d0ec2186b | |||
| fc4ad051f8 | |||
| d5aaba1adb | |||
| 5b9fdc7d30 | |||
| 943a911a17 | |||
| 80f7829adc | |||
| f6f0ad445f | |||
| 554704a63a | |||
| a3e044bf50 | |||
| 66ae2b25fd | |||
| 532b7422b8 | |||
| 80d1c29ab1 | |||
| 18b0cd4142 | |||
| a26e1f4811 | |||
| 3b7bc2b25a | |||
| a7cf98cb58 | |||
| 01b317367a | |||
| a9a4d3aa6e | |||
| 605c13a6ea | |||
| de8fe3b5f5 | |||
| 2b75d5600a | |||
| 01c6e092b9 | |||
| ca6f355aac | |||
| d876ca72bc | |||
| e0fd97616d | |||
| 6af8a5cbfe | |||
| 944e2cedf8 | |||
| cd11a6cce3 | |||
| c98106e594 | |||
| 04f1ff4fe7 | |||
| 45ed92494c | |||
| 5fc96bd299 | |||
| 1ad5df57fe | |||
| 440e778162 | |||
| fdeb8fcb0f | |||
| 5c90983dd4 | |||
| 4171de1e45 | |||
| f12339e6f9 | |||
| ce8e5f0bec | |||
| 7ea9ab5175 | |||
| b72f0dc6e8 | |||
| cb9d429884 | |||
| 0a80bd0a92 | |||
| 9bc9d355e2 | |||
| 7d5e702a11 | |||
| 40cbf7fe4f | |||
| 7b287c45f2 | |||
| c9455672b5 | |||
| 7f06375f9d | |||
| d00d786399 | |||
| a4c465dcaf | |||
| d0e94d0da4 | |||
| bb3cea7fe8 | |||
| 3c6aa8e138 | |||
| 198684c65d | |||
| 617f2df2af | |||
| ef83287126 | |||
| d5e6f095fe | |||
| b10efc0f40 | |||
| 44b1dbf911 | |||
| e9d381a18a | |||
| b005542df3 | |||
| 13a9175ba5 | |||
| 3f8e60a14f | |||
| 6bc1c3c5f1 | |||
| 9f91eb2e08 | |||
| 1e19092319 | |||
| 96664c3923 |
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
stale-issue-message: 'This issue is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
|
||||
days-before-stale: 120
|
||||
days-before-close: 14
|
||||
|
||||
+191
@@ -1,6 +1,197 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.45.13 (2025-12-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan queue**: Adjustments for changes to the pydantic model of the scan queue
|
||||
([`01c6e09`](https://github.com/bec-project/bec_widgets/commit/01c6e092b9cd46ae056c43e8c6576f7a570cce80))
|
||||
|
||||
|
||||
## v2.45.12 (2025-12-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **heatmap**: Flush image if config changes during scan
|
||||
([`e0fd976`](https://github.com/bec-project/bec_widgets/commit/e0fd97616d370722e2ebf12d0f93862ac35cb20d))
|
||||
|
||||
- **heatmap**: Grid scan image correctly map to scan positions
|
||||
([`6af8a5c`](https://github.com/bec-project/bec_widgets/commit/6af8a5cbfe0f97327b31039033d3e6946388347c))
|
||||
|
||||
- **heatmap**: More robust logic for fast and slow axis in grid scan
|
||||
([`d876ca7`](https://github.com/bec-project/bec_widgets/commit/d876ca72bc50f967f0872eb777f2378a3db68ddf))
|
||||
|
||||
|
||||
## v2.45.11 (2025-12-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Support for AsyncMultiSignal
|
||||
([`cd11a6c`](https://github.com/bec-project/bec_widgets/commit/cd11a6cce33f3c0642984ae6b2d159c7441e22c6))
|
||||
|
||||
|
||||
## v2.45.10 (2025-12-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **devices**: Minor fix to comply with new config helper in bec_lib
|
||||
([`04f1ff4`](https://github.com/bec-project/bec_widgets/commit/04f1ff4fe7869215f010bf73f7271e063e21f2a2))
|
||||
|
||||
|
||||
## v2.45.9 (2025-12-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **rpc**: Add expiration to GUI registry state updates
|
||||
([`5fc96bd`](https://github.com/bec-project/bec_widgets/commit/5fc96bd299115c1849240bae3b37112aad8f5a54))
|
||||
|
||||
|
||||
## v2.45.8 (2025-12-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **notification_banner**: Backwards compatibility to push messages from Broker to Centre as dict
|
||||
([`440e778`](https://github.com/bec-project/bec_widgets/commit/440e778162ebb359fc33be26e3d22f99b4f9dcfe))
|
||||
|
||||
- **notification_banner**: Better contrast in light mode
|
||||
([`5c90983`](https://github.com/bec-project/bec_widgets/commit/5c90983dd4c3ff96e5625ebda0054a1ac1256227))
|
||||
|
||||
- **notification_banner**: Expired messages are hidden in notification center but still accessible
|
||||
([`4171de1`](https://github.com/bec-project/bec_widgets/commit/4171de1e454c4832513ca599c0fd0eaa365c7c32))
|
||||
|
||||
- **notification_banner**: Formatted error messages fetched directly from BECMessage; do not repreat
|
||||
notifications ids
|
||||
([`fdeb8fc`](https://github.com/bec-project/bec_widgets/commit/fdeb8fcb0f223d64933f2791585756527c2f41ed))
|
||||
|
||||
|
||||
## v2.45.7 (2025-12-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Handle none in literal combobox
|
||||
([`ce8e5f0`](https://github.com/bec-project/bec_widgets/commit/ce8e5f0bec7643c9f826e06f987775de95abb91d))
|
||||
|
||||
|
||||
## v2.45.6 (2025-11-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **curve**: Update dap curves if data are set manually
|
||||
([`b72f0dc`](https://github.com/bec-project/bec_widgets/commit/b72f0dc6e8474a65c83f7e2c938fc6356b7b5f3a))
|
||||
|
||||
|
||||
## v2.45.5 (2025-11-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove ghost widgets in scan metadata
|
||||
([`0a80bd0`](https://github.com/bec-project/bec_widgets/commit/0a80bd0a9279cef1136a04c252c97e624ef2e779))
|
||||
|
||||
|
||||
## v2.45.4 (2025-11-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **main_window**: Removed hiding scan progressbar animation
|
||||
([`40cbf7f`](https://github.com/bec-project/bec_widgets/commit/40cbf7fe4f834a1a65306e54b3882d2c0495f90a))
|
||||
|
||||
- **web_links**: Fixed link to bec widget issues from gitlab to github
|
||||
([`7d5e702`](https://github.com/bec-project/bec_widgets/commit/7d5e702a11043ed96a8cb97fce6b2162681e8fab))
|
||||
|
||||
|
||||
## v2.45.3 (2025-11-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **fakeredis**: Add support for additional args
|
||||
([`c945567`](https://github.com/bec-project/bec_widgets/commit/c9455672b58b9df101ccd0d80a169bdf6c707f34))
|
||||
|
||||
|
||||
## v2.45.2 (2025-11-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **test**: Removed duplicate test in crosshair
|
||||
([`d00d786`](https://github.com/bec-project/bec_widgets/commit/d00d786399bca516b8030b9de881b674140bf439))
|
||||
|
||||
### Build System
|
||||
|
||||
- Pyqtgraph pin to 0.13.7
|
||||
([`a4c465d`](https://github.com/bec-project/bec_widgets/commit/a4c465dcaf8cb03962dec1e360b7b832a9a5c780))
|
||||
|
||||
|
||||
## v2.45.1 (2025-11-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Async_readback can accept 0D data
|
||||
([`bb3cea7`](https://github.com/bec-project/bec_widgets/commit/bb3cea7fe800cd5375de5351a72e0944dc86861f))
|
||||
|
||||
|
||||
## v2.45.0 (2025-11-10)
|
||||
|
||||
### Chores
|
||||
|
||||
- Add third-party license notice
|
||||
([`617f2df`](https://github.com/bec-project/bec_widgets/commit/617f2df2af41db7692c42d0e10bce4968f36fb94))
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: Dap curve can be attached to custom and history curves
|
||||
([`198684c`](https://github.com/bec-project/bec_widgets/commit/198684c65d9565e8985156b426b8ef98dcc687cc))
|
||||
|
||||
|
||||
## v2.44.0 (2025-11-05)
|
||||
|
||||
### Chores
|
||||
|
||||
- Update stale issue and PR settings to 120 days
|
||||
([`e9d381a`](https://github.com/bec-project/bec_widgets/commit/e9d381a18a425727216f035ecccdad25f3189608))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Readme rewritten
|
||||
([`44b1dbf`](https://github.com/bec-project/bec_widgets/commit/44b1dbf911f43dbde4286e2ea541c480f7b834be))
|
||||
|
||||
### Features
|
||||
|
||||
- **plot_base**: Invert x/y axis
|
||||
([`b10efc0`](https://github.com/bec-project/bec_widgets/commit/b10efc0f400fe36f7cb0d5998214d50943934d7b))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **plot_base**: Consolidated user access for the PlotBase
|
||||
([`d5e6f09`](https://github.com/bec-project/bec_widgets/commit/d5e6f095fe60223972235acd3ea68389aa7a1a14))
|
||||
|
||||
|
||||
## v2.43.0 (2025-10-30)
|
||||
|
||||
### Features
|
||||
|
||||
- Add pdf viewer widget
|
||||
([`13a9175`](https://github.com/bec-project/bec_widgets/commit/13a9175ba5f5e1e2404d7302404d9511872aafc7))
|
||||
|
||||
|
||||
## v2.42.1 (2025-10-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **rpc_server**: Raise window, even if minimized
|
||||
([`6bc1c3c`](https://github.com/bec-project/bec_widgets/commit/6bc1c3c5f1b3e57ab8e8aeabcc1c0a52a56bbf0a))
|
||||
|
||||
|
||||
## v2.42.0 (2025-10-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **image_roi**: Enhance get_coordinates to include rectangle center and dimensions
|
||||
([`96664c3`](https://github.com/bec-project/bec_widgets/commit/96664c3923737df0b09aa8f35df388f9fd630b55))
|
||||
|
||||
- **positioner_box_2d**: Added properties to enable/disable vertical and horizontal controls
|
||||
([`1e19092`](https://github.com/bec-project/bec_widgets/commit/1e190923196f8b28c92dfdd83b9ce90873dd792d))
|
||||
|
||||
|
||||
## v2.41.1 (2025-10-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# BEC Widgets
|
||||

|
||||
|
||||
# BEC Widgets
|
||||
|
||||
[](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/bec-widgets/)
|
||||
@@ -10,72 +11,190 @@
|
||||
[](https://conventionalcommits.org)
|
||||
[](https://codecov.io/gh/bec-project/bec_widgets)
|
||||
|
||||
A modular PySide6(Qt6) toolkit for [BEC (Beamline Experiment Control)](https://github.com/bec-project/bec). Create
|
||||
high-performance, dockable GUIs to move devices, run scans, and stream live or disk data—powered by Redis and a modular
|
||||
plugin system.
|
||||
|
||||
**⚠️ Important Notice:**
|
||||
## Highlights
|
||||
|
||||
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
|
||||
- **No-code first** — For ~90% of day-to-day workflows, you can compose, operate, and save workspaces **without writing
|
||||
a single line of code**. Just launch, drag widgets, and do your experiment.
|
||||
- **Flexible layout composition** — Build complex experiment GUIs in seconds with the `BECDockArea`: drag‑dock, tab,
|
||||
split, and export profiles/workspaces for reuse.
|
||||
- **CLI / scripting** — Control your beamline experiment from the command line a robust RPC layer using
|
||||
`BECIPythonClient`.
|
||||
- **Designer integration** — Use Qt Designer plugins to drop BEC widgets next to any Qt control, then launch the `.ui`
|
||||
with the custom BEC loader for a zero‑glue workflow.
|
||||
- **Operational integration** — Widgets stay in sync with your running BEC/Redis as the single source of truth:
|
||||
Subscribe to events from BEC and create dynamically updating UIs. BECWidgets also grants you easy access the
|
||||
acquisition history.
|
||||
- **Extensible by design** — Build new widgets with minimal boilerplate using `BECWidget` and `BECDispatcher` for BEC data and
|
||||
messaging. Use the generator command to scaffold RPC interfaces and Designer plugin stubs; beamline plugins can extend
|
||||
or override behavior as needed.
|
||||
|
||||
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Features](#features)
|
||||
- [1. Dock area interface: build GUIs in seconds](#1-dock-area-interface-build-guis-in-seconds)
|
||||
- [2. Qt Designer plugins + BEC Launcher (no glue)](#2-qt-designer-plugins--bec-launcher-no-glue)
|
||||
- [3. Robust RPC from CLI & remote scripting](#3-robust-rpc-from-cli--remote-scripting)
|
||||
- [4. Rapid development (extensible by design)](#4-rapid-development-extensible-by-design)
|
||||
- [Widget Library](#widget-library)
|
||||
- [Documentation](#documentation)
|
||||
- [License](#license)
|
||||
|
||||
## Installation
|
||||
|
||||
Use any of the following setups:
|
||||
|
||||
### Stable release
|
||||
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyside6]
|
||||
pip install bec_widgets
|
||||
```
|
||||
|
||||
### From source (recommended for development)
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
git clone https://github.com/bec-project/bec_widgets.git
|
||||
cd bec_widgets
|
||||
pip install -e .[dev,pyside6]
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
|
||||
specified.
|
||||
## Features
|
||||
|
||||
### 1. Dock area interface: build GUIs in seconds
|
||||
|
||||
The fastest way to explore BEC Widgets. Launch the BEC IPython client with simply `bec` in terminal and the **BECDockArea** opens as the default UI:
|
||||
drag widgets, dock/tab/split panes, and explore. Everything is live—widgets auto-connect to BEC/Redis, so you can
|
||||
operate immediately and refine later with RPC or Designer if needed.
|
||||
|
||||

|
||||
|
||||
### 2. Qt Designer plugins + BEC Launcher (no glue)
|
||||
|
||||
All BEC Widgets ship as **Qt Designer plugins** with our custom Qt Designer launchable by `bec-designer`. Design your UI
|
||||
visually in Designer, save a `.ui`, then launch it with
|
||||
the **BEC Launcher**—no glue code. Widgets auto‑connect to BEC/Redis on startup, so your UI is operational immediately.
|
||||
|
||||

|
||||
|
||||
### 3. Robust RPC from CLI & remote scripting
|
||||
|
||||
Operate and automate BEC Widgets directly from the `BECIPythonClient`. Create or attach to GUIs, address any sub-widget
|
||||
via a simple hierarchical API with tab-completion, and script event-driven behavior that reacts to BEC (scan lifecycle,
|
||||
active devices, topics)—so your UI can be heavily automated.
|
||||
|
||||
- Create & control GUIs: launch, load profiles, open/close panels, tweak properties—all from the shell.
|
||||
- Hierarchical addressing: navigate widgets and sub-widgets with discoverable paths and tab-completion.
|
||||
- Event scripting: subscribe to BEC events (e.g., scan start/finish, device readiness, topic updates) and trigger
|
||||
actions,switch profiles, open diagnostic views, or start specific scans.
|
||||
- Remote & headless: run automation on analysis nodes or from notebooks without a local GUI process.
|
||||
- Plays with no-code: Use the Dock Area / BEC Designer to set up the layout and add automation with RPC when needed.
|
||||
|
||||

|
||||
|
||||
### 4. Rapid development (extensible by design)
|
||||
|
||||
Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to
|
||||
bind endpoints. Then run `bw-generate-cli --target <your-plugin-repo>`. This generates the RPC CLI bindings and a Qt
|
||||
Designer plugin that are immediately usable with your BEC setup. Widgets
|
||||
come online with live BEC/Redis wiring out of the box. 
|
||||
|
||||
<details>
|
||||
<summary> View code: Example Widget </summary>
|
||||
|
||||
```python
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QApplication
|
||||
from qtpy.QtCore import Slot
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
|
||||
|
||||
class SimpleMotorWidget(BECWidget, QWidget):
|
||||
USER_ACCESS = ["move"]
|
||||
|
||||
def __init__(self, parent=None, motor_name="samx", step=5.0, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.motor_name = motor_name
|
||||
self.step = float(step)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self.value_label = QLabel(f"{self.motor_name}: —")
|
||||
self.btn_left = QPushButton("◀︎ -5")
|
||||
self.btn_right = QPushButton("+5 ▶︎")
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.btn_left)
|
||||
row.addWidget(self.btn_right)
|
||||
|
||||
col = QVBoxLayout(self)
|
||||
col.addWidget(self.value_label)
|
||||
col.addLayout(row)
|
||||
|
||||
self.btn_left.clicked.connect(lambda: self.move("left", self.step))
|
||||
self.btn_right.clicked.connect(lambda: self.move("right", self.step))
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_readback, MessageEndpoints.device_readback(self.motor_name))
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_readback(self, data: dict, meta: dict):
|
||||
current_value = data.get("signals").get(self.motor_name).get('value')
|
||||
self.value_label.setText(f"{self.motor_name}: {current_value:.3f}")
|
||||
|
||||
@Slot(str, float)
|
||||
def move(self, direction: Literal["left", "right"] = "left", step: float = 5.0):
|
||||
if direction == "left":
|
||||
self.dev[self.motor_name].move(-step, relative=True)
|
||||
else:
|
||||
self.dev[self.motor_name].move(step, relative=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = SimpleMotorWidget(motor_name="samx", step=5.0)
|
||||
w.setWindowTitle("MotorJogWidget")
|
||||
w.resize(280, 90)
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Widget Library
|
||||
|
||||
A large and growing catalog—plug, configure, run:
|
||||
|
||||
### Plotting
|
||||
|
||||
Waveform, MultiWaveform, and Image/Heatmap widgets deliver responsive plots with crosshairs and ROIs for live and
|
||||
history data.
|
||||
|
||||
<img width="1108" height="838" alt="plotting_hr" src="https://github.com/user-attachments/assets/f50462a5-178d-44d4-aee5-d378c74b107b" />
|
||||
|
||||
### Scan orchestration and motion control.
|
||||
|
||||
Start and stop scans, track progress, reuse parameter presets, and browse history from a focused control surface.
|
||||
Positioner boxes and tweak controls handle precise moves, homing, and calibration for day‑to‑day alignment.
|
||||
|
||||
<img width="1496" height="1388" alt="control" src="https://github.com/user-attachments/assets/d4fb2e2e-04f9-4621-8087-790680797620" />
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
|
||||
## Contributing
|
||||
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
|
||||
> │ │
|
||||
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
|
||||
> │ elements|forms|http|language-service|localize|platform-browser|
|
||||
> │ platform-browser-dynamic|platform-server|router|service-worker|
|
||||
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
|
||||
> │ devtools
|
||||
> │
|
||||
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
|
||||
> ```
|
||||
>
|
||||
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
|
||||
|
||||
> ##### Type
|
||||
>
|
||||
> Must be one of the following:
|
||||
>
|
||||
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
|
||||
> * **docs**: Documentation only changes
|
||||
> * **feat**: A new feature
|
||||
> * **fix**: A bug fix
|
||||
> * **perf**: A code change that improves performance
|
||||
> * **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
> * **test**: Adding missing tests or correcting existing tests
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
|
||||
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
While BEC Widgets is shipped with BSD-3-Clause license, it includes third-party components with different licenses. Below is a list of these components along with their respective licenses.
|
||||
|
||||
Core Dependencies:
|
||||
- BEC: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- black: MIT License, see [here](https://github.com/psf/black/blob/main/LICENSE)
|
||||
- isort: MIT License, see [here](https://github.com/PyCQA/isort/blob/main/LICENSE)
|
||||
- pydantic: MIT License, see [here](https://github.com/pydantic/pydantic/blob/main/LICENSE)
|
||||
- pyqtgraph: MIT License, see [here](https://github.com/pyqtgraph/pyqtgraph/blob/master/LICENSE.txt)
|
||||
- PySide6: LGPLv3 License, see [here](https://doc.qt.io/qtforpython/licenses.html)
|
||||
- qtconsole: BSD-3-Clause License, see [here](https://github.com/spyder-ide/qtconsole/blob/main/LICENSE)
|
||||
- qtpy: MIT License, see [here](https://github.com/spyder-ide/qtpy/blob/master/LICENSE.txt)
|
||||
- qtmonaco: BSD-3-Clause License, see [here](https://github.com/bec-project/qtmonaco/blob/main/LICENSE)
|
||||
- thefuzz: MIT License, see [here](https://github.com/seatgeek/thefuzz/blob/master/LICENSE.txt)
|
||||
|
||||
|
||||
Additional Dependencies (Testing/Development):
|
||||
- coverage: Apache License 2.0, see [here](https://github.com/coveragepy/coveragepy/blob/main/LICENSE.txt)
|
||||
- fakeredis: BSD-3-Clause License, see [here](https://github.com/cunla/fakeredis-py/blob/master/LICENSE)
|
||||
- pytest-bec-e2e: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- pytest-qt: MIT License, see [here](https://github.com/pytest-dev/pytest-qt/blob/master/LICENSE)
|
||||
- pytest-random-order: MIT License, see [here](https://github.com/pytest-dev/pytest-random-order/blob/main/LICENSE)
|
||||
- pytest-timeout: MIT License, see [here](https://github.com/pytest-dev/pytest-timeout/blob/main/LICENSE)
|
||||
- pytest-xvfb: MIT License, see [here](https://github.com/The-Compiler/pytest-xvfb/blob/master/LICENSE)
|
||||
- pytest: MIT License, see [here](https://github.com/pytest-dev/pytest/blob/main/LICENSE)
|
||||
- pytest-cov: MIT License, see [here](https://github.com/pytest-dev/pytest-cov/blob/main/LICENSE)
|
||||
- watchdog: Apache License 2.0, see [here](https://github.com/gorakhargosh/watchdog/blob/master/LICENSE)
|
||||
- pre_commit: MIT License, see [here](https://github.com/pre-commit/pre-commit/blob/main/LICENSE)
|
||||
|
||||
@@ -4,9 +4,7 @@ from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
|
||||
DeviceManagerWidget,
|
||||
)
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
@@ -51,7 +49,7 @@ class BECMainApp(BECMainWindow):
|
||||
self, profile_namespace="main_workspace", auto_profile_namespace=False
|
||||
)
|
||||
self.ads.setObjectName("MainWorkspace")
|
||||
self.device_manager = DeviceManagerWidget(self)
|
||||
self.device_manager = DeviceManagerView(self)
|
||||
self.developer_view = DeveloperView(self)
|
||||
|
||||
self.add_view(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import markdown
|
||||
@@ -15,7 +13,6 @@ from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
@@ -127,7 +124,6 @@ class DeveloperWidget(DockAreaWidget):
|
||||
# Connect editor signals
|
||||
self.explorer.file_open_requested.connect(self._open_new_file)
|
||||
self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file)
|
||||
self.monaco.focused_editor.connect(self._on_focused_editor_changed)
|
||||
|
||||
self.toolbar.show_bundles(["save", "execution", "settings"])
|
||||
|
||||
@@ -284,17 +280,14 @@ class DeveloperWidget(DockAreaWidget):
|
||||
|
||||
@SafeSlot()
|
||||
def on_save(self):
|
||||
"""Save the currently focused file in the Monaco editor."""
|
||||
self.monaco.save_file()
|
||||
|
||||
@SafeSlot()
|
||||
def on_save_as(self):
|
||||
"""Save the currently focused file in the Monaco editor with a 'Save As' dialog."""
|
||||
self.monaco.save_file(force_save_as=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_vim_triggered(self):
|
||||
"""Toggle Vim mode in the Monaco editor."""
|
||||
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
@@ -317,26 +310,16 @@ class DeveloperWidget(DockAreaWidget):
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
"""Stop the execution of the currently running script"""
|
||||
if not self.current_script_id:
|
||||
return
|
||||
self.console.send_ctrl_c()
|
||||
|
||||
@property
|
||||
def current_script_id(self):
|
||||
"""Get the ID of the currently running script."""
|
||||
return self._current_script_id
|
||||
|
||||
@current_script_id.setter
|
||||
def current_script_id(self, value: str | None):
|
||||
"""
|
||||
Set the ID of the currently running script.
|
||||
|
||||
Args:
|
||||
value (str | None): The script ID to set.
|
||||
Raises:
|
||||
ValueError: If the provided value is not a string or None.
|
||||
"""
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError("Script ID must be a string.")
|
||||
old_script_id = self._current_script_id
|
||||
@@ -353,28 +336,6 @@ class DeveloperWidget(DockAreaWidget):
|
||||
self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id)
|
||||
)
|
||||
|
||||
@SafeSlot(CDockWidget)
|
||||
def _on_focused_editor_changed(self, tab_widget: CDockWidget):
|
||||
"""
|
||||
Disable the run button if the focused editor is a macro file.
|
||||
Args:
|
||||
tab_widget: The currently focused tab widget in the Monaco editor.
|
||||
"""
|
||||
if not isinstance(tab_widget, CDockWidget):
|
||||
return
|
||||
widget = tab_widget.widget()
|
||||
if not isinstance(widget, MonacoWidget):
|
||||
return
|
||||
file_scope = widget.metadata.get("scope", "")
|
||||
run_action = self.toolbar.components.get_action("run")
|
||||
stop_action = self.toolbar.components.get_action("stop")
|
||||
if "macro" in file_scope:
|
||||
run_action.action.setEnabled(False)
|
||||
stop_action.action.setEnabled(False)
|
||||
else:
|
||||
run_action.action.setEnabled(True)
|
||||
stop_action.action.setEnabled(True)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_script_execution_info(self, content: dict, metadata: dict):
|
||||
"""
|
||||
@@ -398,7 +359,6 @@ class DeveloperWidget(DockAreaWidget):
|
||||
widget.set_highlighted_lines(line_number, line_number)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources used by the developer widget."""
|
||||
self.delete_all()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .config_choice_dialog import ConfigChoiceDialog
|
||||
from .device_form_dialog import DeviceFormDialog
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"""Dialog to choose config loading method: replace, add or cancel."""
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QLabel, QSizePolicy, QVBoxLayout
|
||||
|
||||
|
||||
class ConfigChoiceDialog(QDialog):
|
||||
class Result(IntEnum):
|
||||
CANCEL = QDialog.Rejected
|
||||
ADD = 2
|
||||
REPLACE = 3
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
custom_label: str = "Do you want to replace the current config or add to it?",
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Load Config")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
label = QLabel(custom_label)
|
||||
label.setWordWrap(True)
|
||||
layout.addWidget(label)
|
||||
|
||||
# Use QDialogButtonBox for native layout
|
||||
self.button_box = QDialogButtonBox(self)
|
||||
self.cancel_btn = self.button_box.addButton(
|
||||
"Cancel", QDialogButtonBox.ButtonRole.ActionRole # RejectRole will be next to Accept...
|
||||
)
|
||||
self.replace_btn = self.button_box.addButton(
|
||||
"Replace", QDialogButtonBox.ButtonRole.AcceptRole
|
||||
)
|
||||
self.add_btn = self.button_box.addButton("Add", QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
|
||||
layout.addWidget(self.button_box)
|
||||
|
||||
for btn in [self.replace_btn, self.add_btn, self.cancel_btn]:
|
||||
btn.setMinimumWidth(80)
|
||||
btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
|
||||
# Connections using native done(int)
|
||||
self.replace_btn.clicked.connect(lambda: self.done(self.Result.REPLACE))
|
||||
self.add_btn.clicked.connect(lambda: self.done(self.Result.ADD))
|
||||
self.cancel_btn.clicked.connect(lambda: self.done(self.Result.CANCEL))
|
||||
|
||||
self.replace_btn.setFocus()
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
"""Dialogs for device configuration forms and ophyd testing."""
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.logger import bec_logger
|
||||
from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components import OphydValidation
|
||||
from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import (
|
||||
DeviceConfigTemplate,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import (
|
||||
validate_name,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
format_error_to_md,
|
||||
)
|
||||
|
||||
DEFAULT_DEVICE = "CustomDevice"
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
|
||||
"""Popup dialog to test Ophyd device configurations interactively."""
|
||||
|
||||
def __init__(self, parent=None, config: dict | None = None): # type:ignore
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Device Manager Ophyd Test")
|
||||
self._config_status = ConfigStatus.UNKNOWN.value
|
||||
self._connection_status = ConnectionStatus.UNKNOWN.value
|
||||
self._validated_config: dict = {}
|
||||
self._validation_msg: str = ""
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
# Core test widget
|
||||
self.device_manager_ophyd_test = OphydValidation()
|
||||
layout.addWidget(self.device_manager_ophyd_test)
|
||||
|
||||
# Log/Markdown box for messages
|
||||
self.text_box = QtWidgets.QTextEdit()
|
||||
self.text_box.setReadOnly(True)
|
||||
layout.addWidget(self.text_box)
|
||||
|
||||
# Connect signal for validation messages
|
||||
|
||||
# Load and apply configuration
|
||||
config = config or {}
|
||||
self.device_manager_ophyd_test.change_device_configs([config], True, True)
|
||||
|
||||
# Dialog Buttons: equal size, stacked horizontally
|
||||
button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close)
|
||||
for button in button_box.buttons():
|
||||
button.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed
|
||||
)
|
||||
button.clicked.connect(self.accept)
|
||||
# button_box.setCenterButtons(False)
|
||||
layout.addWidget(button_box)
|
||||
self.device_manager_ophyd_test.validation_completed.connect(self._on_device_validated)
|
||||
self._resize_dialog()
|
||||
self.finished.connect(self._finished)
|
||||
|
||||
def _resize_dialog(self):
|
||||
"""Resize the dialog based on the screen size."""
|
||||
app: QtCore.QCoreApplication = QtWidgets.QApplication.instance()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 4:3 ratio
|
||||
height = int(screen_height * 0.7)
|
||||
width = int(height * (4 / 3))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (4 / 3))
|
||||
|
||||
self.resize(width, height)
|
||||
|
||||
def _on_device_validated(
|
||||
self, device_config: dict, config_status: int, connection_status: int, validation_msg: str
|
||||
):
|
||||
device_name = device_config.get("name", "")
|
||||
self._config_status = config_status
|
||||
self._connection_status = connection_status
|
||||
self._validated_config = device_config
|
||||
self._validation_msg = validation_msg
|
||||
self.text_box.setMarkdown(format_error_to_md(device_name, validation_msg))
|
||||
|
||||
@SafeSlot(int)
|
||||
def _finished(self, state: int):
|
||||
self.device_manager_ophyd_test.close()
|
||||
self.device_manager_ophyd_test.deleteLater()
|
||||
|
||||
@property
|
||||
def validation_result(self) -> tuple[dict, int, int, str]:
|
||||
"""
|
||||
Return the result of the validation as a tuple of
|
||||
|
||||
Returns:
|
||||
result (Tuple[dict, int, int]): A tuple containing:
|
||||
validated_config (dict): The validated device configuration.
|
||||
config_status (int): The configuration status.
|
||||
connection_status (int): The connection status.
|
||||
|
||||
"""
|
||||
return (
|
||||
self._validated_config,
|
||||
self._config_status,
|
||||
self._connection_status,
|
||||
self._validation_msg,
|
||||
)
|
||||
|
||||
|
||||
class DeviceFormDialog(QtWidgets.QDialog):
|
||||
|
||||
# Signal emitted when device configuration is accepted, only
|
||||
# emitted when the user clicks the "Add Device" button
|
||||
# The integer values indicate if the device config was
|
||||
# validated: config_status, connection_status
|
||||
accepted_data = QtCore.Signal(dict, int, int, str, str)
|
||||
|
||||
def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type:ignore
|
||||
super().__init__(parent)
|
||||
# Track old device name if config is edited
|
||||
self._old_device_name: str = ""
|
||||
|
||||
# Config validation result
|
||||
self._validation_result: tuple[dict, int, int, str] = (
|
||||
{},
|
||||
ConfigStatus.UNKNOWN.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
"",
|
||||
)
|
||||
# Group to variants mapping
|
||||
self._group_variants: dict[str, list[str]] = {
|
||||
group: [variant for variant in variants.keys()]
|
||||
for group, variants in OPHYD_DEVICE_TEMPLATES.items()
|
||||
}
|
||||
|
||||
self._control_widgets: dict[str, QtWidgets.QWidget] = {}
|
||||
|
||||
# Setup layout
|
||||
self.setWindowTitle("Device Config Dialog")
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
# Control panel
|
||||
self._control_box = self.create_control_panel()
|
||||
layout.addWidget(self._control_box)
|
||||
|
||||
# Device config template display
|
||||
self._device_config_template = DeviceConfigTemplate(parent=self)
|
||||
self._frame = QtWidgets.QFrame()
|
||||
self._frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
||||
self._frame.setFrameShadow(QtWidgets.QFrame.Raised)
|
||||
frame_layout = QtWidgets.QVBoxLayout(self._frame)
|
||||
frame_layout.addWidget(self._device_config_template)
|
||||
layout.addWidget(self._frame)
|
||||
|
||||
# Custom buttons
|
||||
self.add_btn = QtWidgets.QPushButton(add_btn_text)
|
||||
self.test_connection_btn = QtWidgets.QPushButton("Test Connection")
|
||||
self.cancel_btn = QtWidgets.QPushButton("Cancel")
|
||||
self.reset_btn = QtWidgets.QPushButton("Reset Form")
|
||||
|
||||
btn_layout = QtWidgets.QHBoxLayout()
|
||||
for btn in (self.cancel_btn, self.reset_btn, self.test_connection_btn, self.add_btn):
|
||||
btn.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
|
||||
btn_layout.addWidget(btn)
|
||||
btn_box = QtWidgets.QGroupBox("Actions")
|
||||
btn_box.setLayout(btn_layout)
|
||||
frame_layout.addWidget(btn_box)
|
||||
|
||||
# Connect signals to explicit slots
|
||||
self.add_btn.clicked.connect(self._add_config)
|
||||
self.test_connection_btn.clicked.connect(self._test_connection)
|
||||
self.reset_btn.clicked.connect(self._reset_config)
|
||||
self.cancel_btn.clicked.connect(self._reject_config)
|
||||
|
||||
# layout.addWidget(self._device_config_template)
|
||||
self.update_variant_combo(self._control_widgets["group_combo"].currentText())
|
||||
self.finished.connect(self._finished)
|
||||
|
||||
@SafeSlot(int)
|
||||
def _finished(self, state: int):
|
||||
for widget in self._control_widgets.values():
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
|
||||
@property
|
||||
def config_validation_result(self) -> tuple[dict, int, int, str]:
|
||||
"""Return the result of the last configuration validation."""
|
||||
return self._validation_result
|
||||
|
||||
@config_validation_result.setter
|
||||
def config_validation_result(self, result: tuple[dict, int, int, str]):
|
||||
self._validation_result = result
|
||||
|
||||
def set_device_config(self, device_config: dict):
|
||||
"""Set the device configuration in the template form."""
|
||||
# Figure out which group and variant this config belongs to
|
||||
device_class = device_config.get("deviceClass", None)
|
||||
for group, variants in OPHYD_DEVICE_TEMPLATES.items():
|
||||
for variant, template_info in variants.items():
|
||||
if template_info.get("deviceClass", None) == device_class:
|
||||
# Found the matching group and variant
|
||||
self._control_widgets["group_combo"].setCurrentText(group)
|
||||
self.update_variant_combo(group)
|
||||
self._control_widgets["variant_combo"].setCurrentText(variant)
|
||||
self._device_config_template.set_config_fields(device_config)
|
||||
return
|
||||
# If no match found, set to default
|
||||
self._control_widgets["group_combo"].setCurrentText(DEFAULT_DEVICE)
|
||||
self.update_variant_combo(DEFAULT_DEVICE)
|
||||
self._device_config_template.set_config_fields(device_config)
|
||||
self._old_device_name = device_config.get("name", "")
|
||||
|
||||
def sizeHint(self) -> QtCore.QSize:
|
||||
return QtCore.QSize(1600, 1000)
|
||||
|
||||
def create_control_panel(self) -> QtWidgets.QGroupBox:
|
||||
self._control_box = QtWidgets.QGroupBox("Choose a Device Group")
|
||||
layout = QtWidgets.QGridLayout(self._control_box)
|
||||
|
||||
group_label = QtWidgets.QLabel("Device Group:")
|
||||
layout.addWidget(group_label, 0, 0)
|
||||
|
||||
group_combo = QtWidgets.QComboBox()
|
||||
group_combo.addItems(self._group_variants.keys())
|
||||
self._control_widgets["group_combo"] = group_combo
|
||||
layout.addWidget(group_combo, 1, 0)
|
||||
|
||||
variant_label = QtWidgets.QLabel("Variants:")
|
||||
layout.addWidget(variant_label, 0, 1)
|
||||
|
||||
variant_combo = QtWidgets.QComboBox()
|
||||
self._control_widgets["variant_combo"] = variant_combo
|
||||
layout.addWidget(variant_combo, 1, 1)
|
||||
|
||||
group_combo.currentTextChanged.connect(self.update_variant_combo)
|
||||
variant_combo.currentTextChanged.connect(self.update_device_config_template)
|
||||
|
||||
return self._control_box
|
||||
|
||||
def update_variant_combo(self, group_name: str):
|
||||
variant_combo = self._control_widgets["variant_combo"]
|
||||
variant_combo.clear()
|
||||
variant_combo.addItems(self._group_variants.get(group_name, []))
|
||||
if variant_combo.count() <= 1:
|
||||
variant_combo.setEnabled(False)
|
||||
else:
|
||||
variant_combo.setEnabled(True)
|
||||
|
||||
def update_device_config_template(self, variant_name: str):
|
||||
group_name = self._control_widgets["group_combo"].currentText()
|
||||
template_info = OPHYD_DEVICE_TEMPLATES.get(group_name, {}).get(variant_name, {})
|
||||
if template_info:
|
||||
self._device_config_template.change_template(template_info)
|
||||
else:
|
||||
self._device_config_template.change_template(
|
||||
OPHYD_DEVICE_TEMPLATES[DEFAULT_DEVICE][DEFAULT_DEVICE]
|
||||
)
|
||||
|
||||
def _add_config(self):
|
||||
config = self._device_config_template.get_config_fields()
|
||||
config_status = ConfigStatus.UNKNOWN.value
|
||||
connection_status = ConnectionStatus.UNKNOWN.value
|
||||
validation_msg = ""
|
||||
try:
|
||||
if DeviceModel.model_validate(config) == DeviceModel.model_validate(
|
||||
self._validation_result[0]
|
||||
):
|
||||
config_status = self._validation_result[1]
|
||||
connection_status = self._validation_result[2]
|
||||
validation_msg = self._validation_result[3]
|
||||
except Exception:
|
||||
logger.debug(
|
||||
f"Device config validation changed for config: {config} compared to {self._validation_result[0]}. Returning UNKNOWN statuses."
|
||||
)
|
||||
|
||||
if not validate_name(config.get("name", "")):
|
||||
msg_box = self._create_warning_message_box(
|
||||
"Invalid Device Name",
|
||||
f"Device is invalid, can not be empty with spaces. Please provide a valid name. {config.get('name', '')!r} ",
|
||||
)
|
||||
msg_box.exec()
|
||||
return
|
||||
if config_status == ConfigStatus.INVALID.value:
|
||||
msg_box = self._create_warning_message_box(
|
||||
"Invalid Device Configuration",
|
||||
f"Device configuration is invalid. Last known validation message:\n\nErrors:\n{validation_msg}",
|
||||
)
|
||||
msg_box.exec()
|
||||
return
|
||||
|
||||
self.accepted_data.emit(
|
||||
config, config_status, connection_status, validation_msg, self._old_device_name
|
||||
)
|
||||
self.accept()
|
||||
|
||||
def _create_warning_message_box(self, title: str, text: str) -> QtWidgets.QMessageBox:
|
||||
msg_box = QtWidgets.QMessageBox(self)
|
||||
msg_box.setIcon(QtWidgets.QMessageBox.Warning)
|
||||
msg_box.setWindowTitle(title)
|
||||
msg_box.setText(text)
|
||||
return msg_box
|
||||
|
||||
def _test_connection(self):
|
||||
config = self._device_config_template.get_config_fields()
|
||||
dialog = DeviceManagerOphydValidationDialog(self, config=config)
|
||||
result = dialog.exec()
|
||||
if result in (QtWidgets.QDialog.Accepted, QtWidgets.QDialog.Rejected):
|
||||
self.config_validation_result = dialog.validation_result
|
||||
# self._device_config_template.set_config_fields(self.config_validation_result[0])
|
||||
|
||||
def _reset_config(self):
|
||||
self._device_config_template.reset_to_defaults()
|
||||
|
||||
def _reject_config(self):
|
||||
self.reject()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
|
||||
dialog = DeviceFormDialog()
|
||||
dialog.resize(1200, 800)
|
||||
dialog.show()
|
||||
sys.exit(app.exec())
|
||||
+720
@@ -0,0 +1,720 @@
|
||||
"""Module for the upload redis dialog in the device manager view."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Dict, List, Tuple
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import apply_theme, material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components import OphydValidation
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
get_validation_icons,
|
||||
)
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.colors import AccentColor
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceStatusItem(QtWidgets.QWidget):
|
||||
"""Individual device status item widget for the validation display."""
|
||||
|
||||
def __init__(
|
||||
self, device_config: dict, config_status: int, connection_status: int, parent=None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.device_name = device_config.get("name", "")
|
||||
self.device_config: dict = device_config
|
||||
self.config_status = ConfigStatus(config_status)
|
||||
self.connection_status = ConnectionStatus(connection_status)
|
||||
self._transparent_button_style = "background-color: transparent; border: none;"
|
||||
|
||||
# Get validation icons
|
||||
self.colors = get_accent_colors()
|
||||
self._icon_size = (20, 20)
|
||||
self.icons = get_validation_icons(self.colors, self._icon_size)
|
||||
|
||||
self._setup_ui()
|
||||
self._update_display()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the UI for the device status item."""
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 4, 8, 4)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Device name label
|
||||
self.name_label = QtWidgets.QLabel(self.device_name)
|
||||
self.name_label.setMinimumWidth(150)
|
||||
layout.addWidget(self.name_label)
|
||||
layout.addStretch()
|
||||
|
||||
# Config status icon
|
||||
self.config_icon_label = self._create_status_icon_label(self._icon_size)
|
||||
layout.addWidget(self.config_icon_label)
|
||||
|
||||
# Connection status icon
|
||||
self.connection_icon_label = self._create_status_icon_label(self._icon_size)
|
||||
layout.addWidget(self.connection_icon_label)
|
||||
|
||||
def _create_status_icon_label(self, icon_size: tuple[int, int]) -> QtWidgets.QPushButton:
|
||||
button = QtWidgets.QPushButton()
|
||||
button.setFlat(True)
|
||||
button.setEnabled(False)
|
||||
button.setStyleSheet(self._transparent_button_style)
|
||||
button.setFixedSize(icon_size[0], icon_size[1])
|
||||
return button
|
||||
|
||||
def _update_display(self):
|
||||
"""Update the visual display based on current status."""
|
||||
# Update config status
|
||||
config_icon = self.icons["config_status"].get(self.config_status.value)
|
||||
if config_icon:
|
||||
self.config_icon_label.setIcon(config_icon)
|
||||
|
||||
# Update connection status
|
||||
connection_icon = self.icons["connection_status"].get(self.connection_status.value)
|
||||
if connection_icon:
|
||||
self.connection_icon_label.setIcon(connection_icon)
|
||||
|
||||
def update_status(self, config_status: int, connection_status: int):
|
||||
"""Update the status and refresh display."""
|
||||
self.config_status = ConfigStatus(config_status)
|
||||
self.connection_status = ConnectionStatus(connection_status)
|
||||
self._update_display()
|
||||
|
||||
|
||||
class SortTableItem(QtWidgets.QTableWidgetItem):
|
||||
"""Custom TableWidgetItem with hidden __column_data attribute for sorting."""
|
||||
|
||||
def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool:
|
||||
"""Override less-than operator for sorting."""
|
||||
if not isinstance(other, QtWidgets.QTableWidgetItem):
|
||||
return NotImplemented
|
||||
self_data = self.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
other_data = other.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if self_data is not None and other_data is not None:
|
||||
self_data: DeviceStatusItem
|
||||
other_data: DeviceStatusItem
|
||||
if self_data.config_status != other_data.config_status:
|
||||
return self_data.config_status < other_data.config_status
|
||||
else:
|
||||
return self_data.connection_status < other_data.connection_status
|
||||
return super().__lt__(other)
|
||||
|
||||
def __gt__(self, other: QtWidgets.QTableWidgetItem) -> bool:
|
||||
"""Override less-than operator for sorting."""
|
||||
if not isinstance(other, QtWidgets.QTableWidgetItem):
|
||||
return NotImplemented
|
||||
self_data = self.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
other_data = other.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if self_data is not None and other_data is not None:
|
||||
self_data: DeviceStatusItem
|
||||
other_data: DeviceStatusItem
|
||||
if self_data.config_status != other_data.config_status:
|
||||
return self_data.config_status > other_data.config_status
|
||||
else:
|
||||
return self_data.connection_status > other_data.connection_status
|
||||
return super().__gt__(other)
|
||||
|
||||
|
||||
class ValidationSection(QtWidgets.QGroupBox):
|
||||
"""Section widget for displaying validation results."""
|
||||
|
||||
def __init__(self, title: str, parent=None):
|
||||
super().__init__(title, parent=parent)
|
||||
self._setup_ui()
|
||||
# self.device_items: Dict[str, DeviceStatusItem] = {}
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the UI for the validation section."""
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
|
||||
# Status summary label
|
||||
summary_layout = QtWidgets.QHBoxLayout()
|
||||
self.summary_icon = QtWidgets.QLabel()
|
||||
self.summary_icon.setFixedSize(24, 24)
|
||||
self.summary_label = QtWidgets.QLabel()
|
||||
self.summary_label.setWordWrap(True)
|
||||
summary_layout.addWidget(self.summary_icon)
|
||||
summary_layout.addWidget(self.summary_label)
|
||||
layout.addLayout(summary_layout)
|
||||
|
||||
# Scroll area for device items
|
||||
self.table = QtWidgets.QTableWidget()
|
||||
self.table.setColumnCount(1)
|
||||
self.table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().hide()
|
||||
self.table.verticalHeader().hide()
|
||||
self.table.setShowGrid(False) # r
|
||||
self.table.sortItems(0, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
layout.addWidget(self.table)
|
||||
QtCore.QTimer.singleShot(0, self.adjustSize)
|
||||
|
||||
def add_device(self, device_config: dict, config_status: int, connection_status: int):
|
||||
"""
|
||||
Add a device to the validation section.
|
||||
|
||||
Args:
|
||||
device_config (dict): The device configuration dictionary.
|
||||
config_status (int): The configuration status.
|
||||
connection_status (int): The connection status.
|
||||
"""
|
||||
self.table.setSortingEnabled(False)
|
||||
device_name = device_config.get("name", "")
|
||||
row = self._find_row_by_name(device_name)
|
||||
if row is not None:
|
||||
widget: DeviceStatusItem = self.table.cellWidget(row, 0)
|
||||
widget.update_status(config_status, connection_status)
|
||||
else:
|
||||
row_position = self.table.rowCount()
|
||||
self.table.insertRow(row_position)
|
||||
sort_item = SortTableItem(device_name)
|
||||
sort_item.setText("")
|
||||
self.table.setItem(row_position, 0, sort_item)
|
||||
device_item = DeviceStatusItem(device_config, config_status, connection_status)
|
||||
sort_item.setData(QtCore.Qt.ItemDataRole.UserRole, device_item)
|
||||
self.table.setCellWidget(row_position, 0, device_item)
|
||||
self.table.resizeRowsToContents()
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
def _find_row_by_name(self, device_name: str) -> int | None:
|
||||
"""
|
||||
Find a row by device name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the device to find.
|
||||
Returns:
|
||||
int | None: The row index if found, else None.
|
||||
"""
|
||||
for row in range(self.table.rowCount()):
|
||||
item: SortTableItem = self.table.item(row, 0)
|
||||
widget: DeviceStatusItem = self.table.cellWidget(row, 0)
|
||||
if widget.device_name == device_name:
|
||||
return row
|
||||
return None
|
||||
|
||||
def remove_device(self, device_name: str):
|
||||
"""Remove a device from the table by name."""
|
||||
self.table.setSortingEnabled(False)
|
||||
row = self._find_row_by_name(device_name)
|
||||
if row is not None:
|
||||
self.table.removeRow(row)
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
def clear_devices(self):
|
||||
"""Clear all device items."""
|
||||
self.table.setSortingEnabled(False)
|
||||
while self.table.rowCount() > 0:
|
||||
self.table.removeRow(0)
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
def update_summary(self, text: str, icon: QtGui.QPixmap = None):
|
||||
"""Update the summary label."""
|
||||
self.summary_label.setText(text)
|
||||
if icon:
|
||||
self.summary_icon.setPixmap(icon)
|
||||
|
||||
|
||||
class UploadRedisDialog(QtWidgets.QDialog):
|
||||
"""
|
||||
Dialog for uploading device configurations to BEC server with validation checks.
|
||||
"""
|
||||
|
||||
class UploadAction(IntEnum):
|
||||
"""Enum for upload actions."""
|
||||
|
||||
CANCEL = QtWidgets.QDialog.Rejected
|
||||
OK = QtWidgets.QDialog.Accepted
|
||||
|
||||
# Signal to trigger upload after confirmation
|
||||
upload_confirmed = QtCore.Signal(int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
ophyd_test_widget: OphydValidation,
|
||||
device_configs: dict[str, Tuple[dict, int, int]] | None = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.device_configs: dict[str, Tuple[dict, int, int]] = device_configs or {}
|
||||
self.ophyd_test_widget = ophyd_test_widget
|
||||
self._transparent_button_style = "background-color: transparent; border: none;"
|
||||
|
||||
self.colors = get_accent_colors()
|
||||
self.icons = get_validation_icons(self.colors, (20, 20))
|
||||
material_icon_partial = partial(material_icon, size=(24, 24), filled=True)
|
||||
self._label_icons = {
|
||||
"success": material_icon_partial("check_circle", color=self.colors.success),
|
||||
"warning": material_icon_partial("warning", color=self.colors.warning),
|
||||
"error": material_icon_partial("error", color=self.colors.emergency),
|
||||
"reload": material_icon_partial("refresh", color=self.colors.default),
|
||||
"upload": material_icon_partial("cloud_upload", color=self.colors.default),
|
||||
}
|
||||
|
||||
# Track validation states
|
||||
self.has_invalid_configs: int = 0
|
||||
self.has_untested_connections: int = 0
|
||||
self.has_cannot_connect: int = 0
|
||||
self._current_progress: int | None = None
|
||||
|
||||
self._setup_ui()
|
||||
self._update_ui()
|
||||
# Disable validation features if no ophyd test widget provided, else connect validation
|
||||
self._validation_connection = self.ophyd_test_widget.validation_completed.connect(
|
||||
self._update_from_ophyd_device_tests
|
||||
)
|
||||
|
||||
def set_device_config(self, device_configs: dict[str, Tuple[dict, int, int]]):
|
||||
"""
|
||||
Update the device configuration in the dialog.
|
||||
|
||||
Args:
|
||||
device_configs (dict[str, Tuple[dict, int, int]]): New device configurations with structure
|
||||
{device_name: (config_dict, config_status, connection_status)}.
|
||||
"""
|
||||
self.config_section.clear_devices()
|
||||
self.device_configs = device_configs
|
||||
self._update_ui()
|
||||
|
||||
def accept(self):
|
||||
self.cleanup()
|
||||
return super().accept()
|
||||
|
||||
def reject(self):
|
||||
self.cleanup()
|
||||
return super().reject()
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup on dialog finish."""
|
||||
self.ophyd_test_widget.validation_completed.disconnect(self._validation_connection)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the main UI for the dialog."""
|
||||
self.setWindowTitle("Upload Configuration to BEC Server")
|
||||
self.setModal(True) # Blocks interaction with other parts of the app
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Header
|
||||
header_label = QtWidgets.QLabel("Review Configuration Before Upload")
|
||||
header_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 8px;")
|
||||
layout.addWidget(header_label)
|
||||
|
||||
# Description
|
||||
desc_label = QtWidgets.QLabel(
|
||||
"Please review the configuration and connection status of all devices before uploading to BEC Server."
|
||||
)
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setStyleSheet("color: #666; margin-bottom: 16px;")
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Config validation section
|
||||
sections_layout = QtWidgets.QHBoxLayout()
|
||||
self.config_section = ValidationSection("Configuration Validation")
|
||||
sections_layout.addWidget(self.config_section)
|
||||
layout.addLayout(sections_layout)
|
||||
|
||||
# Action buttons section
|
||||
self._setup_action_buttons(layout)
|
||||
|
||||
# Dialog buttons
|
||||
self._setup_dialog_buttons(layout)
|
||||
self.adjustSize()
|
||||
|
||||
def _setup_action_buttons(self, parent_layout: QtWidgets.QLayout):
|
||||
"""Setup the action buttons section."""
|
||||
action_group = QtWidgets.QGroupBox("Actions")
|
||||
action_layout = QtWidgets.QVBoxLayout(action_group)
|
||||
|
||||
# Validate connections button
|
||||
button_layout = QtWidgets.QHBoxLayout()
|
||||
self.validate_connections_btn = QtWidgets.QPushButton("Validate All Connections")
|
||||
self.validate_connections_btn.setIcon(self._label_icons["reload"])
|
||||
self.validate_connections_btn.clicked.connect(self._validate_connections)
|
||||
button_layout.addWidget(self.validate_connections_btn)
|
||||
button_layout.addStretch()
|
||||
button_layout.addSpacing(16)
|
||||
|
||||
# Progress bar
|
||||
self._progress_bar = BECProgressBar(self)
|
||||
self._progress_bar.setVisible(False)
|
||||
button_layout.addWidget(self._progress_bar)
|
||||
action_layout.addLayout(button_layout)
|
||||
|
||||
# Status indicator
|
||||
status_layout = QtWidgets.QHBoxLayout()
|
||||
self.status_icon = QtWidgets.QPushButton()
|
||||
self.status_icon.setFlat(True)
|
||||
self.status_icon.setEnabled(False)
|
||||
self.status_icon.setStyleSheet(self._transparent_button_style)
|
||||
self.status_icon.setFixedSize(24, 24)
|
||||
self.status_label = QtWidgets.QLabel()
|
||||
self.status_label.setWordWrap(True)
|
||||
status_layout.addWidget(self.status_icon)
|
||||
status_layout.addWidget(self.status_label)
|
||||
action_layout.addLayout(status_layout)
|
||||
|
||||
parent_layout.addWidget(action_group)
|
||||
|
||||
def _setup_dialog_buttons(self, parent_layout: QtWidgets.QLayout):
|
||||
"""Setup the dialog buttons."""
|
||||
button_layout = QtWidgets.QHBoxLayout()
|
||||
|
||||
# Cancel button
|
||||
self.cancel_btn = QtWidgets.QPushButton("Cancel")
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
# Upload button
|
||||
self.upload_btn = QtWidgets.QPushButton("Upload to BEC Server")
|
||||
self.upload_btn.setIcon(self._label_icons["upload"])
|
||||
self.upload_btn.clicked.connect(self._handle_upload)
|
||||
button_layout.addWidget(self.upload_btn)
|
||||
|
||||
parent_layout.addLayout(button_layout)
|
||||
|
||||
def _populate_device_data(self):
|
||||
"""Populate the dialog with device configuration data."""
|
||||
if not self.device_configs:
|
||||
return
|
||||
|
||||
self.has_invalid_configs = 0
|
||||
self.has_untested_connections = 0
|
||||
self.has_cannot_connect = 0
|
||||
|
||||
for device_name, (config, config_status, connection_status) in self.device_configs.items():
|
||||
# Add to appropriate sections
|
||||
self.config_section.add_device(config, config_status, connection_status)
|
||||
|
||||
# Track statistics
|
||||
if config_status == ConfigStatus.INVALID.value:
|
||||
self.has_invalid_configs += 1
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
self.has_untested_connections += 1
|
||||
if connection_status == ConnectionStatus.CANNOT_CONNECT.value:
|
||||
self.has_cannot_connect += 1
|
||||
|
||||
# Update section summaries
|
||||
num_devices = len(self.device_configs)
|
||||
|
||||
# Config validation summary
|
||||
if self.has_invalid_configs > 0:
|
||||
icon = self._label_icons["error"]
|
||||
text = f"{self.has_invalid_configs} of {num_devices} device configurations are invalid."
|
||||
else:
|
||||
icon = self._label_icons["success"]
|
||||
text = f"All {num_devices} device configurations are valid."
|
||||
if self.has_untested_connections > 0:
|
||||
icon = self._label_icons["warning"]
|
||||
text += f"{self.has_untested_connections} device connections are not tested."
|
||||
if self.has_cannot_connect > 0:
|
||||
icon = self._label_icons["warning"]
|
||||
text += f"{self.has_cannot_connect} device connections cannot be established."
|
||||
self.config_section.update_summary(text, icon)
|
||||
|
||||
def _update_ui(self):
|
||||
"""Update UI state based on validation results."""
|
||||
# Update first the device data
|
||||
self._populate_device_data()
|
||||
|
||||
# Invalid configuration have highest priority, upload disabled
|
||||
if self.has_invalid_configs:
|
||||
self.status_icon.setIcon(self._label_icons["error"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{self.has_invalid_configs} device configurations are invalid.",
|
||||
"Please fix configuration errors before uploading.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(False)
|
||||
self.validate_connections_btn.setEnabled(False)
|
||||
self.validate_connections_btn.setText("Invalid Configurations")
|
||||
|
||||
# Next priority: connections that cannot be established, error but upload is enabled
|
||||
elif self.has_cannot_connect:
|
||||
self.status_icon.setIcon(self._label_icons["warning"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{self.has_cannot_connect} connections cannot be established.",
|
||||
"Please fix connection issues before uploading.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setText(
|
||||
f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections"
|
||||
)
|
||||
|
||||
# Next priority: untested connections, warning but upload is enabled
|
||||
elif self.has_untested_connections:
|
||||
self.status_icon.setIcon(self._label_icons["warning"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
f"{self.has_untested_connections} connections have not been tested.",
|
||||
"Consider validating connections before uploading.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setText(
|
||||
f"Validate {self.has_untested_connections + self.has_cannot_connect} Connections"
|
||||
)
|
||||
|
||||
# All good, upload enabled
|
||||
else:
|
||||
self.status_icon.setIcon(self._label_icons["success"])
|
||||
self.status_label.setText(
|
||||
"\n".join(
|
||||
[
|
||||
"All device configurations are valid.",
|
||||
"All connections have been successfully tested.",
|
||||
]
|
||||
)
|
||||
)
|
||||
self.upload_btn.setEnabled(True)
|
||||
self.validate_connections_btn.setEnabled(False)
|
||||
self.validate_connections_btn.setText("All Connections Validated")
|
||||
|
||||
@SafeSlot()
|
||||
def _validate_connections(self):
|
||||
"""Request validation of all untested connections."""
|
||||
testable_devices: List[dict] = []
|
||||
for _, (config, _, connection_status) in self.device_configs.items():
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
testable_devices.append(config)
|
||||
elif connection_status == ConnectionStatus.CANNOT_CONNECT.value:
|
||||
testable_devices.append(config)
|
||||
|
||||
if len(testable_devices) > 0:
|
||||
self.validate_connections_btn.setEnabled(False)
|
||||
self._progress_bar.setVisible(True)
|
||||
self._progress_bar.maximum = len(testable_devices)
|
||||
self._progress_bar.minimum = 0
|
||||
self._progress_bar.set_value(0)
|
||||
self._current_progress = 0
|
||||
self.ophyd_test_widget.change_device_configs(testable_devices, added=True, connect=True)
|
||||
|
||||
@SafeSlot()
|
||||
def _handle_upload(self):
|
||||
"""Handle the upload button click with appropriate confirmations."""
|
||||
# First priority: invalid configurations, block upload
|
||||
if self.has_invalid_configs:
|
||||
detailed_text = (
|
||||
f"There is {self.has_invalid_configs} device with an invalid configuration."
|
||||
if self.has_invalid_configs == 1
|
||||
else f"There are {self.has_invalid_configs} devices with invalid configurations."
|
||||
)
|
||||
text = " ".join(
|
||||
[detailed_text, "Invalid configuration can not be uploaded to the BEC Server."]
|
||||
)
|
||||
QtWidgets.QMessageBox.critical(self, "Device Configurations Invalid", text)
|
||||
self.done(self.UploadAction.CANCEL)
|
||||
return
|
||||
|
||||
# Next priority: connections that cannot be established, show warning, but allow to proceed
|
||||
if self.has_cannot_connect:
|
||||
detailed_text = (
|
||||
f"There is {self.has_cannot_connect} device that cannot connect"
|
||||
if self.has_cannot_connect == 1
|
||||
else f"There are {self.has_cannot_connect} devices that cannot connect."
|
||||
)
|
||||
text = " ".join(
|
||||
[
|
||||
detailed_text,
|
||||
"These devices may not be reachable and disabled BEC upon loading the config.",
|
||||
"Consider validating these connections before.",
|
||||
]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.critical(
|
||||
self,
|
||||
"Devices cannot Connect",
|
||||
text,
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.No,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.No:
|
||||
return
|
||||
|
||||
# If some connections are untested, warn the user
|
||||
if self.has_untested_connections:
|
||||
detailed_text = (
|
||||
f"There is {self.has_untested_connections} device with untested connections."
|
||||
if self.has_untested_connections == 1
|
||||
else f"There are {self.has_untested_connections} devices with untested connections."
|
||||
)
|
||||
text = " ".join(
|
||||
[
|
||||
detailed_text,
|
||||
"Uploading without validating connections may result in devices that cannot be reached when the configuration is applied.",
|
||||
]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Untested Connections",
|
||||
text,
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.No,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.No:
|
||||
return
|
||||
|
||||
# Final confirmation
|
||||
text = " ".join(
|
||||
["You are about to upload the device configurations to BEC Server.", "Please confirm."]
|
||||
)
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Upload to BEC Server",
|
||||
text,
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.Yes,
|
||||
)
|
||||
if reply == QtWidgets.QMessageBox.Yes:
|
||||
self.done(self.UploadAction.OK)
|
||||
else:
|
||||
self.done(self.UploadAction.CANCEL)
|
||||
|
||||
@SafeSlot(dict, int, int, str)
|
||||
def _update_from_ophyd_device_tests(
|
||||
self,
|
||||
device_config: dict,
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
validation_message: str = "",
|
||||
):
|
||||
"""
|
||||
Update device status from ophyd device tests. This has to be with a connection_status that was updated.
|
||||
|
||||
"""
|
||||
if connection_status == ConnectionStatus.UNKNOWN.value:
|
||||
return
|
||||
self.update_device_status(device_config, config_status, connection_status)
|
||||
|
||||
@SafeSlot(dict, int, int)
|
||||
def update_device_status(self, device_config: dict, config_status: int, connection_status: int):
|
||||
"""Update the status of a specific device."""
|
||||
# Update device config status
|
||||
device_name = device_config.get("name", "")
|
||||
old_config, _, _ = self.device_configs.get(device_name, (None, None, None))
|
||||
if old_config is not None:
|
||||
self.device_configs[device_name] = (device_config, config_status, connection_status)
|
||||
if self._current_progress is not None:
|
||||
self._current_progress += 1
|
||||
self._progress_bar.set_value(self._current_progress)
|
||||
if self._current_progress >= self._progress_bar.maximum:
|
||||
self._progress_bar.setVisible(False)
|
||||
self._progress_bar.set_value(0)
|
||||
self._current_progress = None
|
||||
self.validation_completed()
|
||||
self._update_ui()
|
||||
return
|
||||
|
||||
# Update UI sections
|
||||
self.config_section.add_device(device_config, config_status, connection_status)
|
||||
|
||||
# Recalculate summaries and UI state
|
||||
self._update_ui()
|
||||
|
||||
def validation_completed(self):
|
||||
"""Called when connection validation is completed."""
|
||||
self.validate_connections_btn.setEnabled(True)
|
||||
self._update_ui()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
"""Test the upload redis dialog."""
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Sample device configurations for testing
|
||||
sample_configs = [
|
||||
(
|
||||
{"name": "motor_x", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_1", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_2", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_y", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_z", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_x1", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_11", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CANNOT_CONNECT.value,
|
||||
),
|
||||
(
|
||||
{"name": "detector_21", "deviceClass": "EpicsSignal"},
|
||||
ConfigStatus.INVALID.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_y1", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CANNOT_CONNECT.value,
|
||||
),
|
||||
(
|
||||
{"name": "motor_z1", "deviceClass": "EpicsMotor"},
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
),
|
||||
]
|
||||
configs = {cfg[0]["name"]: cfg for cfg in sample_configs}
|
||||
apply_theme("dark")
|
||||
from unittest import mock
|
||||
|
||||
ophyd_test_widget = mock.MagicMock(spec=OphydValidation)
|
||||
dialog = UploadRedisDialog(
|
||||
parent=None, device_configs=configs, ophyd_test_widget=ophyd_test_widget
|
||||
)
|
||||
dialog.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,665 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import List, Literal, get_args
|
||||
|
||||
import yaml
|
||||
from bec_lib import config_helper
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.file_utils import DeviceConfigWriter
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtCore import QMetaObject, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QFileDialog, QMessageBox, QTextEdit, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import (
|
||||
ConfigChoiceDialog,
|
||||
DeviceFormDialog,
|
||||
)
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
|
||||
UploadRedisDialog,
|
||||
)
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.control.device_manager.components import (
|
||||
DeviceTable,
|
||||
DMConfigView,
|
||||
DocstringView,
|
||||
OphydValidation,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
|
||||
ConnectionStatus,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_yes_no_question = partial(
|
||||
QMessageBox.question,
|
||||
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
defaultButton=QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
|
||||
class DeviceManagerDisplayWidget(DockAreaWidget):
|
||||
"""Device Manager main display widget. This contains all sub-widgets and the toolbar."""
|
||||
|
||||
RPC = False
|
||||
|
||||
request_ophyd_validation = Signal(list, bool, bool)
|
||||
|
||||
def __init__(self, parent=None, client=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, variant="compact", *args, **kwargs)
|
||||
|
||||
# Push to Redis dialog
|
||||
self._upload_redis_dialog: UploadRedisDialog | None = None
|
||||
self._dialog_validation_connection: QMetaObject.Connection | None = None
|
||||
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
self._shared_selection = SharedSelectionSignal()
|
||||
|
||||
# Device Table View widget
|
||||
self.device_table_view = DeviceTable(self)
|
||||
|
||||
# Device Config View widget
|
||||
self.dm_config_view = DMConfigView(self)
|
||||
|
||||
# Docstring View
|
||||
self.dm_docs_view = DocstringView(self)
|
||||
|
||||
# Ophyd Test view
|
||||
self.ophyd_widget_view = QWidget(self)
|
||||
layout = QVBoxLayout(self.ophyd_widget_view)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
self.ophyd_test_view = OphydValidation(self, hide_legend=False)
|
||||
layout.addWidget(self.ophyd_test_view)
|
||||
|
||||
# Validation Results view
|
||||
self.validation_results = QTextEdit(self)
|
||||
self.validation_results.setReadOnly(True)
|
||||
self.validation_results.setPlaceholderText("Validation results will appear here...")
|
||||
layout.addWidget(self.validation_results)
|
||||
self.ophyd_test_view.item_clicked.connect(self._ophyd_test_item_clicked_cb)
|
||||
|
||||
for signal, slots in [
|
||||
(
|
||||
self.device_table_view.selected_devices,
|
||||
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
),
|
||||
(
|
||||
self.ophyd_test_view.validation_completed,
|
||||
(self.device_table_view.update_device_validation,),
|
||||
),
|
||||
(
|
||||
self.ophyd_test_view.multiple_validations_completed,
|
||||
(self.device_table_view.update_multiple_device_validations,),
|
||||
),
|
||||
(self.request_ophyd_validation, (self.ophyd_test_view.change_device_configs,)),
|
||||
(
|
||||
self.device_table_view.device_configs_changed,
|
||||
(self.ophyd_test_view.change_device_configs,),
|
||||
),
|
||||
(
|
||||
self.device_table_view.device_config_in_sync_with_redis,
|
||||
(self._update_config_enabled_button,),
|
||||
),
|
||||
(self.device_table_view.device_row_dbl_clicked, (self._edit_device_action,)),
|
||||
]:
|
||||
for slot in slots:
|
||||
signal.connect(slot)
|
||||
|
||||
# Add toolbar
|
||||
self._add_toolbar()
|
||||
|
||||
# Build dock layout using shared helpers
|
||||
self._build_docks()
|
||||
|
||||
def _add_toolbar(self):
|
||||
self.toolbar = ModularToolBar(self)
|
||||
|
||||
# Add IO actions
|
||||
self._add_io_actions()
|
||||
self._add_table_actions()
|
||||
self.toolbar.show_bundles(["IO", "Table"])
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
def _build_docks(self) -> None:
|
||||
# Central device table
|
||||
self.device_table_view_dock = self.new(
|
||||
self.device_table_view,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
# Bottom area: docstrings
|
||||
self.dm_docs_view_dock = self.new(
|
||||
self.dm_docs_view,
|
||||
where="bottom",
|
||||
relative_to=self.device_table_view_dock,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
# Config view left of docstrings
|
||||
self.dm_config_view_dock = self.new(
|
||||
self.dm_config_view,
|
||||
where="left",
|
||||
relative_to=self.dm_docs_view_dock,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
# Right area: ophyd test + validation
|
||||
self.ophyd_test_dock_view = self.new(
|
||||
self.ophyd_widget_view,
|
||||
where="right",
|
||||
relative_to=self.device_table_view_dock,
|
||||
return_dock=True,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
self.set_layout_ratios(splitter_overrides={0: [7, 3], 1: [3, 7]})
|
||||
|
||||
def _add_io_actions(self):
|
||||
# Create IO bundle
|
||||
io_bundle = ToolbarBundle("IO", self.toolbar.components)
|
||||
|
||||
# Load from disk
|
||||
load = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_open",
|
||||
parent=self,
|
||||
tooltip="Load configuration file from disk",
|
||||
label_text="Load Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("load", load)
|
||||
load.action.triggered.connect(self._load_file_action)
|
||||
io_bundle.add_action("load")
|
||||
|
||||
# Add safe to disk
|
||||
save_to_disk = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_save",
|
||||
parent=self,
|
||||
tooltip="Save config to disk",
|
||||
label_text="Save Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("save_to_disk", save_to_disk)
|
||||
save_to_disk.action.triggered.connect(self._save_to_disk_action)
|
||||
io_bundle.add_action("save_to_disk")
|
||||
|
||||
# Add flush config in redis
|
||||
flush_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Flush current config in BEC Server",
|
||||
label_text="Flush loaded Config",
|
||||
)
|
||||
flush_redis.action.triggered.connect(self._flush_redis_action)
|
||||
self.toolbar.components.add_safe("flush_redis", flush_redis)
|
||||
io_bundle.add_action("flush_redis")
|
||||
|
||||
# Add load config from redis
|
||||
load_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cached",
|
||||
parent=self,
|
||||
tooltip="Load current config from BEC Server",
|
||||
label_text="Get loaded Config",
|
||||
)
|
||||
load_redis.action.triggered.connect(self._load_redis_action)
|
||||
self.toolbar.components.add_safe("load_redis", load_redis)
|
||||
io_bundle.add_action("load_redis")
|
||||
|
||||
# Update config action
|
||||
update_config_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cloud_upload",
|
||||
parent=self,
|
||||
tooltip="Update current config in BEC Server",
|
||||
label_text="Update Config",
|
||||
)
|
||||
update_config_redis.action.setEnabled(False)
|
||||
|
||||
update_config_redis.action.triggered.connect(self._update_redis_action)
|
||||
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
|
||||
io_bundle.add_action("update_config_redis")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(io_bundle)
|
||||
|
||||
# Table actions
|
||||
def _add_table_actions(self) -> None:
|
||||
table_bundle = ToolbarBundle("Table", self.toolbar.components)
|
||||
|
||||
# Reset composed view
|
||||
reset_composed = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Reset current composed config view",
|
||||
label_text="Reset Config View",
|
||||
)
|
||||
reset_composed.action.triggered.connect(self._reset_composed_view)
|
||||
self.toolbar.components.add_safe("reset_composed", reset_composed)
|
||||
table_bundle.add_action("reset_composed")
|
||||
|
||||
# Add device
|
||||
add_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="add",
|
||||
parent=self,
|
||||
tooltip="Add new device",
|
||||
label_text="Add Device",
|
||||
)
|
||||
add_device.action.triggered.connect(self._add_device_action)
|
||||
self.toolbar.components.add_safe("add_device", add_device)
|
||||
table_bundle.add_action("add_device")
|
||||
|
||||
# Remove device
|
||||
remove_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="remove",
|
||||
parent=self,
|
||||
tooltip="Remove device",
|
||||
label_text="Remove Device",
|
||||
)
|
||||
remove_device.action.triggered.connect(self._remove_device_action)
|
||||
self.toolbar.components.add_safe("remove_device", remove_device)
|
||||
table_bundle.add_action("remove_device")
|
||||
|
||||
# Rerun validation
|
||||
rerun_validation = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="checklist",
|
||||
parent=self,
|
||||
tooltip="Run device validation with 'connect' on selected devices",
|
||||
label_text="Validate Connection",
|
||||
)
|
||||
rerun_validation.action.triggered.connect(self._run_validate_connection)
|
||||
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
|
||||
table_bundle.add_action("rerun_validation")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(table_bundle)
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def _run_validate_connection(self, connect: bool = True):
|
||||
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
|
||||
configs = list(self.device_table_view.get_selected_device_configs())
|
||||
if not configs:
|
||||
configs = self.device_table_view.get_device_config()
|
||||
self.request_ophyd_validation.emit(configs, True, connect)
|
||||
|
||||
def _update_config_enabled_button(self, enabled: bool):
|
||||
action = self.toolbar.components.get_action("update_config_redis")
|
||||
action.action.setEnabled(not enabled)
|
||||
if enabled:
|
||||
action.action.setToolTip("Push current config to BEC Server")
|
||||
else:
|
||||
action.action.setToolTip("Current config is in sync with BEC Server, button disabled.")
|
||||
|
||||
@SafeSlot()
|
||||
def _load_file_action(self):
|
||||
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
|
||||
config_path = self._get_config_base_path()
|
||||
|
||||
# Implement the file loading logic here
|
||||
start_dir = os.path.abspath(config_path)
|
||||
file_path = self._get_file_path(start_dir, "open_file")
|
||||
if file_path:
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _get_config_base_path(self) -> str:
|
||||
"""Get the base path for device configurations."""
|
||||
try:
|
||||
plugin_path = plugin_repo_path()
|
||||
plugin_name = plugin_package_name()
|
||||
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = self._get_recovery_config_path()
|
||||
logger.warning(
|
||||
f"No plugin repository installed, fallback to recovery config path: {config_path}"
|
||||
)
|
||||
return config_path
|
||||
|
||||
def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str:
|
||||
ALLOWED_EXTS = [".yaml", ".yml"]
|
||||
filter_str = "YAML files (*.yaml *.yml);;All Files (*)"
|
||||
initial_filter = "YAML files (*.yaml *.yml);;"
|
||||
if mode == "open_file":
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
caption="Select Config File",
|
||||
dir=start_dir,
|
||||
filter=filter_str,
|
||||
selectedFilter=initial_filter,
|
||||
)
|
||||
else:
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
caption="Save Config File",
|
||||
dir=start_dir,
|
||||
filter=filter_str,
|
||||
selectedFilter=initial_filter,
|
||||
)
|
||||
if not file_path:
|
||||
return ""
|
||||
_, ext = os.path.splitext(file_path)
|
||||
if ext.lower() not in ALLOWED_EXTS:
|
||||
file_path += ".yaml"
|
||||
return file_path
|
||||
|
||||
def _load_config_from_file(self, file_path: str):
|
||||
"""
|
||||
Load device config from a given file path and update the device table view.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the configuration file.
|
||||
"""
|
||||
try:
|
||||
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
self._open_config_choice_dialog(config)
|
||||
|
||||
def _open_config_choice_dialog(self, config: List[dict]):
|
||||
"""
|
||||
Open a dialog to choose whether to replace or add the loaded config.
|
||||
|
||||
Args:
|
||||
config (List[dict]): List of device configurations loaded from the file.
|
||||
"""
|
||||
if len(self.device_table_view.get_device_config()) == 0:
|
||||
# If no config is composed yet, load directly
|
||||
self.device_table_view.set_device_config(config)
|
||||
return
|
||||
dialog = ConfigChoiceDialog(self)
|
||||
result = dialog.exec()
|
||||
if result == ConfigChoiceDialog.Result.REPLACE:
|
||||
self.device_table_view.set_device_config(config)
|
||||
elif result == ConfigChoiceDialog.Result.ADD:
|
||||
self.device_table_view.add_device_configs(config)
|
||||
|
||||
@SafeSlot()
|
||||
def _flush_redis_action(self):
|
||||
"""Action to flush the current config in Redis."""
|
||||
if self.client.device_manager is None:
|
||||
logger.error("No device manager connected, cannot load config from BEC Server.")
|
||||
return
|
||||
if len(self.client.device_manager.devices) == 0:
|
||||
logger.info("No devices in BEC Server, nothing to flush.")
|
||||
QMessageBox.information(
|
||||
self, "No Devices", "There is currently no config loaded on the BEC Server."
|
||||
)
|
||||
return
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Flush BEC Server Config",
|
||||
"Do you really want to flush the current config in BEC Server?",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.set_busy(enabled=True, text="Flushing configuration in BEC Server...")
|
||||
self.client.config.reset_config()
|
||||
logger.info("Successfully flushed configuration in BEC Server.")
|
||||
self.set_busy(enabled=False)
|
||||
# Check if config is in sync, enable load redis button
|
||||
self.device_table_view.device_config_in_sync_with_redis.emit(
|
||||
self.device_table_view._is_config_in_sync_with_redis()
|
||||
)
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
for config, config_status, connnection_status in validation_results.values():
|
||||
if connnection_status == ConnectionStatus.CONNECTED.value:
|
||||
self.device_table_view.update_device_validation(
|
||||
config, config_status, ConnectionStatus.CAN_CONNECT, ""
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_redis_action(self):
|
||||
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
|
||||
if self.client.device_manager is None:
|
||||
logger.error("No device manager connected, cannot load config from BEC Server.")
|
||||
return
|
||||
if not self.device_table_view.get_device_config():
|
||||
# If no config is composed yet, load directly
|
||||
self.device_table_view.set_device_config(
|
||||
self.client.device_manager._get_redis_device_config()
|
||||
)
|
||||
return
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Load currently active config in BEC Server",
|
||||
"Do you really want to discard the current config and reload?",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.device_table_view.set_device_config(
|
||||
self.client.device_manager._get_redis_device_config()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _update_redis_action(self) -> None | QMessageBox.StandardButton:
|
||||
"""Action to push the current composition to Redis using the upload dialog."""
|
||||
# Check if validations are still running
|
||||
if self.ophyd_test_view.running_ophyd_tests is True:
|
||||
return QMessageBox.warning(
|
||||
self, "Validation in Progress", "Please wait for the validation to finish."
|
||||
)
|
||||
|
||||
# Get all device configurations with their validation status
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
# Create and show upload dialog
|
||||
self._upload_redis_dialog = UploadRedisDialog(
|
||||
parent=self, device_configs=validation_results, ophyd_test_widget=self.ophyd_test_view
|
||||
)
|
||||
|
||||
# Show dialog
|
||||
reply = self._upload_redis_dialog.exec_()
|
||||
|
||||
if reply == UploadRedisDialog.UploadAction.OK:
|
||||
self._push_composition_to_redis(action="set")
|
||||
elif reply == UploadRedisDialog.UploadAction.CANCEL:
|
||||
self.ophyd_test_view.cancel_all_validations()
|
||||
|
||||
def _push_composition_to_redis(self, action: ConfigAction):
|
||||
"""Push the current device composition to Redis."""
|
||||
if action not in get_args(ConfigAction):
|
||||
logger.error(f"Invalid config action: {action} for uploading to BEC Server.")
|
||||
return
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
|
||||
threadpool = QThreadPool.globalInstance()
|
||||
comm = CommunicateConfigAction(self._config_helper, None, config, action)
|
||||
comm.signals.done.connect(self._handle_push_complete_to_communicator)
|
||||
comm.signals.error.connect(self._handle_exception_from_communicator)
|
||||
threadpool.start(comm)
|
||||
self.set_busy(enabled=True, text="Uploading configuration to BEC Server...")
|
||||
|
||||
def _handle_push_complete_to_communicator(self):
|
||||
"""Handle completion of the config push to Redis."""
|
||||
self.set_busy(enabled=False)
|
||||
self._update_validation_icons_after_upload()
|
||||
|
||||
def _handle_exception_from_communicator(self, exception: Exception):
|
||||
"""Handle exceptions from the config communicator."""
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Error Uploading Config",
|
||||
f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}",
|
||||
)
|
||||
self.set_busy(enabled=False)
|
||||
self._update_validation_icons_after_upload()
|
||||
|
||||
def _update_validation_icons_after_upload(self):
|
||||
"""Update validation icons after uploading config to Redis."""
|
||||
if self.client.device_manager is None:
|
||||
return
|
||||
device_names_in_session = list(self.client.device_manager.devices.keys())
|
||||
validation_results = self.device_table_view.get_validation_results()
|
||||
devices_to_update = []
|
||||
for config, config_status, connection_status in validation_results.values():
|
||||
if config["name"] in device_names_in_session:
|
||||
devices_to_update.append(
|
||||
(config, config_status, ConnectionStatus.CONNECTED.value, "")
|
||||
)
|
||||
self.device_table_view.update_multiple_device_validations(devices_to_update)
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_disk_action(self):
|
||||
"""Action for the 'save_to_disk' action to save the current config to disk."""
|
||||
# Check if plugin repo is installed...
|
||||
try:
|
||||
config_path = self._get_recovery_config_path()
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = os.path.abspath(os.path.expanduser("~"))
|
||||
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
|
||||
|
||||
# Implement the file loading logic here
|
||||
file_path = self._get_file_path(config_path, "save_file")
|
||||
if file_path:
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
|
||||
if os.path.exists(file_path):
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Overwrite File",
|
||||
f"The file '{file_path}' already exists. Do you want to overwrite it?",
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
with open(file_path, "w") as file:
|
||||
file.write(yaml.dump(config))
|
||||
|
||||
# Table actions
|
||||
@SafeSlot()
|
||||
def _reset_composed_view(self):
|
||||
"""Action for the 'reset_composed_view' action to reset the composed view."""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Clear View",
|
||||
"You are about to clear the current composed config view, please confirm...",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.device_table_view.clear_device_configs()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _edit_device_action(self, device_config: dict):
|
||||
"""Action to edit a selected device configuration."""
|
||||
dialog = DeviceFormDialog(parent=self, add_btn_text="Apply Changes")
|
||||
dialog.accepted_data.connect(self._update_device_to_table_from_dialog)
|
||||
dialog.set_device_config(device_config)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot()
|
||||
def _add_device_action(self):
|
||||
"""Action for the 'add_device' action to add a new device."""
|
||||
dialog = DeviceFormDialog(parent=self, add_btn_text="Add Device")
|
||||
dialog.accepted_data.connect(self._add_to_table_from_dialog)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _update_device_to_table_from_dialog(
|
||||
self,
|
||||
data: dict,
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
msg: str,
|
||||
old_device_name: str = "",
|
||||
):
|
||||
if old_device_name and old_device_name != data.get("name", ""):
|
||||
self.device_table_view.remove_device(old_device_name)
|
||||
self.device_table_view.update_device_configs([data])
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _add_to_table_from_dialog(
|
||||
self,
|
||||
data: dict,
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
msg: str,
|
||||
old_device_name: str = "",
|
||||
):
|
||||
self.device_table_view.add_device_configs([data])
|
||||
|
||||
@SafeSlot()
|
||||
def _remove_device_action(self):
|
||||
"""Action for the 'remove_device' action to remove a device."""
|
||||
configs = self.device_table_view.get_selected_device_configs()
|
||||
if not configs:
|
||||
QMessageBox.warning(
|
||||
self, "No devices selected", "Please select devices from the table to remove."
|
||||
)
|
||||
return
|
||||
if self.device_table_view._remove_configs_dialog([cfg["name"] for cfg in configs]):
|
||||
self.device_table_view.remove_device_configs(configs)
|
||||
|
||||
@SafeSlot(dict, int, int, str, str)
|
||||
def _ophyd_test_item_clicked_cb(
|
||||
self, device_config: dict, config_status: int, connection_status: int, msg: str, md_msg: str
|
||||
) -> None:
|
||||
self.validation_results.setMarkdown(md_msg)
|
||||
|
||||
def _get_recovery_config_path(self) -> str:
|
||||
"""Get the recovery config path from the log_writer config."""
|
||||
# pylint: disable=protected-access
|
||||
log_writer_config = self.client._service_config.config.get("log_writer", {})
|
||||
writer = DeviceConfigWriter(service_config=log_writer_config)
|
||||
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = QWidget()
|
||||
l = QVBoxLayout()
|
||||
w.setLayout(l)
|
||||
apply_theme("dark")
|
||||
button = DarkModeButton()
|
||||
l.addWidget(button)
|
||||
device_manager_view = DeviceManagerDisplayWidget()
|
||||
l.addWidget(device_manager_view)
|
||||
w.show()
|
||||
w.setWindowTitle("Device Manager View")
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
w.resize(width, height)
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,686 +1,73 @@
|
||||
from __future__ import annotations
|
||||
"""Module for Device Manager View."""
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import List, Literal
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
import yaml
|
||||
from bec_lib import config_helper
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.file_utils import DeviceConfigWriter
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtCore import Qt, QThreadPool, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
|
||||
DeviceManagerWidget,
|
||||
)
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.control.device_manager.components import (
|
||||
DeviceTableView,
|
||||
DMConfigView,
|
||||
DMOphydTest,
|
||||
DocstringView,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
|
||||
AvailableDeviceResources,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
PresetClassDeviceConfigDialog,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_yes_no_question = partial(
|
||||
QMessageBox.question,
|
||||
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
defaultButton=QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
|
||||
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
|
||||
class DeviceManagerView(ViewBase):
|
||||
"""
|
||||
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
|
||||
Works for horizontal or vertical splitters and sets matching stretch factors.
|
||||
A view for users to manage devices within the application.
|
||||
"""
|
||||
|
||||
def apply():
|
||||
n = splitter.count()
|
||||
if n == 0:
|
||||
return
|
||||
w = list(weights[:n]) + [1] * max(0, n - len(weights))
|
||||
w = [max(0.0, float(x)) for x in w]
|
||||
tot_w = sum(w)
|
||||
if tot_w <= 0:
|
||||
w = [1.0] * n
|
||||
tot_w = float(n)
|
||||
total_px = (
|
||||
splitter.width()
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal
|
||||
else splitter.height()
|
||||
)
|
||||
if total_px < 2:
|
||||
QTimer.singleShot(0, apply)
|
||||
return
|
||||
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
|
||||
diff = total_px - sum(sizes)
|
||||
if diff != 0:
|
||||
idx = max(range(n), key=lambda i: w[i])
|
||||
sizes[idx] = max(1, sizes[idx] + diff)
|
||||
splitter.setSizes(sizes)
|
||||
for i, wi in enumerate(w):
|
||||
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
|
||||
|
||||
QTimer.singleShot(0, apply)
|
||||
|
||||
|
||||
class ConfigChoiceDialog(QDialog):
|
||||
REPLACE = 1
|
||||
ADD = 2
|
||||
CANCEL = 0
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Load Config")
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
label = QLabel("Do you want to replace the current config or add to it?")
|
||||
label.setWordWrap(True)
|
||||
layout.addWidget(label)
|
||||
|
||||
# Buttons: equal size, stacked vertically
|
||||
self.replace_btn = QPushButton("Replace")
|
||||
self.add_btn = QPushButton("Add")
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
btn_layout = QHBoxLayout()
|
||||
for btn in (self.replace_btn, self.add_btn, self.cancel_btn):
|
||||
btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
btn_layout.addWidget(btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# Connect signals to explicit slots
|
||||
self.replace_btn.clicked.connect(self.accept_replace)
|
||||
self.add_btn.clicked.connect(self.accept_add)
|
||||
self.cancel_btn.clicked.connect(self.reject_cancel)
|
||||
|
||||
self._result = self.CANCEL
|
||||
|
||||
def accept_replace(self):
|
||||
self._result = self.REPLACE
|
||||
self.accept()
|
||||
|
||||
def accept_add(self):
|
||||
self._result = self.ADD
|
||||
self.accept()
|
||||
|
||||
def reject_cancel(self):
|
||||
self._result = self.CANCEL
|
||||
self.reject()
|
||||
|
||||
def result(self):
|
||||
return self._result
|
||||
|
||||
|
||||
AVAILABLE_RESOURCE_IS_READY = False
|
||||
|
||||
|
||||
class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, client=None, *args, **kwargs)
|
||||
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
self._shared_selection = SharedSelectionSignal()
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
self.dock_manager = QtAds.CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# Device Table View widget
|
||||
self.device_table_view = DeviceTableView(
|
||||
self, shared_selection_signal=self._shared_selection
|
||||
)
|
||||
self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self)
|
||||
self.device_table_view_dock.setWidget(self.device_table_view)
|
||||
|
||||
# Device Config View widget
|
||||
self.dm_config_view = DMConfigView(self)
|
||||
self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self)
|
||||
self.dm_config_view_dock.setWidget(self.dm_config_view)
|
||||
|
||||
# Docstring View
|
||||
self.dm_docs_view = DocstringView(self)
|
||||
self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self)
|
||||
self.dm_docs_view_dock.setWidget(self.dm_docs_view)
|
||||
|
||||
# Ophyd Test view
|
||||
self.ophyd_test_view = DMOphydTest(self)
|
||||
self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self)
|
||||
self.ophyd_test_dock_view.setWidget(self.ophyd_test_view)
|
||||
|
||||
# Help Inspector
|
||||
widget = QWidget(self)
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
self.help_inspector = HelpInspector(self)
|
||||
layout.addWidget(self.help_inspector)
|
||||
text_box = QTextEdit(self)
|
||||
text_box.setReadOnly(False)
|
||||
text_box.setPlaceholderText("Help text will appear here...")
|
||||
layout.addWidget(text_box)
|
||||
self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self)
|
||||
self.help_inspector_dock.setWidget(widget)
|
||||
|
||||
# Register callback
|
||||
self.help_inspector.bec_widget_help.connect(text_box.setMarkdown)
|
||||
|
||||
# Error Logs View
|
||||
self.error_logs_view = QTextEdit(self)
|
||||
self.error_logs_view.setReadOnly(True)
|
||||
self.error_logs_view.setPlaceholderText("Error logs will appear here...")
|
||||
self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self)
|
||||
self.error_logs_dock.setWidget(self.error_logs_view)
|
||||
self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown)
|
||||
|
||||
# Arrange widgets within the QtAds dock manager
|
||||
# Central widget area
|
||||
self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock)
|
||||
# Right area - should be pushed into view if something is active
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
self.ophyd_test_dock_view,
|
||||
self.central_dock_area,
|
||||
)
|
||||
# create bottom area (2-arg -> area)
|
||||
self.bottom_dock_area = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock
|
||||
)
|
||||
|
||||
# YAML view left of docstrings (docks relative to bottom area)
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area
|
||||
)
|
||||
|
||||
# Error/help area right of docstrings (dock relative to bottom area)
|
||||
area = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
self.help_inspector_dock,
|
||||
self.bottom_dock_area,
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area)
|
||||
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
dock.setFeature(QtAds.CDockWidget.DockWidgetClosable, False)
|
||||
dock.setFeature(QtAds.CDockWidget.DockWidgetFloatable, False)
|
||||
dock.setFeature(QtAds.CDockWidget.DockWidgetMovable, False)
|
||||
|
||||
# Apply stretch after the layout is done
|
||||
self.set_default_view([2, 8, 2], [7, 3])
|
||||
|
||||
for signal, slots in [
|
||||
(
|
||||
self.device_table_view.selected_devices,
|
||||
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
),
|
||||
(
|
||||
self.ophyd_test_view.device_validated,
|
||||
(self.device_table_view.update_device_validation,),
|
||||
),
|
||||
(
|
||||
self.device_table_view.device_configs_changed,
|
||||
(self.ophyd_test_view.change_device_configs,),
|
||||
),
|
||||
]:
|
||||
for slot in slots:
|
||||
signal.connect(slot)
|
||||
|
||||
# Once available resource is ready, add it to the view again
|
||||
if AVAILABLE_RESOURCE_IS_READY:
|
||||
# Available Resources Widget
|
||||
self.available_devices = AvailableDeviceResources(
|
||||
self, shared_selection_signal=self._shared_selection
|
||||
)
|
||||
self.available_devices_dock = QtAds.CDockWidget(
|
||||
self.dock_manager, "Available Devices", self
|
||||
)
|
||||
self.available_devices_dock.setWidget(self.available_devices)
|
||||
# Connect slots for available reosource
|
||||
for signal, slots in [
|
||||
(
|
||||
self.available_devices.selected_devices,
|
||||
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
),
|
||||
(
|
||||
self.device_table_view.device_configs_changed,
|
||||
(self.available_devices.mark_devices_used,),
|
||||
),
|
||||
(
|
||||
self.available_devices.add_selected_devices,
|
||||
(self.device_table_view.add_device_configs,),
|
||||
),
|
||||
(
|
||||
self.available_devices.del_selected_devices,
|
||||
(self.device_table_view.remove_device_configs,),
|
||||
),
|
||||
]:
|
||||
for slot in slots:
|
||||
signal.connect(slot)
|
||||
|
||||
# Add toolbar
|
||||
self._add_toolbar()
|
||||
|
||||
def _add_toolbar(self):
|
||||
self.toolbar = ModularToolBar(self)
|
||||
|
||||
# Add IO actions
|
||||
self._add_io_actions()
|
||||
self._add_table_actions()
|
||||
self.toolbar.show_bundles(["IO", "Table"])
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
def _add_io_actions(self):
|
||||
# Create IO bundle
|
||||
io_bundle = ToolbarBundle("IO", self.toolbar.components)
|
||||
|
||||
# Load from disk
|
||||
load = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_open",
|
||||
parent=self,
|
||||
tooltip="Load configuration file from disk",
|
||||
label_text="Load Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("load", load)
|
||||
load.action.triggered.connect(self._load_file_action)
|
||||
io_bundle.add_action("load")
|
||||
|
||||
# Add safe to disk
|
||||
save_to_disk = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_save",
|
||||
parent=self,
|
||||
tooltip="Save config to disk",
|
||||
label_text="Save Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("save_to_disk", save_to_disk)
|
||||
save_to_disk.action.triggered.connect(self._save_to_disk_action)
|
||||
io_bundle.add_action("save_to_disk")
|
||||
|
||||
# Add load config from redis
|
||||
load_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cached",
|
||||
parent=self,
|
||||
tooltip="Load current config from Redis",
|
||||
label_text="Get Current Config",
|
||||
)
|
||||
load_redis.action.triggered.connect(self._load_redis_action)
|
||||
self.toolbar.components.add_safe("load_redis", load_redis)
|
||||
io_bundle.add_action("load_redis")
|
||||
|
||||
# Update config action
|
||||
update_config_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cloud_upload",
|
||||
parent=self,
|
||||
tooltip="Update current config in Redis",
|
||||
label_text="Update Config",
|
||||
)
|
||||
update_config_redis.action.setEnabled(False)
|
||||
update_config_redis.action.triggered.connect(self._update_redis_action)
|
||||
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
|
||||
io_bundle.add_action("update_config_redis")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(io_bundle)
|
||||
|
||||
# Table actions
|
||||
|
||||
def _add_table_actions(self) -> None:
|
||||
table_bundle = ToolbarBundle("Table", self.toolbar.components)
|
||||
|
||||
# Reset composed view
|
||||
reset_composed = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Reset current composed config view",
|
||||
label_text="Reset Config",
|
||||
)
|
||||
reset_composed.action.triggered.connect(self._reset_composed_view)
|
||||
self.toolbar.components.add_safe("reset_composed", reset_composed)
|
||||
table_bundle.add_action("reset_composed")
|
||||
|
||||
# Add device
|
||||
add_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="add",
|
||||
parent=self,
|
||||
tooltip="Add new device",
|
||||
label_text="Add Device",
|
||||
)
|
||||
add_device.action.triggered.connect(self._add_device_action)
|
||||
self.toolbar.components.add_safe("add_device", add_device)
|
||||
table_bundle.add_action("add_device")
|
||||
|
||||
# Remove device
|
||||
remove_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="remove",
|
||||
parent=self,
|
||||
tooltip="Remove device",
|
||||
label_text="Remove Device",
|
||||
)
|
||||
remove_device.action.triggered.connect(self._remove_device_action)
|
||||
self.toolbar.components.add_safe("remove_device", remove_device)
|
||||
table_bundle.add_action("remove_device")
|
||||
|
||||
# Rerun validation
|
||||
rerun_validation = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="checklist",
|
||||
parent=self,
|
||||
tooltip="Run device validation with 'connect' on selected devices",
|
||||
label_text="Validate Connection",
|
||||
)
|
||||
rerun_validation.action.triggered.connect(self._rerun_validation_action)
|
||||
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
|
||||
table_bundle.add_action("rerun_validation")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(table_bundle)
|
||||
|
||||
# IO actions
|
||||
def _coming_soon(self):
|
||||
return QMessageBox.question(
|
||||
self,
|
||||
"Not implemented yet",
|
||||
"This feature has not been implemented yet, will be coming soon...!!",
|
||||
QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.device_manager_widget = DeviceManagerWidget(parent=self)
|
||||
self.set_content(self.device_manager_widget)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_file_action(self):
|
||||
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
|
||||
try:
|
||||
plugin_path = plugin_repo_path()
|
||||
plugin_name = plugin_package_name()
|
||||
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = self._get_recovery_config_path()
|
||||
logger.warning(
|
||||
f"No plugin repository installed, fallback to recovery config path: {config_path}"
|
||||
)
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the view becomes current/visible.
|
||||
|
||||
# Implement the file loading logic here
|
||||
start_dir = os.path.abspath(config_path)
|
||||
file_path = self._get_file_path(start_dir, "open_file")
|
||||
if file_path:
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str:
|
||||
if mode == "open_file":
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, caption="Select Config File", dir=start_dir
|
||||
)
|
||||
else:
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, caption="Save Config File", dir=start_dir
|
||||
)
|
||||
return file_path
|
||||
|
||||
def _load_config_from_file(self, file_path: str):
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
Load device config from a given file path and update the device table view.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the configuration file.
|
||||
"""
|
||||
try:
|
||||
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
self._open_config_choice_dialog(config)
|
||||
|
||||
def _open_config_choice_dialog(self, config: List[dict]):
|
||||
"""
|
||||
Open a dialog to choose whether to replace or add the loaded config.
|
||||
|
||||
Args:
|
||||
config (List[dict]): List of device configurations loaded from the file.
|
||||
"""
|
||||
dialog = ConfigChoiceDialog(self)
|
||||
if dialog.exec():
|
||||
if dialog.result() == ConfigChoiceDialog.REPLACE:
|
||||
self.device_table_view.set_device_config(config)
|
||||
elif dialog.result() == ConfigChoiceDialog.ADD:
|
||||
self.device_table_view.add_device_configs(config)
|
||||
|
||||
# TODO would we ever like to add the current config to an existing composition
|
||||
@SafeSlot()
|
||||
def _load_redis_action(self):
|
||||
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Load currently active config",
|
||||
"Do you really want to discard the current config and reload?",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None:
|
||||
self.device_table_view.set_device_config(
|
||||
self.client.device_manager._get_redis_device_config()
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
@SafeSlot()
|
||||
def _update_redis_action(self) -> None | QMessageBox.StandardButton:
|
||||
"""Action to push the current composition to Redis"""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Push composition to Redis",
|
||||
"Do you really want to replace the active configuration in the BEC server with the current composition? ",
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
if self.device_table_view.table.contains_invalid_devices():
|
||||
return QMessageBox.warning(
|
||||
self, "Validation has errors!", "Please resolve before proceeding."
|
||||
)
|
||||
if self.ophyd_test_view.validation_running():
|
||||
return QMessageBox.warning(
|
||||
self, "Validation has not completed.", "Please wait for the validation to finish."
|
||||
)
|
||||
self._push_composition_to_redis()
|
||||
|
||||
def _push_composition_to_redis(self):
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()}
|
||||
threadpool = QThreadPool.globalInstance()
|
||||
comm = CommunicateConfigAction(self._config_helper, None, config, "set")
|
||||
threadpool.start(comm)
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_disk_action(self):
|
||||
"""Action for the 'save_to_disk' action to save the current config to disk."""
|
||||
# Check if plugin repo is installed...
|
||||
try:
|
||||
config_path = self._get_recovery_config_path()
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = os.path.abspath(os.path.expanduser("~"))
|
||||
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
|
||||
|
||||
# Implement the file loading logic here
|
||||
file_path = self._get_file_path(config_path, "save_file")
|
||||
if file_path:
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
|
||||
with open(file_path, "w") as file:
|
||||
file.write(yaml.dump(config))
|
||||
|
||||
# Table actions
|
||||
@SafeSlot()
|
||||
def _reset_composed_view(self):
|
||||
"""Action for the 'reset_composed_view' action to reset the composed view."""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Clear View",
|
||||
"You are about to clear the current composed config view, please confirm...",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.device_table_view.clear_device_configs()
|
||||
|
||||
# TODO Bespoke Form to add a new device
|
||||
@SafeSlot()
|
||||
def _add_device_action(self):
|
||||
"""Action for the 'add_device' action to add a new device."""
|
||||
dialog = PresetClassDeviceConfigDialog(parent=self)
|
||||
dialog.accepted_data.connect(self._add_to_table_from_dialog)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _add_to_table_from_dialog(self, data):
|
||||
self.device_table_view.add_device_configs([data])
|
||||
|
||||
@SafeSlot()
|
||||
def _remove_device_action(self):
|
||||
"""Action for the 'remove_device' action to remove a device."""
|
||||
self.device_table_view.remove_selected_rows()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def _rerun_validation_action(self, connect: bool = True):
|
||||
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
|
||||
configs = self.device_table_view.table.selected_configs()
|
||||
self.ophyd_test_view.change_device_configs(configs, True, connect)
|
||||
|
||||
####### Default view has to be done with setting up splitters ########
|
||||
def set_default_view(
|
||||
self, horizontal_weights: list, vertical_weights: list
|
||||
): # TODO separate logic for all ads based widgets
|
||||
"""Apply initial weights to every horizontal and vertical splitter.
|
||||
|
||||
Examples:
|
||||
horizontal_weights = [1, 3, 2, 1]
|
||||
vertical_weights = [3, 7] # top:bottom = 30:70
|
||||
"""
|
||||
splitters_h = []
|
||||
splitters_v = []
|
||||
for splitter in self.findChildren(QSplitter):
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal:
|
||||
splitters_h.append(splitter)
|
||||
elif splitter.orientation() == Qt.Orientation.Vertical:
|
||||
splitters_v.append(splitter)
|
||||
|
||||
def apply_all():
|
||||
for s in splitters_h:
|
||||
set_splitter_weights(s, horizontal_weights)
|
||||
for s in splitters_v:
|
||||
set_splitter_weights(s, vertical_weights)
|
||||
|
||||
QTimer.singleShot(0, apply_all)
|
||||
|
||||
def set_stretch(
|
||||
self, *, horizontal=None, vertical=None
|
||||
): # TODO separate logic for all ads based widgets
|
||||
"""Update splitter weights and re-apply to all splitters.
|
||||
|
||||
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||
for convenience: horizontal roles = {"left","center","right"},
|
||||
vertical roles = {"top","bottom"}.
|
||||
"""
|
||||
|
||||
def _coerce_h(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [
|
||||
float(x.get("left", 1)),
|
||||
float(x.get("center", x.get("middle", 1))),
|
||||
float(x.get("right", 1)),
|
||||
]
|
||||
return None
|
||||
|
||||
def _coerce_v(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
|
||||
return None
|
||||
|
||||
h = _coerce_h(horizontal)
|
||||
v = _coerce_v(vertical)
|
||||
if h is None:
|
||||
h = [1, 1, 1]
|
||||
if v is None:
|
||||
v = [1, 1]
|
||||
self.set_default_view(h, v)
|
||||
|
||||
def _get_recovery_config_path(self) -> str:
|
||||
"""Get the recovery config path from the log_writer config."""
|
||||
# pylint: disable=protected-access
|
||||
log_writer_config = self.client._service_config.config.get("log_writer", {})
|
||||
writer = DeviceConfigWriter(service_config=log_writer_config)
|
||||
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
|
||||
self.device_manager_widget.on_enter()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = QWidget()
|
||||
l = QVBoxLayout()
|
||||
w.setLayout(l)
|
||||
apply_theme("dark")
|
||||
button = DarkModeButton()
|
||||
l.addWidget(button)
|
||||
device_manager_view = DeviceManagerView()
|
||||
l.addWidget(device_manager_view)
|
||||
# config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
|
||||
# cfg = yaml_load(config_path)
|
||||
# cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
|
||||
|
||||
# # config = device_manager_view.client.device_manager._get_redis_device_config()
|
||||
# device_manager_view.device_table_view.set_device_config(cfg)
|
||||
w.show()
|
||||
w.setWindowTitle("Device Manager View")
|
||||
w.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
_app = BECMainApp()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
_app.resize(width, height)
|
||||
device_manager_view = DeviceManagerView()
|
||||
_app.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
id="device_manager",
|
||||
widget=device_manager_view.device_manager_widget,
|
||||
mini_text="DM",
|
||||
)
|
||||
_app.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -9,7 +9,9 @@ from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import (
|
||||
DeviceManagerDisplayWidget,
|
||||
)
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
@@ -18,8 +20,10 @@ logger = bec_logger.logger
|
||||
|
||||
class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent)
|
||||
super().__init__(parent=parent, client=client)
|
||||
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
@@ -27,14 +31,19 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
self.setLayout(self.stacked_layout)
|
||||
|
||||
# Add device manager view
|
||||
self.device_manager_view = DeviceManagerView()
|
||||
self.stacked_layout.addWidget(self.device_manager_view)
|
||||
self.device_manager_display = DeviceManagerDisplayWidget(parent=self, client=self.client)
|
||||
self.stacked_layout.addWidget(self.device_manager_display)
|
||||
|
||||
# Add overlay widget
|
||||
self._overlay_widget = QtWidgets.QWidget(self)
|
||||
self._customize_overlay()
|
||||
self.stacked_layout.addWidget(self._overlay_widget)
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
self._initialized = False
|
||||
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the widget becomes visible."""
|
||||
if self._initialized is False:
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
|
||||
def _customize_overlay(self):
|
||||
self._overlay_widget.setAutoFillBackground(True)
|
||||
@@ -60,33 +69,17 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
def _load_config_from_file_clicked(self):
|
||||
"""Handle click on 'Load Config From File' button."""
|
||||
start_dir = os.path.expanduser("~")
|
||||
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||
self, caption="Select Config File", dir=start_dir
|
||||
)
|
||||
if file_path:
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _load_config_from_file(self, file_path: str):
|
||||
try:
|
||||
config = yaml_load(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
config_list = []
|
||||
for name, cfg in config.items():
|
||||
config_list.append(cfg)
|
||||
config_list[-1]["name"] = name
|
||||
self.device_manager_view.device_table_view.set_device_config(config_list)
|
||||
# self.device_manager_view.ophyd_test.on_device_config_update(config)
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||
self.device_manager_display._load_file_action()
|
||||
self._initialized = True # Set initialized to True after first load
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_display)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_config_clicked(self):
|
||||
"""Handle click on 'Load Current Config' button."""
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
self.device_manager_view.device_table_view.set_device_config(config)
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||
self.device_manager_display.device_table_view.set_device_config(config)
|
||||
self._initialized = True # Set initialized to True after first load
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_display)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
+487
-158
@@ -44,6 +44,7 @@ _Widgets = {
|
||||
"MonacoWidget": "MonacoWidget",
|
||||
"MotorMap": "MotorMap",
|
||||
"MultiWaveform": "MultiWaveform",
|
||||
"PdfViewerWidget": "PdfViewerWidget",
|
||||
"PositionIndicator": "PositionIndicator",
|
||||
"PositionerBox": "PositionerBox",
|
||||
"PositionerBox2D": "PositionerBox2D",
|
||||
@@ -1147,48 +1148,6 @@ class Curve(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class DMConfigView(RPCBase):
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class DMOphydTest(RPCBase):
|
||||
"""Widget to test device configurations using ophyd devices."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class DapComboBox(RPCBase):
|
||||
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
|
||||
|
||||
@@ -1620,6 +1579,24 @@ class EllipticalROI(RPCBase):
|
||||
class Heatmap(RPCBase):
|
||||
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -1807,6 +1784,29 @@ class Heatmap(RPCBase):
|
||||
Show the outer axes of the plot widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -1835,6 +1835,48 @@ class Heatmap(RPCBase):
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
"""
|
||||
Set X-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@x_log.setter
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
"""
|
||||
Set X-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_log(self) -> "bool":
|
||||
"""
|
||||
Set Y-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@y_log.setter
|
||||
@rpc_call
|
||||
def y_log(self) -> "bool":
|
||||
"""
|
||||
Set Y-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def legend_label_size(self) -> "int":
|
||||
"""
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@legend_label_size.setter
|
||||
@rpc_call
|
||||
def legend_label_size(self) -> "int":
|
||||
"""
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
@@ -1849,18 +1891,6 @@ class Heatmap(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -1924,20 +1954,6 @@ class Heatmap(RPCBase):
|
||||
Get the maximum value of the v_range.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def autorange(self) -> "bool":
|
||||
@@ -2177,6 +2193,24 @@ class Heatmap(RPCBase):
|
||||
class Image(RPCBase):
|
||||
"""Image widget for displaying 2D data."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -2364,6 +2398,29 @@ class Image(RPCBase):
|
||||
Show the outer axes of the plot widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -2392,6 +2449,48 @@ class Image(RPCBase):
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
"""
|
||||
Set X-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@x_log.setter
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
"""
|
||||
Set X-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_log(self) -> "bool":
|
||||
"""
|
||||
Set Y-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@y_log.setter
|
||||
@rpc_call
|
||||
def y_log(self) -> "bool":
|
||||
"""
|
||||
Set Y-axis to log scale if True, linear if False.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def legend_label_size(self) -> "int":
|
||||
"""
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@legend_label_size.setter
|
||||
@rpc_call
|
||||
def legend_label_size(self) -> "int":
|
||||
"""
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
@@ -2406,18 +2505,6 @@ class Image(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -2481,20 +2568,6 @@ class Image(RPCBase):
|
||||
Get the maximum value of the v_range.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@lock_aspect_ratio.setter
|
||||
@rpc_call
|
||||
def lock_aspect_ratio(self) -> "bool":
|
||||
"""
|
||||
Whether the aspect ratio is locked.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def autorange(self) -> "bool":
|
||||
@@ -3223,6 +3296,24 @@ class MonacoWidget(RPCBase):
|
||||
class MotorMap(RPCBase):
|
||||
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -3424,6 +3515,15 @@ class MotorMap(RPCBase):
|
||||
Lock aspect ratio of the plot widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -3494,16 +3594,18 @@ class MotorMap(RPCBase):
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
None
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@@ -3604,7 +3706,9 @@ class MotorMap(RPCBase):
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def map(self, x_name: "str", y_name: "str", validate_bec: "bool" = True) -> "None":
|
||||
def map(
|
||||
self, x_name: "str", y_name: "str", validate_bec: "bool" = True, suppress_errors=False
|
||||
) -> "None":
|
||||
"""
|
||||
Set the x and y motor names.
|
||||
|
||||
@@ -3612,6 +3716,7 @@ class MotorMap(RPCBase):
|
||||
x_name(str): The name of the x motor.
|
||||
y_name(str): The name of the y motor.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
@@ -3629,10 +3734,56 @@ class MotorMap(RPCBase):
|
||||
dict: Data of the motor map.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_motor(self) -> "str":
|
||||
"""
|
||||
Name of the motor shown on the X axis.
|
||||
"""
|
||||
|
||||
@x_motor.setter
|
||||
@rpc_call
|
||||
def x_motor(self) -> "str":
|
||||
"""
|
||||
Name of the motor shown on the X axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def y_motor(self) -> "str":
|
||||
"""
|
||||
Name of the motor shown on the Y axis.
|
||||
"""
|
||||
|
||||
@y_motor.setter
|
||||
@rpc_call
|
||||
def y_motor(self) -> "str":
|
||||
"""
|
||||
Name of the motor shown on the Y axis.
|
||||
"""
|
||||
|
||||
|
||||
class MultiWaveform(RPCBase):
|
||||
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -3834,6 +3985,15 @@ class MultiWaveform(RPCBase):
|
||||
Lock aspect ratio of the plot widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -3918,18 +4078,6 @@ class MultiWaveform(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -4074,6 +4222,137 @@ class MultiWaveform(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class PdfViewerWidget(RPCBase):
|
||||
"""A widget to display PDF documents with toolbar controls."""
|
||||
|
||||
@rpc_call
|
||||
def load_pdf(self, file_path: str):
|
||||
"""
|
||||
Load a PDF file into the viewer.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the PDF file to load.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def zoom_in(self):
|
||||
"""
|
||||
Zoom in the PDF view.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def zoom_out(self):
|
||||
"""
|
||||
Zoom out the PDF view.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def fit_to_width(self):
|
||||
"""
|
||||
Fit PDF to width.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def fit_to_page(self):
|
||||
"""
|
||||
Fit PDF to page.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def reset_zoom(self):
|
||||
"""
|
||||
Reset zoom to 100% (1.0 factor).
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def previous_page(self):
|
||||
"""
|
||||
Go to previous page.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def next_page(self):
|
||||
"""
|
||||
Go to next page.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def toggle_continuous_scroll(self, checked: bool):
|
||||
"""
|
||||
Toggle between single page and continuous scroll mode.
|
||||
|
||||
Args:
|
||||
checked (bool): True to enable continuous scroll, False for single page mode.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def page_spacing(self):
|
||||
"""
|
||||
Get the spacing between pages in continuous scroll mode.
|
||||
"""
|
||||
|
||||
@page_spacing.setter
|
||||
@rpc_call
|
||||
def page_spacing(self):
|
||||
"""
|
||||
Get the spacing between pages in continuous scroll mode.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def side_margins(self):
|
||||
"""
|
||||
Get the horizontal margins (side spacing) around the PDF content.
|
||||
"""
|
||||
|
||||
@side_margins.setter
|
||||
@rpc_call
|
||||
def side_margins(self):
|
||||
"""
|
||||
Get the horizontal margins (side spacing) around the PDF content.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def go_to_first_page(self):
|
||||
"""
|
||||
Go to the first page.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def go_to_last_page(self):
|
||||
"""
|
||||
Go to the last page.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def jump_to_page(self, page_number: int):
|
||||
"""
|
||||
Jump to a specific page number (1-based index).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def current_page(self):
|
||||
"""
|
||||
Get the current page number (1-based index).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def current_file_path(self):
|
||||
"""
|
||||
Get the current PDF file path.
|
||||
"""
|
||||
|
||||
@current_file_path.setter
|
||||
@rpc_call
|
||||
def current_file_path(self):
|
||||
"""
|
||||
Get the current PDF file path.
|
||||
"""
|
||||
|
||||
|
||||
class PositionIndicator(RPCBase):
|
||||
"""Display a position within a defined range, e.g. motor limits."""
|
||||
|
||||
@@ -4211,6 +4490,34 @@ class PositionerBox2D(RPCBase):
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_controls_hor(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for horizontal control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
@enable_controls_hor.setter
|
||||
@rpc_call
|
||||
def enable_controls_hor(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for horizontal control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_controls_ver(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for vertical control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
@enable_controls_ver.setter
|
||||
@rpc_call
|
||||
def enable_controls_ver(self) -> "bool":
|
||||
"""
|
||||
Persisted switch for vertical control buttons (tweak/step).
|
||||
"""
|
||||
|
||||
|
||||
class PositionerControlLine(RPCBase):
|
||||
"""A widget that controls a single device."""
|
||||
@@ -4361,8 +4668,8 @@ class RectangularROI(RPCBase):
|
||||
@rpc_call
|
||||
def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple":
|
||||
"""
|
||||
Returns the coordinates of a rectangle's corners. Supports returning them
|
||||
as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
Returns the coordinates of a rectangle's corners, rectangle center and dimensions.
|
||||
Supports returning them as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary with
|
||||
@@ -4370,7 +4677,7 @@ class RectangularROI(RPCBase):
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, where the format
|
||||
dict | tuple: The rectangle's corner coordinates, rectangle center and dimensions, where the format
|
||||
depends on the `typed` parameter.
|
||||
"""
|
||||
|
||||
@@ -4789,6 +5096,24 @@ class ScatterCurve(RPCBase):
|
||||
|
||||
|
||||
class ScatterWaveform(RPCBase):
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -4990,6 +5315,15 @@ class ScatterWaveform(RPCBase):
|
||||
Lock aspect ratio of the plot widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -5074,18 +5408,6 @@ class ScatterWaveform(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -5410,6 +5732,12 @@ class VSCodeEditor(RPCBase):
|
||||
class Waveform(RPCBase):
|
||||
"""Widget for plotting waveforms."""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
@@ -5422,23 +5750,6 @@ class Waveform(RPCBase):
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def enable_toolbar(self) -> "bool":
|
||||
@@ -5640,6 +5951,15 @@ class Waveform(RPCBase):
|
||||
Lock aspect ratio of the plot widget.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def auto_range_x(self) -> "bool":
|
||||
@@ -5668,15 +5988,6 @@ class Waveform(RPCBase):
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def auto_range(self, value: "bool" = True):
|
||||
"""
|
||||
On demand apply autorange to the plot item based on the visible curves.
|
||||
|
||||
Args:
|
||||
value(bool): If True, apply autorange to the visible curves.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def x_log(self) -> "bool":
|
||||
@@ -5733,6 +6044,23 @@ class Waveform(RPCBase):
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def curves(self) -> "list[Curve]":
|
||||
@@ -5861,9 +6189,9 @@ class Waveform(RPCBase):
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
color(str): The color of the curve.
|
||||
label(str): The label of the curve.
|
||||
dap(str): The dap model to use for the curve, only available for sync devices.
|
||||
If not specified, none will be added.
|
||||
Use the same string as is the name of the LMFit model.
|
||||
dap(str): The dap model to use for the curve. When provided, a DAP curve is
|
||||
attached automatically for device, history, or custom data sources. Use
|
||||
the same string as the LMFit model name.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
@@ -5883,11 +6211,12 @@ class Waveform(RPCBase):
|
||||
**kwargs,
|
||||
) -> "Curve":
|
||||
"""
|
||||
Create a new DAP curve referencing the existing device curve `device_label`,
|
||||
with the data processing model `dap_name`.
|
||||
Create a new DAP curve referencing the existing curve `device_label`, with the
|
||||
data processing model `dap_name`. DAP curves can be attached to curves that
|
||||
originate from live devices, history, or fully custom data sources.
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the device curve to add DAP to.
|
||||
device_label(str): The label of the source curve to add DAP to.
|
||||
dap_name(str): The name of the DAP model to use.
|
||||
color(str): The color of the curve.
|
||||
dap_oversample(int): The oversampling factor for the DAP curve.
|
||||
|
||||
@@ -392,7 +392,8 @@ class BECGuiClient(RPCBase):
|
||||
timeout = 60
|
||||
# Wait for 'bec' gui to be registered, this may take some time
|
||||
# After 60s timeout. Should this raise an exception on timeout?
|
||||
while time.time() < time.time() + timeout:
|
||||
start = time.monotonic()
|
||||
while time.monotonic() < start + timeout:
|
||||
if len(list(self._server_registry.keys())) < 2 or not hasattr(
|
||||
self, self._anchor_widget
|
||||
):
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.examples.general_app.web_links import BECWebLinksMixin
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECGeneralApp(QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
super(BECGeneralApp, self).__init__(parent)
|
||||
ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui")
|
||||
self.load_ui(ui_file_path)
|
||||
|
||||
self.resize(1280, 720)
|
||||
|
||||
self.ini_ui()
|
||||
|
||||
def ini_ui(self):
|
||||
self._setup_icons()
|
||||
self._hook_menubar_docs()
|
||||
self._hook_theme_bar()
|
||||
|
||||
def load_ui(self, ui_file):
|
||||
loader = UILoader(self)
|
||||
self.ui = loader.loader(ui_file)
|
||||
self.setCentralWidget(self.ui)
|
||||
|
||||
def _hook_menubar_docs(self):
|
||||
# BEC Docs
|
||||
self.ui.action_BEC_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
|
||||
# BEC Widgets Docs
|
||||
self.ui.action_BEC_widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
|
||||
# Bug report
|
||||
self.ui.action_bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
|
||||
|
||||
def change_theme(self, theme):
|
||||
apply_theme(theme)
|
||||
|
||||
def _setup_icons(self):
|
||||
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
|
||||
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
|
||||
computer_icon = QIcon.fromTheme("computer")
|
||||
widget_icon = QIcon(os.path.join(MODULE_PATH, "assets", "designer_icons", "dock_area.png"))
|
||||
|
||||
self.ui.action_BEC_docs.setIcon(help_icon)
|
||||
self.ui.action_BEC_widgets_docs.setIcon(help_icon)
|
||||
self.ui.action_bug_report.setIcon(bug_icon)
|
||||
|
||||
self.ui.central_tab.setTabIcon(0, widget_icon)
|
||||
self.ui.central_tab.setTabIcon(1, computer_icon)
|
||||
|
||||
def _hook_theme_bar(self):
|
||||
self.ui.action_light.setCheckable(True)
|
||||
self.ui.action_dark.setCheckable(True)
|
||||
|
||||
# Create an action group to make sure only one can be checked at a time
|
||||
theme_group = QActionGroup(self)
|
||||
theme_group.addAction(self.ui.action_light)
|
||||
theme_group.addAction(self.ui.action_dark)
|
||||
theme_group.setExclusive(True)
|
||||
|
||||
# Connect the actions to the theme change method
|
||||
|
||||
self.ui.action_light.triggered.connect(lambda: self.change_theme("light"))
|
||||
self.ui.action_dark.triggered.connect(lambda: self.change_theme("dark"))
|
||||
|
||||
self.ui.action_dark.trigger()
|
||||
|
||||
|
||||
def main(): # pragma: no cover
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "BEC-General-App.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
main_window = BECGeneralApp()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,262 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1718</width>
|
||||
<height>1139</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<property name="tabShape">
|
||||
<enum>QTabWidget::TabShape::Rounded</enum>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="central_tab">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="dock_area_tab">
|
||||
<attribute name="title">
|
||||
<string>Dock Area</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECDockArea" name="dock_area"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="vscode_tab">
|
||||
<attribute name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::Computer"/>
|
||||
</attribute>
|
||||
<attribute name="title">
|
||||
<string>Visual Studio Code</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="VSCodeEditor" name="vscode"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1718</width>
|
||||
<height>31</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuHelp">
|
||||
<property name="title">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<addaction name="action_BEC_docs"/>
|
||||
<addaction name="action_BEC_widgets_docs"/>
|
||||
<addaction name="action_bug_report"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuTheme">
|
||||
<property name="title">
|
||||
<string>Theme</string>
|
||||
</property>
|
||||
<addaction name="action_light"/>
|
||||
<addaction name="action_dark"/>
|
||||
</widget>
|
||||
<addaction name="menuTheme"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<widget class="QDockWidget" name="dock_scan_control">
|
||||
<property name="windowTitle">
|
||||
<string>Scan Control</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="ScanControl" name="scan_control"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="dock_status_2">
|
||||
<property name="windowTitle">
|
||||
<string>BEC Service Status</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_3">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_5">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECStatusBox" name="bec_status_box_2"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QDockWidget" name="dock_queue">
|
||||
<property name="windowTitle">
|
||||
<string>Scan Queue</string>
|
||||
</property>
|
||||
<attribute name="dockWidgetArea">
|
||||
<number>2</number>
|
||||
</attribute>
|
||||
<widget class="QWidget" name="dockWidgetContents_4">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="BECQueue" name="bec_queue">
|
||||
<row/>
|
||||
<column/>
|
||||
<column/>
|
||||
<column/>
|
||||
<item row="0" column="0"/>
|
||||
<item row="0" column="1"/>
|
||||
<item row="0" column="2"/>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<action name="action_BEC_docs">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEC Docs</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_BEC_widgets_docs">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEC Widgets Docs</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_bug_report">
|
||||
<property name="icon">
|
||||
<iconset theme="QIcon::ThemeIcon::DialogError"/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Bug Report</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_light">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Light</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_dark">
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Dark</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>WebsiteWidget</class>
|
||||
<extends>QWebEngineView</extends>
|
||||
<header>website_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECQueue</class>
|
||||
<extends>QTableWidget</extends>
|
||||
<header>bec_queue</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ScanControl</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>scan_control</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>VSCodeEditor</class>
|
||||
<extends>WebsiteWidget</extends>
|
||||
<header>vs_code_editor</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECStatusBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_status_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECDockArea</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dock_area</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>QWebEngineView</class>
|
||||
<extends></extends>
|
||||
<header location="global">QtWebEngineWidgets/QWebEngineView</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,15 +0,0 @@
|
||||
import webbrowser
|
||||
|
||||
|
||||
class BECWebLinksMixin:
|
||||
@staticmethod
|
||||
def open_bec_docs():
|
||||
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
|
||||
|
||||
@staticmethod
|
||||
def open_bec_widgets_docs():
|
||||
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
|
||||
|
||||
@staticmethod
|
||||
def open_bec_bug_report():
|
||||
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
|
||||
@@ -0,0 +1,93 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECList(QListWidget):
|
||||
"""List Widget that manages ListWidgetItems with associated widgets."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._widget_map: dict[str, tuple[QListWidgetItem, QWidget]] = {}
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self._widget_map
|
||||
|
||||
def add_widget_item(self, key: str, widget: QWidget):
|
||||
"""
|
||||
Add a widget to the list, mapping is associated with the given key.
|
||||
|
||||
Args:
|
||||
key (str): Key to associate with the widget.
|
||||
widget (QWidget): Widget to add to the list.
|
||||
"""
|
||||
if key in self._widget_map:
|
||||
self.remove_widget_item(key)
|
||||
|
||||
item = QListWidgetItem()
|
||||
item.setSizeHint(widget.sizeHint())
|
||||
self.insertItem(0, item)
|
||||
self.setItemWidget(item, widget)
|
||||
self._widget_map[key] = (item, widget)
|
||||
|
||||
def remove_widget_item(self, key: str):
|
||||
"""
|
||||
Remove a widget by identifier key.
|
||||
|
||||
Args:
|
||||
key (str): Key associated with the widget to remove.
|
||||
"""
|
||||
if key not in self._widget_map:
|
||||
return
|
||||
|
||||
item, widget = self._widget_map.pop(key)
|
||||
row = self.row(item)
|
||||
self.takeItem(row)
|
||||
try:
|
||||
widget.close()
|
||||
except Exception:
|
||||
logger.debug(f"Could not close widget properly for key: {key}.")
|
||||
try:
|
||||
widget.deleteLater()
|
||||
except Exception:
|
||||
logger.debug(f"Could not delete widget properly for key: {key}.")
|
||||
|
||||
def clear_widgets(self):
|
||||
"""Remove and destroy all widget items."""
|
||||
for key in list(self._widget_map.keys()):
|
||||
self.remove_widget_item(key)
|
||||
self._widget_map.clear()
|
||||
self.clear()
|
||||
|
||||
def get_widget(self, key: str) -> QWidget | None:
|
||||
"""Return the widget for a given key."""
|
||||
entry = self._widget_map.get(key)
|
||||
return entry[1] if entry else None
|
||||
|
||||
def get_item(self, key: str) -> QListWidgetItem | None:
|
||||
"""Return the QListWidgetItem for a given key."""
|
||||
entry = self._widget_map.get(key)
|
||||
return entry[0] if entry else None
|
||||
|
||||
def get_widgets(self) -> list[QWidget]:
|
||||
"""Return all managed widgets."""
|
||||
return [w for _, w in self._widget_map.values()]
|
||||
|
||||
def get_widget_for_item(self, item: QListWidgetItem) -> QWidget | None:
|
||||
"""Return the widget associated with a given QListWidgetItem."""
|
||||
for itm, widget in self._widget_map.values():
|
||||
if itm == item:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def get_item_for_widget(self, widget: QWidget) -> QListWidgetItem | None:
|
||||
"""Return the QListWidgetItem associated with a given widget."""
|
||||
for itm, w in self._widget_map.values():
|
||||
if w == widget:
|
||||
return itm
|
||||
return None
|
||||
|
||||
def get_all_keys(self) -> list[str]:
|
||||
"""Return all keys for managed widgets."""
|
||||
return list(self._widget_map.keys())
|
||||
@@ -10,6 +10,8 @@ from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout,
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
RAISE_ERROR_DEFAULT = False
|
||||
|
||||
|
||||
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
|
||||
"""
|
||||
@@ -159,7 +161,7 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
_slot_params = {
|
||||
"popup_error": bool(slot_kwargs.pop("popup_error", False)),
|
||||
"verify_sender": bool(slot_kwargs.pop("verify_sender", False)),
|
||||
"raise_error": bool(slot_kwargs.pop("raise_error", False)),
|
||||
"raise_error": bool(slot_kwargs.pop("raise_error", RAISE_ERROR_DEFAULT)),
|
||||
}
|
||||
|
||||
def error_managed(method):
|
||||
|
||||
@@ -81,10 +81,11 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
self._form_grid_container = QWidget(parent=self)
|
||||
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
|
||||
self._form_grid = QWidget(parent=self._form_grid_container)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
|
||||
self._widget_types: dict | None = None
|
||||
@@ -105,11 +106,11 @@ class TypedForm(BECWidget, QWidget):
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
label = QLabel(item.name)
|
||||
label = QLabel(parent=self._form_grid, text=item.name)
|
||||
label.setProperty("_model_field_name", item.name)
|
||||
label.setToolTip(item.info.description or item.name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
grid.addWidget(widget, row, 1)
|
||||
@@ -128,19 +129,17 @@ class TypedForm(BECWidget, QWidget):
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
if (old_layout := self._form_grid.layout()) is not None:
|
||||
while old_layout.count():
|
||||
item = old_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
old_layout.deleteLater()
|
||||
self._form_grid.deleteLater()
|
||||
gl = self._form_grid.layout()
|
||||
while w := gl.takeAt(0):
|
||||
w = w.widget()
|
||||
if hasattr(w, "teardown"):
|
||||
w.teardown()
|
||||
w.deleteLater()
|
||||
self._form_grid_container.layout().removeWidget(self._form_grid)
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
self._form_grid_container.layout().addWidget(self._form_grid)
|
||||
|
||||
self.update_size()
|
||||
|
||||
def update_size(self):
|
||||
@@ -149,7 +148,7 @@ class TypedForm(BECWidget, QWidget):
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout()
|
||||
new_grid = QGridLayout(self)
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
return new_grid
|
||||
|
||||
|
||||
@@ -4,14 +4,16 @@ import inspect
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, UnionType
|
||||
from types import GenericAlias, NoneType, UnionType
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Final,
|
||||
Generic,
|
||||
Iterable,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
OrderedDict,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
@@ -74,7 +76,7 @@ class FormItemSpec(BaseModel):
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
item_type: type | UnionType | GenericAlias
|
||||
item_type: type | UnionType | GenericAlias | Optional[Any]
|
||||
name: str
|
||||
info: FieldInfo = FieldInfo()
|
||||
pretty_display: bool = Field(
|
||||
@@ -193,6 +195,10 @@ class DynamicFormItem(QWidget):
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
@SafeSlot()
|
||||
def clear(self, *_):
|
||||
return
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self.setEnabled(False)
|
||||
if button := getattr(self, "_clear_button", None):
|
||||
@@ -209,11 +215,17 @@ class DynamicFormItem(QWidget):
|
||||
self._layout.addWidget(self._clear_button)
|
||||
# the widget added in _add_main_widget must implement .clear() if value is not required
|
||||
self._clear_button.setToolTip("Clear value or reset to default.")
|
||||
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
|
||||
self._clear_button.clicked.connect(self.clear) # type: ignore
|
||||
|
||||
def _value_changed(self, *_, **__):
|
||||
self.valueChanged.emit()
|
||||
|
||||
def teardown(self):
|
||||
self._layout.deleteLater()
|
||||
self._layout.removeWidget(self._main_widget)
|
||||
self._main_widget.deleteLater()
|
||||
self._main_widget = None
|
||||
|
||||
|
||||
class StrFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
@@ -551,11 +563,14 @@ class StrLiteralFormItem(DynamicFormItem):
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.currentIndex() == -1:
|
||||
return None
|
||||
return self._main_widget.currentText()
|
||||
|
||||
def setValue(self, value: str | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
return
|
||||
for i in range(self._main_widget.count()):
|
||||
if self._main_widget.itemText(i) == value:
|
||||
self._main_widget.setCurrentIndex(i)
|
||||
@@ -566,6 +581,18 @@ class StrLiteralFormItem(DynamicFormItem):
|
||||
self._main_widget.setCurrentIndex(-1)
|
||||
|
||||
|
||||
class OptionalStrLiteralFormItem(StrLiteralFormItem):
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QComboBox()
|
||||
self._options = get_args(get_args(self._spec.info.annotation)[0])
|
||||
for opt in self._options:
|
||||
self._main_widget.addItem(opt)
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _ItemTypeFn(Protocol):
|
||||
def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ...
|
||||
@@ -575,13 +602,28 @@ WidgetTypeRegistry = OrderedDict[
|
||||
str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn]
|
||||
]
|
||||
|
||||
|
||||
def _is_string_literal(t: type):
|
||||
return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str}
|
||||
|
||||
|
||||
def _is_optional_string_literal(t: type):
|
||||
if not hasattr(t, "__args__"):
|
||||
return False
|
||||
if len(t.__args__) != 2:
|
||||
return False
|
||||
if _is_string_literal(t.__args__[0]) and t.__args__[1] is NoneType:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
|
||||
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
|
||||
# and delete/insert keys or change the order
|
||||
"literal_str": (
|
||||
lambda spec: type(spec.info.annotation) is type(Literal[""])
|
||||
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
|
||||
StrLiteralFormItem,
|
||||
"literal_str": (lambda spec: _is_string_literal(spec.info.annotation), StrLiteralFormItem),
|
||||
"optional_literal_str": (
|
||||
lambda spec: _is_optional_string_literal(spec.info.annotation),
|
||||
OptionalStrLiteralFormItem,
|
||||
),
|
||||
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
|
||||
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
|
||||
@@ -635,6 +677,8 @@ if __name__ == "__main__": # pragma: no cover
|
||||
value5: int | None = Field()
|
||||
value6: list[int] = Field()
|
||||
value7: list = Field()
|
||||
literal: Literal["a", "b", "c"]
|
||||
nullable_literal: Literal["a", "b", "c"] | None = None
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
@@ -642,7 +686,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
w.setLayout(layout)
|
||||
items = []
|
||||
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
||||
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
|
||||
spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
|
||||
layout.addWidget(QLabel(field_name), i, 0)
|
||||
widg = widget_from_type(spec)(spec=spec)
|
||||
items.append(widg)
|
||||
|
||||
@@ -11,7 +11,7 @@ from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
@@ -129,16 +129,44 @@ class RPCServer:
|
||||
# Run with rpc registry broadcast, but only once
|
||||
with RPCRegister.delayed_broadcast():
|
||||
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
if method == "raise" and hasattr(
|
||||
obj, "setWindowState"
|
||||
): # special case for raising windows, should work even if minimized
|
||||
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
|
||||
# The procedure is as follows:
|
||||
# 1. Get the current window state to check if the window is minimized and remove minimized flag
|
||||
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
|
||||
# and call raise_() and activateWindow()
|
||||
# This forces gnome to raise the window even if focus stealing is prevented
|
||||
# 3. Flag for stay on top is removed again to restore the original window state
|
||||
# 4. Finally, we call show() to ensure the window is visible
|
||||
|
||||
state = getattr(obj, "windowState", lambda: Qt.WindowNoState)()
|
||||
target_state = state | Qt.WindowActive
|
||||
if state & Qt.WindowMinimized:
|
||||
target_state &= ~Qt.WindowMinimized
|
||||
obj.setWindowState(target_state)
|
||||
if hasattr(obj, "showNormal") and state & Qt.WindowMinimized:
|
||||
obj.showNormal()
|
||||
if hasattr(obj, "raise_"):
|
||||
obj.setWindowFlags(obj.windowFlags() | Qt.WindowStaysOnTopHint)
|
||||
obj.raise_()
|
||||
if hasattr(obj, "activateWindow"):
|
||||
obj.activateWindow()
|
||||
obj.setWindowFlags(obj.windowFlags() & ~Qt.WindowStaysOnTopHint)
|
||||
obj.show()
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
@@ -201,6 +229,7 @@ class RPCServer:
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
max_size=1,
|
||||
expire=60,
|
||||
)
|
||||
|
||||
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
|
||||
|
||||
@@ -21,7 +21,17 @@ from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
PROPERTY_TO_SKIP = ["palette", "font", "windowIcon", "windowIconText", "locale", "styleSheet"]
|
||||
PROPERTY_TO_SKIP = [
|
||||
"palette",
|
||||
"font",
|
||||
"windowIcon",
|
||||
"windowIconText",
|
||||
"locale",
|
||||
"styleSheet",
|
||||
"updatesEnabled",
|
||||
"objectName",
|
||||
"visible",
|
||||
]
|
||||
|
||||
|
||||
class WidgetStateManager:
|
||||
@@ -110,16 +120,8 @@ class WidgetStateManager:
|
||||
prop = meta.property(i)
|
||||
name = prop.name()
|
||||
|
||||
# Skip persisting QWidget visibility because container widgets (e.g. tab
|
||||
# stacks, dock managers) manage that state themselves. Restoring a saved
|
||||
# False can permanently hide a widget, while forcing True makes hidden
|
||||
# tabs show on top. Leave the property to the parent widget instead.
|
||||
if name == "visible":
|
||||
continue
|
||||
|
||||
if (
|
||||
name == "objectName"
|
||||
or name in PROPERTY_TO_SKIP
|
||||
name in PROPERTY_TO_SKIP
|
||||
or not prop.isReadable()
|
||||
or not prop.isWritable()
|
||||
or not prop.isStored() # can be extended to fine filter
|
||||
@@ -176,7 +178,7 @@ class WidgetStateManager:
|
||||
for i in range(meta.propertyCount()):
|
||||
prop = meta.property(i)
|
||||
name = prop.name()
|
||||
if name == "visible":
|
||||
if name in PROPERTY_TO_SKIP:
|
||||
continue
|
||||
if settings.contains(name):
|
||||
value = settings.value(name)
|
||||
|
||||
@@ -941,30 +941,14 @@ class AdvancedDockArea(DockAreaWidget):
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QTabWidget
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
dispatcher = BECDispatcher(gui_id="ads")
|
||||
window = BECMainWindowNoRPC()
|
||||
central = QWidget()
|
||||
layout = QVBoxLayout(central)
|
||||
window.setCentralWidget(central)
|
||||
|
||||
# two dock areas stacked vertically no instance ids
|
||||
ads = AdvancedDockArea(mode="creator", enable_profile_management=True)
|
||||
ads2 = AdvancedDockArea(mode="creator", enable_profile_management=True)
|
||||
layout.addWidget(ads, 1)
|
||||
layout.addWidget(ads2, 1)
|
||||
|
||||
# two dock areas inside a tab widget
|
||||
tabs = QTabWidget(parent=central)
|
||||
ads3 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab3")
|
||||
ads4 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab4")
|
||||
tabs.addTab(ads3, "Workspace 3")
|
||||
tabs.addTab(ads4, "Workspace 4")
|
||||
layout.addWidget(tabs, 1)
|
||||
ads = AdvancedDockArea(mode="creator", enable_profile_management=True, root_widget=True)
|
||||
|
||||
window.setCentralWidget(ads)
|
||||
window.show()
|
||||
window.resize(800, 1000)
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
if tab_with is not None and relative_to is not None:
|
||||
raise ValueError("Specify either 'tab_with' or 'relative_to', not both.")
|
||||
|
||||
dock = CDockWidget(widget.objectName())
|
||||
dock = CDockWidget(self.dock_manager, widget.objectName(), self)
|
||||
dock.setWidget(widget)
|
||||
dock._dock_preferences = dict(dock_preferences or {})
|
||||
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True)
|
||||
|
||||
@@ -5,7 +5,6 @@ from qtpy.QtCore import QMimeData, Qt, Signal
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
|
||||
|
||||
@@ -49,6 +48,8 @@ class CollapsibleSection(QWidget):
|
||||
|
||||
# Create header button
|
||||
self.header_button = QPushButton()
|
||||
# Apply theme variant for title styling
|
||||
self.header_button.setProperty("variant", "title")
|
||||
self.header_button.clicked.connect(self.toggle_expanded)
|
||||
|
||||
# Enable drag and drop for reordering
|
||||
@@ -105,23 +106,6 @@ class CollapsibleSection(QWidget):
|
||||
self.header_button.setIcon(icon)
|
||||
self.header_button.setText(self.title)
|
||||
|
||||
# Get theme colors
|
||||
palette = get_theme_palette()
|
||||
|
||||
self.header_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
icon-size: 20px 20px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
def toggle_expanded(self):
|
||||
"""Toggle the expanded state and update size policy"""
|
||||
self.expanded = not self.expanded
|
||||
|
||||
+128
-56
@@ -14,13 +14,14 @@ from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from enum import Enum, auto
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.alarm_handler import Alarms # external enum
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import ErrorInfo
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from qtpy.QtCore import QObject, QTimer
|
||||
@@ -28,6 +29,7 @@ from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidg
|
||||
|
||||
from bec_widgets import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
|
||||
|
||||
@@ -53,10 +55,10 @@ DARK_PALETTE = {
|
||||
}
|
||||
|
||||
LIGHT_PALETTE = {
|
||||
"base": "#e9ecef",
|
||||
"title": "#212121",
|
||||
"body": "#424242",
|
||||
"separator": "rgba(0,0,0,40)",
|
||||
"base": "#f5f5f7",
|
||||
"title": "#111827",
|
||||
"body": "#374151",
|
||||
"separator": "rgba(15,23,42,40)",
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +110,7 @@ class NotificationToast(QFrame):
|
||||
self._kind = kind if isinstance(kind, SeverityKind) else SeverityKind(kind)
|
||||
self._traceback = traceback
|
||||
self._accent_color = QtGui.QColor(SEVERITY[self._kind.value]["color"])
|
||||
self._accent_alpha = 50
|
||||
self.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
||||
|
||||
self.created = datetime.now()
|
||||
@@ -379,22 +382,31 @@ class NotificationToast(QFrame):
|
||||
|
||||
# buttons (text colour)
|
||||
base_btn_color = palette["title"]
|
||||
card_bg = QtGui.QColor(palette["base"])
|
||||
# tune card background and hover contrast per theme
|
||||
if theme == "light":
|
||||
card_bg.setAlphaF(0.98)
|
||||
btn_hover = self._accent_color.darker(105).name()
|
||||
else:
|
||||
card_bg.setAlphaF(0.88)
|
||||
btn_hover = self._accent_color.name()
|
||||
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
#NotificationToast {
|
||||
background: transparent;
|
||||
f"""
|
||||
#NotificationToast {{
|
||||
background: {card_bg.name(QtGui.QColor.HexArgb)};
|
||||
border-radius: 12px;
|
||||
color: %s;
|
||||
}
|
||||
#NotificationToast QPushButton {
|
||||
color: {base_btn_color};
|
||||
border: 1px solid {palette["separator"]};
|
||||
}}
|
||||
#NotificationToast QPushButton {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: %s;
|
||||
color: {base_btn_color};
|
||||
font-size: 14px;
|
||||
}
|
||||
#NotificationToast QPushButton:hover { color: %s; }
|
||||
}}
|
||||
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
|
||||
"""
|
||||
% (base_btn_color, base_btn_color, self._accent_color.name())
|
||||
)
|
||||
# traceback panel colours
|
||||
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
|
||||
@@ -407,6 +419,37 @@ class NotificationToast(QFrame):
|
||||
"""
|
||||
)
|
||||
|
||||
# icon glyph vs badge background: darker badge, lighter icon in light mode
|
||||
icon_fg = "#ffffff" if theme == "light" else self._accent_color.name()
|
||||
icon = material_icon(
|
||||
icon_name=SEVERITY[self._kind.value]["icon"],
|
||||
color=icon_fg,
|
||||
filled=True,
|
||||
size=(24, 24),
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
self._icon_btn.setIcon(icon)
|
||||
|
||||
badge_bg = QtGui.QColor(self._accent_color)
|
||||
if theme == "light":
|
||||
# darken and strengthen the badge on light cards for contrast
|
||||
badge_bg = badge_bg.darker(115)
|
||||
badge_bg.setAlphaF(0.70)
|
||||
else:
|
||||
badge_bg.setAlphaF(0.30)
|
||||
icon_bg = badge_bg.name(QtGui.QColor.HexArgb)
|
||||
self._icon_btn.setStyleSheet(
|
||||
f"""
|
||||
QToolButton {{
|
||||
background: {icon_bg};
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
# stronger accent wash in light mode, slightly stronger in dark too
|
||||
self._accent_alpha = 110 if theme == "light" else 60
|
||||
self.update()
|
||||
|
||||
########################################
|
||||
@@ -488,7 +531,9 @@ class NotificationToast(QFrame):
|
||||
# accent gradient, fades to transparent
|
||||
grad = QtGui.QLinearGradient(0, 0, self.width() * 0.7, 0)
|
||||
accent = QtGui.QColor(self._accent_color)
|
||||
accent.setAlpha(50)
|
||||
if getattr(self, "_theme", "dark") == "light":
|
||||
accent = accent.darker(115)
|
||||
accent.setAlpha(getattr(self, "_accent_alpha", 50))
|
||||
grad.setColorAt(0.0, accent)
|
||||
fade = QtGui.QColor(self._accent_color)
|
||||
fade.setAlpha(0)
|
||||
@@ -690,7 +735,6 @@ class NotificationCentre(QScrollArea):
|
||||
toast.notification_id = notification_id
|
||||
broker = BECNotificationBroker()
|
||||
toast.closed.connect(lambda nid=notification_id: broker.notification_closed.emit(nid))
|
||||
toast.expired.connect(lambda nid=notification_id: broker.notification_closed.emit(nid))
|
||||
toast.closed.connect(lambda: self._hide_notification(toast))
|
||||
toast.expired.connect(lambda t=toast: self._handle_expire(t))
|
||||
toast.expanded.connect(self._adjust_height)
|
||||
@@ -1016,32 +1060,55 @@ class BECNotificationBroker(BECConnector, QObject):
|
||||
"""
|
||||
Called when a new alarm arrives. Builds and pushes a toast to each centre
|
||||
with a shared notification_id, and hooks its close/expire signals.
|
||||
|
||||
Args:
|
||||
msg(dict): The message containing alarm details.
|
||||
meta(dict): Metadata about the alarm.
|
||||
"""
|
||||
msg = msg or {}
|
||||
meta = meta or {}
|
||||
|
||||
centres = WidgetIO.find_widgets(NotificationCentre)
|
||||
kind = self._banner_kind_from_severity(msg.get("severity", 0))
|
||||
|
||||
# Normalise the incoming info payload (can be ErrorInfo, dict or missing entirely)
|
||||
raw_info = msg.get("info")
|
||||
if isinstance(raw_info, dict):
|
||||
try:
|
||||
raw_info = ErrorInfo(**raw_info)
|
||||
except Exception:
|
||||
raw_info = None
|
||||
|
||||
notification_id = getattr(raw_info, "id", None) or uuid4().hex
|
||||
|
||||
# build title and body
|
||||
scan_id = meta.get("scan_id")
|
||||
scan_number = meta.get("scan_number")
|
||||
formatted_trace = self._err_util.format_traceback(msg.get("msg", ""))
|
||||
short_msg = self._err_util.parse_error_message(formatted_trace)
|
||||
title = msg.get("alarm_type", "Alarm")
|
||||
alarm_type = msg.get("alarm_type") or getattr(raw_info, "exception_type", None) or "Alarm"
|
||||
title = alarm_type
|
||||
if scan_number:
|
||||
title += f" - Scan #{scan_number}"
|
||||
body_text = short_msg
|
||||
# build detailed traceback
|
||||
sections: list[str] = []
|
||||
if scan_id:
|
||||
sections.extend(["-------- SCAN_ID --------\n", scan_id])
|
||||
sections.extend(["-------- TRACEBACK --------", formatted_trace])
|
||||
source = msg.get("source")
|
||||
if source:
|
||||
source_pretty = json.dumps(source, indent=4, default=str)
|
||||
sections.extend(["", "-------- SOURCE --------", source_pretty])
|
||||
detailed_trace = "\n".join(sections)
|
||||
|
||||
trace_text = getattr(raw_info, "error_message", None) or msg.get("msg") or ""
|
||||
compact_msg = getattr(raw_info, "compact_error_message", None)
|
||||
|
||||
# Prefer the compact message; fall back to parsing the traceback for a human‑readable snippet
|
||||
body_text = compact_msg or self._err_util.parse_error_message(trace_text)
|
||||
|
||||
# build detailed traceback for the expandable panel
|
||||
detailed_trace: str | None = None
|
||||
if trace_text:
|
||||
sections: list[str] = []
|
||||
if scan_id:
|
||||
sections.extend(["-------- SCAN_ID --------\n", scan_id])
|
||||
sections.extend(["-------- TRACEBACK --------", trace_text])
|
||||
detailed_trace = "\n".join(sections)
|
||||
|
||||
lifetime = 0 if kind == SeverityKind.MAJOR else 5_000
|
||||
|
||||
# generate one ID for all toasts of this event
|
||||
notification_id = uuid4().hex
|
||||
if notification_id in self._active_notifications:
|
||||
return # already posted
|
||||
# record this notification for future centres
|
||||
self._active_notifications[notification_id] = {
|
||||
"title": title,
|
||||
@@ -1059,9 +1126,8 @@ class BECNotificationBroker(BECConnector, QObject):
|
||||
lifetime_ms=lifetime,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
# broadcast any close or expire
|
||||
# broadcast close events (expiry is handled locally to keep history)
|
||||
toast.closed.connect(lambda nid=notification_id: self.notification_closed.emit(nid))
|
||||
toast.expired.connect(lambda nid=notification_id: self.notification_closed.emit(nid))
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_scan_status(self, msg: dict, meta: dict) -> None:
|
||||
@@ -1086,6 +1152,13 @@ class BECNotificationBroker(BECConnector, QObject):
|
||||
Translate an integer severity (0/1/2) into a SeverityKind enum.
|
||||
Unknown values fall back to SeverityKind.WARNING.
|
||||
"""
|
||||
if isinstance(severity, SeverityKind):
|
||||
return severity
|
||||
if isinstance(severity, str):
|
||||
try:
|
||||
return SeverityKind(severity)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return SeverityKind[Alarms(severity).name] # e.g. WARNING → SeverityKind.WARNING
|
||||
except (ValueError, KeyError):
|
||||
@@ -1164,10 +1237,10 @@ class DemoWindow(QMainWindow): # pragma: no cover
|
||||
|
||||
# ----- wiring ------------------------------------------------------------
|
||||
self._counter = 1
|
||||
self.info_btn.clicked.connect(lambda: self._post("info"))
|
||||
self.warning_btn.clicked.connect(lambda: self._post("warning"))
|
||||
self.minor_btn.clicked.connect(lambda: self._post("minor"))
|
||||
self.major_btn.clicked.connect(lambda: self._post("major"))
|
||||
self.info_btn.clicked.connect(lambda: self._post(SeverityKind.INFO))
|
||||
self.warning_btn.clicked.connect(lambda: self._post(SeverityKind.WARNING))
|
||||
self.minor_btn.clicked.connect(lambda: self._post(SeverityKind.MINOR))
|
||||
self.major_btn.clicked.connect(lambda: self._post(SeverityKind.MAJOR))
|
||||
# Raise buttons simulate alarms
|
||||
self.raise_warning_btn.clicked.connect(lambda: self._raise_error(Alarms.WARNING))
|
||||
self.raise_minor_btn.clicked.connect(lambda: self._raise_error(Alarms.MINOR))
|
||||
@@ -1183,30 +1256,28 @@ class DemoWindow(QMainWindow): # pragma: no cover
|
||||
indicator.hide_all_requested.connect(self.notification_centre.hide_all)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _post(self, kind):
|
||||
expire = 0 if kind == "error" else 5000
|
||||
trace = (
|
||||
'Traceback (most recent call last):\n File "<stdin>", line 1\nZeroDivisionError: 1/0'
|
||||
if kind == "error"
|
||||
else None
|
||||
)
|
||||
self.notification_centre.add_notification(
|
||||
title=f"{kind.capitalize()} #{self._counter}",
|
||||
body="Lorem ipsum dolor sit amet.",
|
||||
kind=SeverityKind(kind),
|
||||
lifetime_ms=expire,
|
||||
traceback=trace,
|
||||
)
|
||||
def _post(self, kind: SeverityKind):
|
||||
"""
|
||||
Send a simple notification through the broker (non-error case).
|
||||
"""
|
||||
msg = {
|
||||
"severity": kind.value, # handled by broker for SeverityKind
|
||||
"alarm_type": f"{kind.value.capitalize()}",
|
||||
"msg": f"{kind.value.capitalize()} #{self._counter}",
|
||||
}
|
||||
self.notification_broker.post_notification(msg, meta={})
|
||||
self._counter += 1
|
||||
|
||||
def _raise_error(self, severity):
|
||||
"""Simulate an error that would be caught by the notification broker."""
|
||||
self.notification_broker.client.connector.raise_alarm(
|
||||
severity=severity,
|
||||
alarm_type="ValueError",
|
||||
source={"device": "samx", "source": "async_file_writer"},
|
||||
msg=f"test alarm",
|
||||
metadata={"test": 1},
|
||||
info=ErrorInfo(
|
||||
id=uuid4().hex,
|
||||
exception_type="ValueError",
|
||||
error_message="An example error occurred in DemoWindowApp.",
|
||||
compact_error_message="An example error occurred.",
|
||||
),
|
||||
)
|
||||
|
||||
# this part is same as implemented in the BECMainWindow
|
||||
@@ -1225,6 +1296,7 @@ class DemoWindow(QMainWindow): # pragma: no cover
|
||||
|
||||
def main(): # pragma: no cover
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
win = DemoWindow()
|
||||
win.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -12,4 +12,4 @@ class BECWebLinksMixin:
|
||||
|
||||
@staticmethod
|
||||
def open_bec_bug_report():
|
||||
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")
|
||||
webbrowser.open("https://github.com/bec-project/bec_widgets/issues")
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
|
||||
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -42,7 +42,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
RPC = True
|
||||
PLUGIN = True
|
||||
SCAN_PROGRESS_WIDTH = 100 # px
|
||||
STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds
|
||||
SCAN_PROGRESS_HEIGHT = 12 # px
|
||||
|
||||
def __init__(self, parent=None, window_title: str = "BEC", **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
@@ -193,8 +193,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self._scan_progress_bar_simple.show_remaining_time = False
|
||||
self._scan_progress_bar_simple.show_source_label = False
|
||||
self._scan_progress_bar_simple.progressbar.label_template = ""
|
||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
|
||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
|
||||
self._scan_progress_bar_full = ScanProgressBar(self)
|
||||
self._scan_progress_hover = HoverWidget(
|
||||
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
|
||||
@@ -211,62 +211,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
self._scan_progress_bar_with_separator.layout.addWidget(separator)
|
||||
self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover)
|
||||
|
||||
# Set Size
|
||||
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(self._scan_progress_bar_target_width)
|
||||
|
||||
self.status_bar.addWidget(self._scan_progress_bar_with_separator)
|
||||
|
||||
# Visibility logic
|
||||
self._scan_progress_bar_with_separator.hide()
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(0)
|
||||
|
||||
# Timer for hiding logic
|
||||
self._scan_progress_hide_timer = QTimer(self)
|
||||
self._scan_progress_hide_timer.setSingleShot(True)
|
||||
self._scan_progress_hide_timer.setInterval(self.STATUS_BAR_WIDGETS_EXPIRE_TIME)
|
||||
self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
|
||||
|
||||
# Show / hide behaviour
|
||||
self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar)
|
||||
self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar)
|
||||
|
||||
def _show_scan_progress_bar(self):
|
||||
if self._scan_progress_hide_timer.isActive():
|
||||
self._scan_progress_hide_timer.stop()
|
||||
if self._scan_progress_bar_with_separator.isVisible():
|
||||
return
|
||||
|
||||
# Make visible and reset width
|
||||
self._scan_progress_bar_with_separator.show()
|
||||
self._scan_progress_bar_with_separator.setMaximumWidth(0)
|
||||
|
||||
self._show_container_anim = QPropertyAnimation(
|
||||
self._scan_progress_bar_with_separator, b"maximumWidth", self
|
||||
)
|
||||
self._show_container_anim.setDuration(300)
|
||||
self._show_container_anim.setStartValue(0)
|
||||
self._show_container_anim.setEndValue(self._scan_progress_bar_target_width)
|
||||
self._show_container_anim.setEasingCurve(QEasingCurve.OutCubic)
|
||||
self._show_container_anim.start()
|
||||
|
||||
def _delay_hide_scan_progress_bar(self):
|
||||
"""Start the countdown to hide the scan progress bar."""
|
||||
if hasattr(self, "_scan_progress_hide_timer"):
|
||||
self._scan_progress_hide_timer.start()
|
||||
|
||||
def _animate_hide_scan_progress_bar(self):
|
||||
"""Shrink container to the right, then hide."""
|
||||
self._hide_container_anim = QPropertyAnimation(
|
||||
self._scan_progress_bar_with_separator, b"maximumWidth", self
|
||||
)
|
||||
self._hide_container_anim.setDuration(300)
|
||||
self._hide_container_anim.setStartValue(self._scan_progress_bar_with_separator.width())
|
||||
self._hide_container_anim.setEndValue(0)
|
||||
self._hide_container_anim.setEasingCurve(QEasingCurve.InCubic)
|
||||
self._hide_container_anim.finished.connect(self._scan_progress_bar_with_separator.hide)
|
||||
self._hide_container_anim.start()
|
||||
|
||||
def _add_separator(self, separate_object: bool = False) -> QWidget | None:
|
||||
"""
|
||||
Add a vertically centred separator to the status bar or just return it as a separate object.
|
||||
@@ -452,8 +398,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
# Timer cleanup
|
||||
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
||||
self._client_info_expire_timer.stop()
|
||||
if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
|
||||
self._scan_progress_hide_timer.stop()
|
||||
|
||||
########################################
|
||||
# Status bar widgets cleanup
|
||||
|
||||
+54
-1
@@ -34,7 +34,17 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "attach", "detach", "screenshot"]
|
||||
USER_ACCESS = [
|
||||
"set_positioner_hor",
|
||||
"set_positioner_ver",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
"enable_controls_hor",
|
||||
"enable_controls_hor.setter",
|
||||
"enable_controls_ver",
|
||||
"enable_controls_ver.setter",
|
||||
]
|
||||
|
||||
device_changed_hor = Signal(str, str)
|
||||
device_changed_ver = Signal(str, str)
|
||||
@@ -65,6 +75,8 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
self._dialog = None
|
||||
self._hide_device_selection = False
|
||||
self._hide_device_boxes = False
|
||||
self._enable_controls_hor = True
|
||||
self._enable_controls_ver = True
|
||||
if self.current_path == "":
|
||||
self.current_path = os.path.dirname(__file__)
|
||||
self.init_ui()
|
||||
@@ -285,6 +297,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
self.on_device_readback_hor,
|
||||
self._device_ui_components_hv("horizontal"),
|
||||
)
|
||||
self._apply_controls_enabled("horizontal")
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def on_device_change_ver(self, old_device: str, new_device: str):
|
||||
@@ -304,6 +317,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
self.on_device_readback_ver,
|
||||
self._device_ui_components_hv("vertical"),
|
||||
)
|
||||
self._apply_controls_enabled("vertical")
|
||||
|
||||
def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents:
|
||||
if device == "horizontal":
|
||||
@@ -340,6 +354,25 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
if device == self.device_ver:
|
||||
return self._device_ui_components_hv("vertical")
|
||||
|
||||
def _apply_controls_enabled(self, axis: DeviceId):
|
||||
state = self._enable_controls_hor if axis == "horizontal" else self._enable_controls_ver
|
||||
if axis == "horizontal":
|
||||
widgets = [
|
||||
self.ui.tweak_increase_hor,
|
||||
self.ui.tweak_decrease_hor,
|
||||
self.ui.step_increase_hor,
|
||||
self.ui.step_decrease_hor,
|
||||
]
|
||||
else:
|
||||
widgets = [
|
||||
self.ui.tweak_increase_ver,
|
||||
self.ui.tweak_decrease_ver,
|
||||
self.ui.step_increase_ver,
|
||||
self.ui.step_decrease_ver,
|
||||
]
|
||||
for w in widgets:
|
||||
w.setEnabled(state)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback_hor(self, msg_content: dict, metadata: dict):
|
||||
"""Callback for device readback.
|
||||
@@ -420,6 +453,26 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
"""Step size for tweak"""
|
||||
self.ui.step_size_ver.setValue(val)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_controls_hor(self) -> bool:
|
||||
"""Persisted switch for horizontal control buttons (tweak/step)."""
|
||||
return self._enable_controls_hor
|
||||
|
||||
@enable_controls_hor.setter
|
||||
def enable_controls_hor(self, value: bool):
|
||||
self._enable_controls_hor = value
|
||||
self._apply_controls_enabled("horizontal")
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enable_controls_ver(self) -> bool:
|
||||
"""Persisted switch for vertical control buttons (tweak/step)."""
|
||||
return self._enable_controls_ver
|
||||
|
||||
@enable_controls_ver.setter
|
||||
def enable_controls_ver(self, value: bool):
|
||||
self._enable_controls_ver = value
|
||||
self._apply_controls_enabled("vertical")
|
||||
|
||||
@SafeSlot()
|
||||
def on_tweak_inc_hor(self):
|
||||
"""Tweak device a up"""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .components import DeviceTable, DMConfigView, DocstringView, OphydValidation
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .device_table_view import DeviceTableView
|
||||
# from .device_table_view import DeviceTableView
|
||||
from .device_table.device_table import DeviceTable
|
||||
from .dm_config_view import DMConfigView
|
||||
from .dm_docstring_view import DocstringView
|
||||
from .dm_ophyd_test import DMOphydTest
|
||||
from .dm_docstring_view import DocstringView, docstring_to_markdown
|
||||
from .ophyd_validation.ophyd_validation import OphydValidation
|
||||
|
||||
@@ -9,64 +9,105 @@ CONFIG_DATA_ROLE: Final[int] = 118
|
||||
|
||||
# TODO 882 keep in sync with headers in device_table_view.py
|
||||
HEADERS_HELP_MD: dict[str, str] = {
|
||||
"status": "\n".join(
|
||||
[
|
||||
"## Status",
|
||||
"The current status of the device. Can be one of the following values: ",
|
||||
"### **LOADED** \n The device with the specified configuration is loaded in the current config.",
|
||||
"### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.",
|
||||
"### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.",
|
||||
"### **VALID** \n The device config is valid, but the connection has not yet been validated.",
|
||||
"### **INVALID** \n The device config is invalid and can not be loaded to the current config.",
|
||||
]
|
||||
),
|
||||
"name": "\n".join(["## Name ", "The name of the device."]),
|
||||
"deviceClass": "\n".join(
|
||||
[
|
||||
"## Device Class",
|
||||
"The device class specifies the type of the device. It will be used to create the instance.",
|
||||
]
|
||||
),
|
||||
"readoutPriority": "\n".join(
|
||||
[
|
||||
"## Readout Priority",
|
||||
"The readout priority of the device. Can be one of the following values: ",
|
||||
"### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.",
|
||||
"### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.",
|
||||
"### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.",
|
||||
"### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.",
|
||||
"### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.",
|
||||
]
|
||||
),
|
||||
"deviceTags": "\n".join(
|
||||
[
|
||||
"## Device Tags",
|
||||
"A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.",
|
||||
]
|
||||
),
|
||||
"enabled": "\n".join(
|
||||
[
|
||||
"## Enabled",
|
||||
"Indicator whether the device is enabled or disabled. Disabled devices can not be used.",
|
||||
]
|
||||
),
|
||||
"readOnly": "\n".join(
|
||||
["## Read Only", "Indicator that a device is read-only or can be modified."]
|
||||
),
|
||||
"onFailure": "\n".join(
|
||||
[
|
||||
"## On Failure",
|
||||
"Specifies the behavior of the device in case of a failure. Can be one of the following values: ",
|
||||
"### **buffer** \n The device readback will fall back to the last known value.",
|
||||
"### **retry** \n The device readback will be retried once, and raises an error if it fails again.",
|
||||
"### **raise** \n The device readback will raise immediately.",
|
||||
]
|
||||
),
|
||||
"softwareTrigger": "\n".join(
|
||||
[
|
||||
"## Software Trigger",
|
||||
"Indicator whether the device receives a software trigger from BEC during a scan.",
|
||||
]
|
||||
),
|
||||
"description": "\n".join(["## Description", "A short description of the device."]),
|
||||
"valid": {
|
||||
"long": "\n".join(
|
||||
[
|
||||
"## Valid",
|
||||
"The current configuration status of the device. Can be one of the following values: ",
|
||||
"### **VALID** \n The device configuration is valid and can be used.",
|
||||
"### **INVALID** \n The device configuration is invalid.",
|
||||
"### **UNKNOWN** \n The device configuration has not been validated yet.",
|
||||
]
|
||||
),
|
||||
"short": "Validation status of the device configuration.",
|
||||
},
|
||||
"connect": {
|
||||
"long": "\n".join(
|
||||
[
|
||||
"## Connect",
|
||||
"The current connection status of the device. Can be one of the following values: ",
|
||||
"### **CONNECTED** \n The device is connected and in current session.",
|
||||
"### **CAN_CONNECT** \n The connection to the device has been validated. It's not yet loaded in the current session.",
|
||||
"### **CANNOT_CONNECT** \n The connection to the device could not be established.",
|
||||
"### **UNKNOWN** \n The connection status of the device is unknown.",
|
||||
]
|
||||
),
|
||||
"short": "Connection status of the device.",
|
||||
},
|
||||
"name": {
|
||||
"long": "\n".join(["## Name ", "The name of the device."]),
|
||||
"short": "Name of the device.",
|
||||
},
|
||||
"deviceClass": {
|
||||
"long": "\n".join(
|
||||
[
|
||||
"## Device Class",
|
||||
"The device class specifies the type of the device. It will be used to create the instance.",
|
||||
]
|
||||
),
|
||||
"short": "Python class for the device.",
|
||||
},
|
||||
"readoutPriority": {
|
||||
"long": "\n".join(
|
||||
[
|
||||
"## Readout Priority",
|
||||
"The readout priority of the device. Can be one of the following values: ",
|
||||
"### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.",
|
||||
"### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.",
|
||||
"### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.",
|
||||
"### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.",
|
||||
"### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.",
|
||||
]
|
||||
),
|
||||
"short": "Readout priority of the device for scans in BEC.",
|
||||
},
|
||||
"deviceTags": {
|
||||
"long": "\n".join(
|
||||
[
|
||||
"## Device Tags",
|
||||
"A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.",
|
||||
]
|
||||
),
|
||||
"short": "Tags associated with the device.",
|
||||
},
|
||||
"enabled": {
|
||||
"long": "\n".join(
|
||||
[
|
||||
"## Enabled",
|
||||
"Indicator whether the device is enabled or disabled. Disabled devices can not be used.",
|
||||
]
|
||||
),
|
||||
"short": "Enabled status of the device.",
|
||||
},
|
||||
"readOnly": {
|
||||
"long": "\n".join(
|
||||
["## Read Only", "Indicator that a device is read-only or can be modified."]
|
||||
),
|
||||
"short": "Read-only status of the device.",
|
||||
},
|
||||
"onFailure": {
|
||||
"long": "\n".join(
|
||||
[
|
||||
"## On Failure",
|
||||
"Specifies the behavior of the device in case of a failure. Can be one of the following values: ",
|
||||
"### **buffer** \n The device readback will fall back to the last known value.",
|
||||
"### **retry** \n The device readback will be retried once, and raises an error if it fails again.",
|
||||
"### **raise** \n The device readback will raise immediately.",
|
||||
]
|
||||
),
|
||||
"short": "On failure behavior of the device.",
|
||||
},
|
||||
"softwareTrigger": {
|
||||
"long": "\n".join(
|
||||
[
|
||||
"## Software Trigger",
|
||||
"Indicator whether the device receives a software trigger from BEC during a scan.",
|
||||
]
|
||||
),
|
||||
"short": "Software trigger status of the device.",
|
||||
},
|
||||
"description": {
|
||||
"long": "\n".join(["## Description", "A short description of the device."]),
|
||||
"short": "Description of the device.",
|
||||
},
|
||||
}
|
||||
|
||||
+519
@@ -0,0 +1,519 @@
|
||||
"""Module for the device configuration form widget for EpicsMotor, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Type
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.logger import bec_logger
|
||||
from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES
|
||||
from pydantic import BaseModel
|
||||
from pydantic_core import PydanticUndefinedType
|
||||
from qtpy import QtWidgets
|
||||
|
||||
from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import (
|
||||
DEVICE_CONFIG_FIELDS,
|
||||
DEVICE_FIELDS,
|
||||
DeviceConfigField,
|
||||
DeviceTagsWidget,
|
||||
InputLineEdit,
|
||||
LimitInputWidget,
|
||||
OnFailureComboBox,
|
||||
ParameterValueWidget,
|
||||
ReadoutPriorityComboBox,
|
||||
)
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceConfigTemplate(QtWidgets.QWidget):
|
||||
"""
|
||||
Device Configuration Template Widget.
|
||||
Current supported templates follow the structure in
|
||||
ophyd_devices.interfaces.device_config_templates.ophyd_templates.OPHYD_DEVICE_TEMPLATES.
|
||||
|
||||
Args:
|
||||
parent (QtWidgets.QWidget, optional) : Parent widget. Defaults to None.
|
||||
client (BECClient, optional) : BECClient instance. Defaults to None.
|
||||
template (dict[str, any], optional) : Device configuration template. If None,
|
||||
the "CustomDevice" template will be used. Defaults to None.
|
||||
"""
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, template: dict[str, any] = None):
|
||||
super().__init__(parent=parent)
|
||||
if template is None:
|
||||
template = OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"]
|
||||
self.template = template
|
||||
self._device_fields = deepcopy(DEVICE_FIELDS)
|
||||
self._device_config_fields = deepcopy(DEVICE_CONFIG_FIELDS)
|
||||
self._unknown_device_config_entry: dict[str, any] = {}
|
||||
|
||||
# Dict to store references to input widgets
|
||||
self._widgets: dict[str, QtWidgets.QWidget] = {}
|
||||
|
||||
# Two column layout
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(2, 0, 2, 0)
|
||||
layout.setSpacing(2)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Left hand side, settings, connection and advanced settings
|
||||
self._left_layout = QtWidgets.QVBoxLayout()
|
||||
self._left_layout.setContentsMargins(2, 2, 2, 2)
|
||||
self._left_layout.setSpacing(4)
|
||||
# Settings box, name | deviceClass | description
|
||||
self.settings_box = self._create_settings_box()
|
||||
# Device Config settings box | dynamic fields from deviceConfig
|
||||
self.connection_settings_box = self._create_connection_settings_box()
|
||||
# Advanced Control box | readoutPriority | onFailure | softwareTrigger | enabled | readOnly
|
||||
self.advanced_control_box = self._create_advanced_control_box()
|
||||
# Add boxes to left layout
|
||||
self._left_layout.addWidget(self.settings_box)
|
||||
self._left_layout.addWidget(self.connection_settings_box)
|
||||
self._left_layout.addWidget(self.advanced_control_box)
|
||||
layout.addLayout(self._left_layout)
|
||||
|
||||
# Right hand side, advanced settings
|
||||
self._right_layout = QtWidgets.QVBoxLayout()
|
||||
self._right_layout.setContentsMargins(2, 2, 2, 2)
|
||||
self._right_layout.setSpacing(4)
|
||||
layout.addLayout(self._right_layout)
|
||||
# Create Additional Settings box
|
||||
self.additional_settings_box = self.create_additional_settings()
|
||||
self._right_layout.addWidget(self.additional_settings_box)
|
||||
|
||||
# Set default values
|
||||
self.reset_to_defaults()
|
||||
|
||||
def _clear_layout(self, layout: QtWidgets.QLayout) -> None:
|
||||
"""Clear a layout recursively. If the layout contains sub-layouts, they will also be cleared."""
|
||||
while layout.count():
|
||||
item = layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().close()
|
||||
item.widget().deleteLater()
|
||||
if item.layout():
|
||||
self._clear_layout(item.layout())
|
||||
|
||||
def reset_to_defaults(self) -> None:
|
||||
"""Reset all fields to default values."""
|
||||
self._widgets.pop("deviceConfig", None)
|
||||
self._clear_layout(self.connection_settings_box.layout())
|
||||
|
||||
# Recreate Connection Settings box
|
||||
layout: QtWidgets.QGridLayout = self.connection_settings_box.layout()
|
||||
self._fill_connection_settings_box(self.connection_settings_box, layout)
|
||||
|
||||
# Reset Settings and Advanced Control boxes
|
||||
for field_name, widget in self._widgets.items():
|
||||
if field_name in self.template:
|
||||
self._set_value_for_widget(widget, self.template[field_name])
|
||||
else:
|
||||
self._set_default_entry(field_name, widget)
|
||||
|
||||
def change_template(self, template: dict[str, any]) -> None:
|
||||
"""
|
||||
Change the template and update the form fields accordingly.
|
||||
|
||||
Args:
|
||||
template (dict[str, any]): New device configuration template.
|
||||
"""
|
||||
self.template = template
|
||||
self.reset_to_defaults()
|
||||
|
||||
def get_config_fields(self) -> dict:
|
||||
"""Retrieve the current configuration from the input fields."""
|
||||
config: dict[str, any] = {}
|
||||
for device_entry, widget in self._widgets.items():
|
||||
config[device_entry] = self._get_entry_for_widget(widget)
|
||||
if self._unknown_device_config_entry:
|
||||
if "deviceConfig" not in config:
|
||||
config["deviceConfig"] = {}
|
||||
config["deviceConfig"].update(self._unknown_device_config_entry)
|
||||
return config
|
||||
|
||||
def set_config_fields(self, config: dict) -> None:
|
||||
"""
|
||||
Set the configuration fields based on the provided config dictionary.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration dictionary to set the fields.
|
||||
"""
|
||||
# Clear storage for unknown entries
|
||||
self._unknown_device_config_entry.clear()
|
||||
if self.template.get("deviceClass", "") != config.get("deviceClass", ""):
|
||||
logger.warning(
|
||||
f"Device class {config.get('deviceClass', '')} does not match template device class {self.template.get('deviceClass', '')}. Using custom device template."
|
||||
)
|
||||
self.change_template(OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"])
|
||||
else:
|
||||
self.reset_to_defaults()
|
||||
self._fill_fields_from_config(config)
|
||||
|
||||
def _fill_fields_from_config(self, model: dict) -> None:
|
||||
"""
|
||||
Fill the form fields base on the provided configuration dictionary.
|
||||
Please note, deviceConfig is handled separately through _fill_connection_settings_box
|
||||
as this depends on the template used.
|
||||
|
||||
Args:
|
||||
model (dict): Configuration dictionary to fill the fields.
|
||||
"""
|
||||
for key, value in model.items():
|
||||
if key == "name":
|
||||
wid = self._widgets["name"]
|
||||
wid.setText(value or "")
|
||||
elif key == "deviceClass":
|
||||
wid = self._widgets["deviceClass"]
|
||||
wid.setText(value or "")
|
||||
if "deviceClass" in self.template:
|
||||
wid.setEnabled(False)
|
||||
else:
|
||||
wid.setEnabled(True)
|
||||
elif key == "deviceConfig" and isinstance(
|
||||
self._widgets.get("deviceConfig", None), dict
|
||||
):
|
||||
# If _widgets["deviceConfig"] is a dict, we have individual widgets for each field
|
||||
for sub_key, sub_value in value.items():
|
||||
widget = self._widgets["deviceConfig"].get(sub_key, None)
|
||||
if widget is None:
|
||||
logger.warning(
|
||||
f"Widget for key {sub_key} not found in deviceConfig widgets."
|
||||
)
|
||||
# Store any unknown entry fields
|
||||
self._unknown_device_config_entry[sub_key] = sub_value
|
||||
continue
|
||||
self._set_value_for_widget(widget, sub_value)
|
||||
else:
|
||||
widget = self._widgets.get(key, None)
|
||||
if widget is not None:
|
||||
self._set_value_for_widget(widget, value)
|
||||
|
||||
def _set_value_for_widget(self, widget: QtWidgets.QWidget, value: any) -> None:
|
||||
"""
|
||||
Set the value for a widget based on its type.
|
||||
|
||||
Args:
|
||||
widget (QtWidgets.QWidget): The widget to set the value for.
|
||||
value (any): The value to set.
|
||||
"""
|
||||
if isinstance(widget, (ParameterValueWidget)) and isinstance(value, dict):
|
||||
for param, val in value.items():
|
||||
widget.add_parameter_line(param, val)
|
||||
elif isinstance(widget, DeviceTagsWidget) and isinstance(value, (list, tuple, set)):
|
||||
for tag in value:
|
||||
widget.add_parameter_line(tag or "")
|
||||
elif isinstance(widget, InputLineEdit):
|
||||
widget.setText(str(value or ""))
|
||||
elif isinstance(widget, ToggleSwitch):
|
||||
widget.setChecked(bool(value))
|
||||
elif isinstance(widget, LimitInputWidget):
|
||||
widget.set_limits(value)
|
||||
elif isinstance(widget, QtWidgets.QComboBox):
|
||||
index = widget.findText(value)
|
||||
if index != -1:
|
||||
widget.setCurrentIndex(index)
|
||||
elif isinstance(widget, QtWidgets.QTextEdit):
|
||||
widget.setPlainText(str(value or ""))
|
||||
else:
|
||||
logger.warning(f"Unsupported widget type for setting value: {type(widget)}")
|
||||
|
||||
def _get_entry_for_widget(self, widget: QtWidgets.QWidget) -> any:
|
||||
"""
|
||||
Get the value from a widget based on its type.
|
||||
|
||||
Args:
|
||||
widget (QtWidgets.QWidget): The widget to get the value from.
|
||||
Returns:
|
||||
any: The value retrieved from the widget.
|
||||
"""
|
||||
if isinstance(widget, (ParameterValueWidget, DeviceTagsWidget)):
|
||||
return widget.parameters()
|
||||
elif isinstance(widget, InputLineEdit):
|
||||
return widget.text().strip()
|
||||
elif isinstance(widget, ToggleSwitch):
|
||||
return widget.isChecked()
|
||||
elif isinstance(widget, LimitInputWidget):
|
||||
return widget.get_limits()
|
||||
elif isinstance(widget, QtWidgets.QComboBox):
|
||||
return widget.currentText()
|
||||
elif isinstance(widget, QtWidgets.QTextEdit):
|
||||
return widget.toPlainText()
|
||||
elif isinstance(widget, dict):
|
||||
result = {}
|
||||
for sub_entry, sub_widget in widget.items():
|
||||
result[sub_entry] = self._get_entry_for_widget(sub_widget)
|
||||
return result
|
||||
else:
|
||||
logger.warning(f"Unsupported widget type for getting entry: {type(widget)}")
|
||||
return None
|
||||
|
||||
def _create_device_field(
|
||||
self, field_name: str, field_info: DeviceConfigField | None = None
|
||||
) -> tuple[QtWidgets.QLabel, QtWidgets.QWidget]:
|
||||
"""
|
||||
Create a device field based on the field name. If field_info is not provided,
|
||||
a default label and input widget will be created.
|
||||
|
||||
Args:
|
||||
field_name (str): Name of the field.
|
||||
field_info (DeviceConfigField | None, optional): Information about the field. Defaults to None.
|
||||
"""
|
||||
if field_info is None:
|
||||
label = QtWidgets.QLabel(field_name, parent=self)
|
||||
input_widget = QtWidgets.QLineEdit(parent=self)
|
||||
return label, input_widget
|
||||
|
||||
label_text = field_info.label
|
||||
label = QtWidgets.QLabel(label_text, parent=self)
|
||||
if field_info.required:
|
||||
label_text = label.text()
|
||||
label_text += " *"
|
||||
label.setText(label_text)
|
||||
label.setStyleSheet("font-weight: bold;")
|
||||
input_widget = field_info.widget_cls(parent=self)
|
||||
if field_info.placeholder_text:
|
||||
if hasattr(input_widget, "setPlaceholderText"):
|
||||
input_widget.setPlaceholderText(field_info.placeholder_text)
|
||||
if field_info.static:
|
||||
input_widget.setEnabled(False)
|
||||
if field_info.validation_callback:
|
||||
# Attach validation callback if provided
|
||||
if isinstance(input_widget, InputLineEdit):
|
||||
input_widget: InputLineEdit
|
||||
for callback in field_info.validation_callback:
|
||||
input_widget.register_validation_callback(callback)
|
||||
if field_info.default is not None:
|
||||
# Set default value
|
||||
if isinstance(input_widget, QtWidgets.QLineEdit):
|
||||
input_widget.setText(str(field_info.default))
|
||||
elif isinstance(input_widget, QtWidgets.QTextEdit):
|
||||
input_widget.setPlainText(str(field_info.default))
|
||||
elif isinstance(input_widget, ToggleSwitch):
|
||||
input_widget.setChecked(bool(field_info.default))
|
||||
elif isinstance(input_widget, (ReadoutPriorityComboBox, OnFailureComboBox)):
|
||||
index = input_widget.findText(field_info.default)
|
||||
if index != -1:
|
||||
input_widget.setCurrentIndex(index)
|
||||
return label, input_widget
|
||||
|
||||
def _create_group_box_with_grid_layout(
|
||||
self, title: str
|
||||
) -> tuple[QtWidgets.QGroupBox, QtWidgets.QGridLayout]:
|
||||
"""Create a group box with a grid layout."""
|
||||
box = QtWidgets.QGroupBox(title)
|
||||
layout = QtWidgets.QGridLayout(box)
|
||||
layout.setContentsMargins(4, 8, 4, 8)
|
||||
layout.setSpacing(4)
|
||||
box.setLayout(layout)
|
||||
return box, layout
|
||||
|
||||
def _set_default_entry(self, field_name: str, widget: QtWidgets.QWidget) -> None:
|
||||
"""
|
||||
Set the default value for a given field in the form based on the Pydantic model.
|
||||
|
||||
Args:
|
||||
field_name (str): Name of the field.
|
||||
widget (QtWidgets.QWidget): The widget to set the default value for.
|
||||
"""
|
||||
if field_name == "enabled":
|
||||
widget.setChecked(True)
|
||||
return
|
||||
if field_name == "readOnly":
|
||||
widget.setChecked(False)
|
||||
return
|
||||
default = self._get_default_for_device_config_field(field_name) or ""
|
||||
widget.setEnabled(True)
|
||||
if isinstance(widget, QtWidgets.QComboBox):
|
||||
index = widget.findText(default)
|
||||
if index != -1:
|
||||
widget.setCurrentIndex(index)
|
||||
elif isinstance(widget, (QtWidgets.QTextEdit, QtWidgets.QLineEdit)):
|
||||
widget.setText(str(default))
|
||||
elif isinstance(widget, ToggleSwitch):
|
||||
widget.setChecked(bool(default))
|
||||
elif isinstance(widget, (ParameterValueWidget, DeviceTagsWidget)):
|
||||
widget.clear_widget()
|
||||
|
||||
def _get_default_for_device_config_field(self, field_name: str) -> any:
|
||||
"""
|
||||
Get the default value for a given deviceConfig field based on the Pydantic model.
|
||||
|
||||
Args:
|
||||
field_name (str): Name of the deviceConfig field.
|
||||
Returns:
|
||||
any: The default value for the field, or None if not found.
|
||||
"""
|
||||
model_properties: dict = DeviceModel.model_json_schema()["properties"]
|
||||
if field_name in model_properties:
|
||||
field_info = model_properties[field_name]
|
||||
default = field_info.get("default", None)
|
||||
if default:
|
||||
return default
|
||||
return None
|
||||
|
||||
### Box creation methods ###
|
||||
|
||||
def _create_box(self, box_title: str, field_names: list[str]) -> QtWidgets.QGroupBox:
|
||||
"""
|
||||
Create a box layout with specific fields. If field_names are in _device_fields,
|
||||
their corresponding widgets will be used.
|
||||
"""
|
||||
# Create box
|
||||
box, layout = self._create_group_box_with_grid_layout(box_title)
|
||||
box.setLayout(layout)
|
||||
|
||||
for ii, field_name in enumerate(field_names):
|
||||
label, input_widget = self._create_device_field(
|
||||
field_name, self._device_fields.get(field_name, None)
|
||||
)
|
||||
layout.addWidget(label, ii, 0)
|
||||
layout.addWidget(input_widget, ii, 1)
|
||||
self._widgets[field_name] = input_widget
|
||||
return box
|
||||
|
||||
def _create_settings_box(self) -> QtWidgets.QGroupBox:
|
||||
"""Create the settings box widget."""
|
||||
box = self._create_box("Settings", ["name", "deviceClass", "description"])
|
||||
layout = box.layout()
|
||||
# Set column stretch
|
||||
layout.setColumnStretch(0, 0)
|
||||
layout.setColumnStretch(1, 1)
|
||||
return box
|
||||
|
||||
def _create_advanced_control_box(self) -> QtWidgets.QGroupBox:
|
||||
"""Create the advanced control box widget."""
|
||||
# Set up advanced control box
|
||||
box = self._create_box("Advanced Control", ["readoutPriority", "onFailure"])
|
||||
layout = box.layout()
|
||||
for ii, field_name in enumerate(["enabled", "readOnly", "softwareTrigger"]):
|
||||
label, input_widget = self._create_device_field(
|
||||
field_name, self._device_fields.get(field_name, None)
|
||||
)
|
||||
layout.addWidget(label, ii, 2)
|
||||
layout.addWidget(input_widget, ii, 3)
|
||||
self._widgets[field_name] = input_widget
|
||||
return box
|
||||
|
||||
def _create_connection_settings_box(self) -> QtWidgets.QGroupBox:
|
||||
"""Create the connection settings box widget. These are all entries in the deviceConfig field."""
|
||||
box, layout = self._create_group_box_with_grid_layout("Connection Settings")
|
||||
box = self._fill_connection_settings_box(box, layout)
|
||||
return box
|
||||
|
||||
def _fill_connection_settings_box(
|
||||
self, box: QtWidgets.QGroupBox, layout: QtWidgets.QGridLayout
|
||||
) -> QtWidgets.QGroupBox:
|
||||
"""Fill the connection settings box based on the deviceConfig template."""
|
||||
if not self.template.get("deviceConfig", {}):
|
||||
widget = ParameterValueWidget(parent=self)
|
||||
widget.setToolTip(
|
||||
"Add custom deviceConfig entries as key-value pairs in the tree view."
|
||||
)
|
||||
layout.addWidget(widget, 0, 0)
|
||||
self._widgets["deviceConfig"] = widget
|
||||
return box
|
||||
# If template specifies deviceConfig fields, create them
|
||||
self._widgets["deviceConfig"] = {}
|
||||
model: Type[BaseModel] = self.template["deviceConfig"]
|
||||
for field_name, field in model.model_fields.items():
|
||||
field_info = self._device_config_fields.get(field_name, None)
|
||||
default = field.get_default()
|
||||
if isinstance(default, PydanticUndefinedType):
|
||||
default = None
|
||||
if field_info:
|
||||
if field.is_required():
|
||||
field_info.required = True
|
||||
if field.description:
|
||||
field_info.placeholder_text = field.description
|
||||
if default is not None:
|
||||
field_info.default = default
|
||||
label, input_widget = self._create_device_field(field_name, field_info)
|
||||
row = layout.rowCount()
|
||||
layout.addWidget(label, row, 0)
|
||||
layout.addWidget(input_widget, row, 1)
|
||||
self._widgets["deviceConfig"][field_name] = input_widget
|
||||
return box
|
||||
|
||||
def create_additional_settings(self) -> QtWidgets.QGroupBox:
|
||||
"""Create the additional settings box widget."""
|
||||
box, layout = self._create_group_box_with_grid_layout("Additional Settings")
|
||||
toolbox = QtWidgets.QToolBox(parent=self)
|
||||
layout.addWidget(toolbox, 0, 0)
|
||||
user_parameters_widget = ParameterValueWidget(parent=self)
|
||||
self._widgets["userParameter"] = user_parameters_widget
|
||||
toolbox.addItem(user_parameters_widget, "User Parameter")
|
||||
device_tags_widget = DeviceTagsWidget(parent=self)
|
||||
toolbox.addItem(device_tags_widget, "Device Tags")
|
||||
toolbox.setCurrentIndex(1)
|
||||
self._widgets["deviceTags"] = device_tags_widget
|
||||
return box
|
||||
|
||||
|
||||
if __name__ == """__main__""": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
import yaml
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
apply_theme("light")
|
||||
|
||||
class TestWidget(QtWidgets.QWidget):
|
||||
pass
|
||||
|
||||
w = TestWidget()
|
||||
w_layout = QtWidgets.QVBoxLayout(w)
|
||||
w_layout.setContentsMargins(0, 0, 0, 0)
|
||||
w_layout.setSpacing(20)
|
||||
dark_mode_button = DarkModeButton()
|
||||
w_layout.addWidget(dark_mode_button)
|
||||
test_motor = "EpicsMotor"
|
||||
config_form = DeviceConfigTemplate(template=OPHYD_DEVICE_TEMPLATES[test_motor][test_motor])
|
||||
w_layout.addWidget(config_form)
|
||||
button_layout = QtWidgets.QHBoxLayout()
|
||||
button = QtWidgets.QPushButton("Get Config")
|
||||
button.clicked.connect(
|
||||
lambda: print("Device Config:", yaml.dump(config_form.get_config_fields(), indent=4))
|
||||
)
|
||||
button_layout.addWidget(button)
|
||||
button2 = QtWidgets.QPushButton("Reset")
|
||||
button2.clicked.connect(config_form.reset_to_defaults)
|
||||
button_layout.addWidget(button2)
|
||||
combo = QtWidgets.QComboBox()
|
||||
combo_keys = [
|
||||
"EpicsMotor",
|
||||
"EpicsSignal",
|
||||
"EpicsSignalRO",
|
||||
"EpicsSignalWithRBV",
|
||||
"CustomDevice",
|
||||
]
|
||||
combo.addItems(combo_keys)
|
||||
combo.setCurrentText(test_motor)
|
||||
|
||||
def text_changed(text: str) -> None:
|
||||
if text.startswith("EpicsMotor"):
|
||||
if text == "EpicsMotor":
|
||||
template = OPHYD_DEVICE_TEMPLATES[text][text]
|
||||
else:
|
||||
template = OPHYD_DEVICE_TEMPLATES["EpicsMotor"][text]
|
||||
elif text.startswith("EpicsSignal"):
|
||||
if text == "EpicsSignal":
|
||||
template = OPHYD_DEVICE_TEMPLATES[text][text]
|
||||
else:
|
||||
template = OPHYD_DEVICE_TEMPLATES["EpicsSignal"][text]
|
||||
else:
|
||||
template = OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"]
|
||||
config_form.change_template(template)
|
||||
|
||||
combo.currentTextChanged.connect(text_changed)
|
||||
button_layout.addWidget(button)
|
||||
button_layout.addWidget(combo)
|
||||
w_layout.addLayout(button_layout)
|
||||
w.resize(1200, 600)
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
"""Module for custom input widgets used in device configuration templates."""
|
||||
|
||||
from ast import literal_eval
|
||||
from typing import Callable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from qtpy import QtWidgets
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanDoubleSpinBox
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def _try_literal_eval(value: any) -> any:
|
||||
"""Consolidated function for literal evaluation of a value."""
|
||||
if value in ["true", "True"]:
|
||||
return True
|
||||
if value in ["false", "False"]:
|
||||
return False
|
||||
if value == "":
|
||||
return ""
|
||||
try:
|
||||
return literal_eval(f"{value}")
|
||||
except ValueError:
|
||||
return value
|
||||
except Exception:
|
||||
logger.warning(f"Could not literal_eval value: {value}, returning as string")
|
||||
return value
|
||||
|
||||
|
||||
class InputLineEdit(QtWidgets.QLineEdit):
|
||||
"""
|
||||
Custom QLineEdit for input fields with validation.
|
||||
|
||||
Args:
|
||||
parent (QtWidgets.QWidget, optional): Parent widget. Defaults to None.
|
||||
config_field (str, optional): Configuration field name. Defaults to "no_field_specified"
|
||||
required (bool, optional): Whether the field is required. Defaults to True.
|
||||
placeholder_text (str, optional): Placeholder text for the input field. Defaults to "".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
config_field: str = "no_field_specified",
|
||||
required: bool = True,
|
||||
placeholder_text: str = "",
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._config_field = config_field
|
||||
self._colors = get_accent_colors()
|
||||
self._required = required
|
||||
self.textChanged.connect(self._update_input_field_style)
|
||||
self._validation_callbacks: list[Callable[[bool], str]] = []
|
||||
self.setPlaceholderText(placeholder_text)
|
||||
self._update_input_field_style()
|
||||
|
||||
def register_validation_callback(self, callback: Callable[[str], bool]) -> None:
|
||||
"""
|
||||
Register a custom validation callback.
|
||||
|
||||
Args:
|
||||
callback (Callable[[str], bool]): A function that takes the input string
|
||||
and returns True if valid, False otherwise.
|
||||
"""
|
||||
self._validation_callbacks.append(callback)
|
||||
|
||||
def apply_theme(self, theme: str) -> None:
|
||||
"""Apply the theme to the widget."""
|
||||
self._colors = get_accent_colors()
|
||||
self._update_input_field_style()
|
||||
|
||||
def _update_input_field_style(self) -> None:
|
||||
"""Update the input field style based on validation."""
|
||||
name = self.text()
|
||||
if not self.is_valid_input(name) and self._required is True:
|
||||
self.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};")
|
||||
return
|
||||
self.setStyleSheet("")
|
||||
return
|
||||
|
||||
def is_valid_input(self, name: str) -> bool:
|
||||
"""Validate the input string using plugin helper."""
|
||||
name = name.strip() # Remove leading/trailing whitespace
|
||||
# Run registered validation callbacks
|
||||
for callback in self._validation_callbacks:
|
||||
try:
|
||||
valid = callback(name)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Validation callback raised an exception: {exc}. Defaulting to valid"
|
||||
)
|
||||
valid = True
|
||||
if not valid:
|
||||
return False
|
||||
if not self._required:
|
||||
return True
|
||||
if not name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class OnFailureComboBox(QtWidgets.QComboBox):
|
||||
"""Custom QComboBox for the onFailure input field."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.addItems(["buffer", "retry", "raise"])
|
||||
|
||||
|
||||
class ReadoutPriorityComboBox(QtWidgets.QComboBox):
|
||||
"""Custom QComboBox for the readoutPriority input field."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.addItems(["monitored", "baseline", "async", "continuous", "on_request"])
|
||||
|
||||
|
||||
class LimitInputWidget(QtWidgets.QWidget):
|
||||
"""Custom widget for inputting limits as a tuple (min, max)."""
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent)
|
||||
self._layout = QtWidgets.QHBoxLayout(self)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setSpacing(4)
|
||||
|
||||
# Colors
|
||||
self._colors = get_accent_colors()
|
||||
|
||||
self.min_input = ScanDoubleSpinBox(self, arg_name="min_limit", default=0.0)
|
||||
self.min_input.setPrefix("Min: ")
|
||||
self.min_input.setEnabled(False)
|
||||
self.min_input.setRange(-1e12, 1e12)
|
||||
self._layout.addWidget(self.min_input)
|
||||
|
||||
self.max_input = ScanDoubleSpinBox(self, arg_name="max_limit", default=0.0)
|
||||
self.max_input.setPrefix("Max: ")
|
||||
self.max_input.setRange(-1e12, 1e12)
|
||||
self.max_input.setEnabled(False)
|
||||
self._layout.addWidget(self.max_input)
|
||||
|
||||
# Add validity checks
|
||||
self.min_input.valueChanged.connect(self._check_valid_inputs)
|
||||
self.max_input.valueChanged.connect(self._check_valid_inputs)
|
||||
|
||||
# Add checkbox to enable/disable limits
|
||||
self.enable_toggle = ToggleSwitch(self)
|
||||
self.enable_toggle.setToolTip("Enable editing limits")
|
||||
self.enable_toggle.setChecked(False)
|
||||
self.enable_toggle.enabled.connect(self._toggle_limits_enabled)
|
||||
self._layout.addWidget(self.enable_toggle)
|
||||
|
||||
def reset_defaults(self) -> None:
|
||||
"""Reset limits to default values."""
|
||||
self.min_input.setValue(0.0)
|
||||
self.max_input.setValue(0.0)
|
||||
self.enable_toggle.setChecked(False)
|
||||
|
||||
def _is_valid_limit(self) -> bool:
|
||||
"""Check if the current limits are valid (min < max)."""
|
||||
return self.min_input.value() <= self.max_input.value()
|
||||
|
||||
def _check_valid_inputs(self) -> None:
|
||||
"""Check if the current inputs are valid and update styles accordingly."""
|
||||
if not self._is_valid_limit():
|
||||
self.min_input.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};")
|
||||
self.max_input.setStyleSheet(f"border: 1px solid {self._colors.emergency.name()};")
|
||||
else:
|
||||
self.min_input.setStyleSheet("")
|
||||
self.max_input.setStyleSheet("")
|
||||
|
||||
def _toggle_limits_enabled(self, enable: bool) -> None:
|
||||
"""Enable or disable the limit inputs based on the checkbox state."""
|
||||
self.min_input.setEnabled(enable)
|
||||
self.max_input.setEnabled(enable)
|
||||
|
||||
def get_limits(self) -> list[float, float]:
|
||||
"""Return the limits as a list [min, max]."""
|
||||
min_val = self.min_input.value()
|
||||
max_val = self.max_input.value()
|
||||
return [min_val, max_val]
|
||||
|
||||
def set_limits(self, limits: tuple) -> None:
|
||||
"""Set the limits from a tuple (min, max)."""
|
||||
checked_state = self.enable_toggle.isChecked()
|
||||
if not checked_state:
|
||||
self.enable_toggle.setChecked(True)
|
||||
self.min_input.setValue(limits[0])
|
||||
self.max_input.setValue(limits[1])
|
||||
self.enable_toggle.setChecked(checked_state)
|
||||
|
||||
|
||||
class ParameterValueWidget(QtWidgets.QWidget):
|
||||
"""Custom QTreeWidget for user parameters input field."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self._layout = QtWidgets.QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setSpacing(4)
|
||||
self.tree_widget = QtWidgets.QTreeWidget(self)
|
||||
self._layout.addWidget(self.tree_widget)
|
||||
self.tree_widget.setColumnCount(2)
|
||||
self.tree_widget.setHeaderLabels(["Parameter", "Value"])
|
||||
self.tree_widget.setIndentation(0)
|
||||
self.tree_widget.setRootIsDecorated(False)
|
||||
header = self.tree_widget.header()
|
||||
header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch)
|
||||
self._add_tool_buttons()
|
||||
|
||||
def clear_widget(self) -> None:
|
||||
"""Clear all tags."""
|
||||
for i in reversed(range(self.tree_widget.topLevelItemCount())):
|
||||
item = self.tree_widget.topLevelItem(i)
|
||||
index = self.tree_widget.indexOfTopLevelItem(item)
|
||||
if index != -1:
|
||||
self.tree_widget.takeTopLevelItem(index)
|
||||
|
||||
def _add_tool_buttons(self) -> None:
|
||||
"""Add tool buttons for adding/removing parameter lines."""
|
||||
button_layout = QtWidgets.QHBoxLayout()
|
||||
button_layout.setContentsMargins(0, 0, 0, 0)
|
||||
button_layout.setSpacing(4)
|
||||
self._layout.addLayout(button_layout)
|
||||
self._button_add = QtWidgets.QPushButton(self)
|
||||
self._button_add.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False))
|
||||
self._button_add.setToolTip("Add parameter")
|
||||
self._button_add.clicked.connect(self._add_button_clicked)
|
||||
button_layout.addWidget(self._button_add)
|
||||
|
||||
self._button_remove = QtWidgets.QPushButton(self)
|
||||
self._button_remove.setIcon(material_icon("remove", size=(16, 16), convert_to_pixmap=False))
|
||||
self._button_remove.setToolTip("Remove selected parameter")
|
||||
self._button_remove.clicked.connect(self.remove_parameter_line)
|
||||
button_layout.addWidget(self._button_remove)
|
||||
|
||||
def _add_button_clicked(self, *args, **kwargs) -> None:
|
||||
"""Handle the add button click event."""
|
||||
self.add_parameter_line()
|
||||
|
||||
def add_parameter_line(self, parameter: str | None = None, value: str | None = None) -> None:
|
||||
"""Add a new row with editable Parameter/Value QLineEdits."""
|
||||
item = QtWidgets.QTreeWidgetItem(self.tree_widget)
|
||||
self.tree_widget.addTopLevelItem(item)
|
||||
|
||||
# Parameter field
|
||||
param_edit = QtWidgets.QLineEdit(self.tree_widget)
|
||||
param_edit.setPlaceholderText("Parameter")
|
||||
self.tree_widget.setItemWidget(item, 0, param_edit)
|
||||
|
||||
# Value field
|
||||
value_edit = QtWidgets.QLineEdit(self.tree_widget)
|
||||
value_edit.setPlaceholderText("Value")
|
||||
self.tree_widget.setItemWidget(item, 1, value_edit)
|
||||
if parameter is not None:
|
||||
param_edit.setText(str(parameter))
|
||||
if value is not None:
|
||||
value_edit.setText(str(value))
|
||||
|
||||
def remove_parameter_line(self) -> None:
|
||||
"""Remove the selected row."""
|
||||
selected_items = self.tree_widget.selectedItems()
|
||||
for item in selected_items:
|
||||
index = self.tree_widget.indexOfTopLevelItem(item)
|
||||
if index != -1:
|
||||
self.tree_widget.takeTopLevelItem(index)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def parameters(self) -> dict:
|
||||
"""Return all parameters as a dictionary {parameter: value}."""
|
||||
result = {}
|
||||
for i in range(self.tree_widget.topLevelItemCount()):
|
||||
item = self.tree_widget.topLevelItem(i)
|
||||
param_edit = self.tree_widget.itemWidget(item, 0)
|
||||
value_edit = self.tree_widget.itemWidget(item, 1)
|
||||
if param_edit and value_edit:
|
||||
key = param_edit.text().strip()
|
||||
val = value_edit.text().strip()
|
||||
if key and val:
|
||||
result[key] = _try_literal_eval(val)
|
||||
return result
|
||||
|
||||
|
||||
class DeviceTagsWidget(QtWidgets.QWidget):
|
||||
"""Custom QTreeWidget for deviceTags input field."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self._layout = QtWidgets.QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setSpacing(4)
|
||||
self.tree_widget = QtWidgets.QTreeWidget(self)
|
||||
self._layout.addWidget(self.tree_widget)
|
||||
self.tree_widget.setColumnCount(1)
|
||||
self.tree_widget.setHeaderLabels(["Tags"])
|
||||
self.tree_widget.setIndentation(0)
|
||||
self.tree_widget.setRootIsDecorated(False)
|
||||
self._add_tool_buttons()
|
||||
|
||||
def clear_widget(self) -> None:
|
||||
"""Clear all tags."""
|
||||
for i in reversed(range(self.tree_widget.topLevelItemCount())):
|
||||
item = self.tree_widget.topLevelItem(i)
|
||||
index = self.tree_widget.indexOfTopLevelItem(item)
|
||||
if index != -1:
|
||||
self.tree_widget.takeTopLevelItem(index)
|
||||
|
||||
def _add_tool_buttons(self) -> None:
|
||||
"""Add tool buttons for adding/removing parameter lines."""
|
||||
button_layout = QtWidgets.QHBoxLayout()
|
||||
button_layout.setContentsMargins(0, 0, 0, 0)
|
||||
button_layout.setSpacing(4)
|
||||
self._layout.addLayout(button_layout)
|
||||
self._button_add = QtWidgets.QPushButton(self)
|
||||
self._button_add.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False))
|
||||
self._button_add.setToolTip("Add parameter")
|
||||
self._button_add.clicked.connect(self._add_button_clicked)
|
||||
button_layout.addWidget(self._button_add)
|
||||
|
||||
self._button_remove = QtWidgets.QPushButton(self)
|
||||
self._button_remove.setIcon(material_icon("remove", size=(16, 16), convert_to_pixmap=False))
|
||||
self._button_remove.setToolTip("Remove selected parameter")
|
||||
self._button_remove.clicked.connect(self.remove_parameter_line)
|
||||
button_layout.addWidget(self._button_remove)
|
||||
|
||||
def _add_button_clicked(self, *args, **kwargs) -> None:
|
||||
"""Handle the add button click event."""
|
||||
self.add_parameter_line()
|
||||
|
||||
def add_parameter_line(self, parameter: str | None = None) -> None:
|
||||
"""Add a new row with editable Tag QLineEdit."""
|
||||
item = QtWidgets.QTreeWidgetItem(self.tree_widget)
|
||||
self.tree_widget.addTopLevelItem(item)
|
||||
|
||||
# Tag field
|
||||
param_edit = QtWidgets.QLineEdit(self.tree_widget)
|
||||
param_edit.setPlaceholderText("Tag")
|
||||
self.tree_widget.setItemWidget(item, 0, param_edit)
|
||||
if parameter is not None:
|
||||
param_edit.setText(str(parameter))
|
||||
|
||||
def remove_parameter_line(self) -> None:
|
||||
"""Remove the selected row."""
|
||||
selected_items = self.tree_widget.selectedItems()
|
||||
for item in selected_items:
|
||||
index = self.tree_widget.indexOfTopLevelItem(item)
|
||||
if index != -1:
|
||||
self.tree_widget.takeTopLevelItem(index)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def parameters(self) -> list[str]:
|
||||
"""Return all parameters as a list of tags."""
|
||||
result = []
|
||||
for i in range(self.tree_widget.topLevelItemCount()):
|
||||
item = self.tree_widget.topLevelItem(i)
|
||||
param_edit = self.tree_widget.itemWidget(item, 0)
|
||||
if param_edit:
|
||||
tag = param_edit.text().strip()
|
||||
if tag:
|
||||
result.append(tag)
|
||||
return result
|
||||
|
||||
|
||||
# Validation callback for name field
|
||||
def validate_name(name: str) -> bool:
|
||||
"""Check that the name does not contain spaces."""
|
||||
if " " in name:
|
||||
return False
|
||||
if not name.replace("_", "").isalnum():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Validation callback for deviceClass field
|
||||
def validate_device_cls(name: str) -> bool:
|
||||
"""Check that the name does not contain spaces."""
|
||||
if " " in name:
|
||||
return False
|
||||
if not name.replace("_", "").replace(".", "").isalnum():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_prefix(value: str) -> bool:
|
||||
"""Check that the prefix does not contain spaces."""
|
||||
if " " in value:
|
||||
return False
|
||||
if not value.replace("_", "").replace(".", "").replace("-", "").replace(":", "").isalnum():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class DeviceConfigField(BaseModel):
|
||||
"""Pydantic model for device configuration fields."""
|
||||
|
||||
label: str
|
||||
widget_cls: type[QtWidgets.QWidget]
|
||||
required: bool = False
|
||||
static: bool = False
|
||||
placeholder_text: str | None = None
|
||||
validation_callback: list[Callable[[str], bool]] | None = None
|
||||
default: any = None
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
DEVICE_FIELDS = {
|
||||
"name": DeviceConfigField(
|
||||
label="Name",
|
||||
widget_cls=InputLineEdit,
|
||||
required=True,
|
||||
placeholder_text="Device name (no spaces or special characters)",
|
||||
validation_callback=[validate_name],
|
||||
),
|
||||
"deviceClass": DeviceConfigField(
|
||||
label="Device Class",
|
||||
widget_cls=InputLineEdit,
|
||||
required=True,
|
||||
placeholder_text="Device class (no spaces or special characters)",
|
||||
validation_callback=[validate_device_cls],
|
||||
),
|
||||
"description": DeviceConfigField(
|
||||
label="Description",
|
||||
widget_cls=QtWidgets.QTextEdit,
|
||||
required=False,
|
||||
placeholder_text="Short device description",
|
||||
),
|
||||
"enabled": DeviceConfigField(
|
||||
label="Enabled", widget_cls=ToggleSwitch, required=False, default=True
|
||||
),
|
||||
"readOnly": DeviceConfigField(
|
||||
label="Read Only", widget_cls=ToggleSwitch, required=False, default=False
|
||||
),
|
||||
"softwareTrigger": DeviceConfigField(
|
||||
label="Software Trigger", widget_cls=ToggleSwitch, required=False, default=False
|
||||
),
|
||||
"readoutPriority": DeviceConfigField(
|
||||
label="Readout Priority", widget_cls=ReadoutPriorityComboBox, default="baseline"
|
||||
),
|
||||
"onFailure": DeviceConfigField(
|
||||
label="On Failure", widget_cls=OnFailureComboBox, default="retry"
|
||||
),
|
||||
"userParameter": DeviceConfigField(
|
||||
label="User Parameters", widget_cls=ParameterValueWidget, static=False
|
||||
),
|
||||
"deviceTags": DeviceConfigField(label="Device Tags", widget_cls=DeviceTagsWidget, static=False),
|
||||
}
|
||||
|
||||
DEVICE_CONFIG_FIELDS = {
|
||||
"prefix": DeviceConfigField(
|
||||
label="Prefix",
|
||||
widget_cls=InputLineEdit,
|
||||
static=False,
|
||||
placeholder_text="EPICS IOC prefix, e.g. X25DA-ES1-MOT:",
|
||||
validation_callback=[validate_prefix],
|
||||
),
|
||||
"read_pv": DeviceConfigField(
|
||||
label="Read PV",
|
||||
widget_cls=InputLineEdit,
|
||||
static=False,
|
||||
placeholder_text="EPICS read PV: e.g. X25DA-ES1-MOT:GET",
|
||||
validation_callback=[validate_prefix],
|
||||
),
|
||||
"write_pv": DeviceConfigField(
|
||||
label="Write PV",
|
||||
widget_cls=InputLineEdit,
|
||||
static=False,
|
||||
placeholder_text="EPICS write PV (if different from read_pv): e.g. X25DA-ES1-MOT:SET",
|
||||
validation_callback=[validate_prefix],
|
||||
),
|
||||
"limits": DeviceConfigField(label="Limits", widget_cls=LimitInputWidget, static=False),
|
||||
"DEFAULT": DeviceConfigField(label="DEFAULT FIELD", widget_cls=InputLineEdit, static=False),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+56
@@ -0,0 +1,56 @@
|
||||
"""Module with custom table row for the device manager device table view."""
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
)
|
||||
|
||||
|
||||
class DeviceTableRow:
|
||||
"""
|
||||
Custom class to hold data and validation status for a device table row.
|
||||
|
||||
Args:
|
||||
data (list[str, dict] | None): Initial data for the row.
|
||||
"""
|
||||
|
||||
def __init__(self, data: list[str, dict] | None = None):
|
||||
"""Initialize the DeviceTableRow with optional data.
|
||||
|
||||
Args:
|
||||
data (list[str, dict] | None): Initial data for the row.
|
||||
"""
|
||||
self._data = {}
|
||||
self.validation_status: tuple[int, int] = (ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN)
|
||||
self.set_data(data or {})
|
||||
|
||||
@property
|
||||
def data(self) -> dict:
|
||||
"""Get the current data from the row widgets as a dictionary."""
|
||||
return self._data
|
||||
|
||||
def set_data(self, data: DeviceModel | dict) -> None:
|
||||
"""Set the data for the row widgets."""
|
||||
if isinstance(data, dict):
|
||||
data = DeviceModel.model_validate(data)
|
||||
old_data = DeviceModel.model_validate(self._data) if self._data else None
|
||||
if old_data is not None and old_data == data:
|
||||
return # No change needed
|
||||
self._data = data.model_dump()
|
||||
self.set_validation_status(ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN)
|
||||
|
||||
def set_validation_status(
|
||||
self, valid: ConfigStatus | int, connect_status: ConnectionStatus | int
|
||||
) -> None:
|
||||
"""
|
||||
Set the validation and connection status icons.
|
||||
|
||||
Args:
|
||||
valid (ConfigStatus | int): The configuration validation status.
|
||||
connect_status (ConnectionStatus | int): The connection status.
|
||||
"""
|
||||
valid = int(valid)
|
||||
connect_status = int(connect_status)
|
||||
self.validation_status = valid, connect_status
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,42 +8,47 @@ import yaml
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DMConfigView(BECWidget, QtWidgets.QWidget):
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent, theme_update=True)
|
||||
class DMConfigView(QtWidgets.QWidget):
|
||||
"""Widget to show the config of a selected device in YAML format."""
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.setLayout(self.stacked_layout)
|
||||
|
||||
# Monaco widget
|
||||
self.monaco_editor = MonacoWidget()
|
||||
self.monaco_editor = MonacoWidget(parent=self)
|
||||
self._customize_monaco()
|
||||
self.stacked_layout.addWidget(self.monaco_editor)
|
||||
|
||||
self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config")
|
||||
# Overlay widget
|
||||
self._overlay_text = "Select a single device to view its config."
|
||||
self._overlay_widget = QtWidgets.QLabel(text=self._overlay_text)
|
||||
self._customize_overlay()
|
||||
self.stacked_layout.addWidget(self._overlay_widget)
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
|
||||
def _customize_monaco(self):
|
||||
|
||||
"""Customize the Monaco editor for YAML display."""
|
||||
self.monaco_editor.set_language("yaml")
|
||||
self.monaco_editor.set_vim_mode_enabled(False)
|
||||
self.monaco_editor.set_minimap_enabled(False)
|
||||
# self.monaco_editor.setFixedHeight(600)
|
||||
self.monaco_editor.set_readonly(True)
|
||||
self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False)
|
||||
self.monaco_editor.editor.set_line_numbers_mode("off")
|
||||
|
||||
def _customize_overlay(self):
|
||||
"""Customize the overlay widget."""
|
||||
self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
self._overlay_widget.setAutoFillBackground(True)
|
||||
self._overlay_widget.setSizePolicy(
|
||||
@@ -52,13 +57,24 @@ class DMConfigView(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
@SafeSlot(dict)
|
||||
def on_select_config(self, device: list[dict]):
|
||||
"""Handle selection of a device from the device table."""
|
||||
"""
|
||||
Handle selection of a device from the device table. If more than one device is selected,
|
||||
show an overlay message. Otherwise, display the device config in YAML format.
|
||||
|
||||
Args:
|
||||
device (list[dict]): The selected device configuration.
|
||||
"""
|
||||
if len(device) != 1:
|
||||
text = ""
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
else:
|
||||
try:
|
||||
text = yaml.dump(device[0], default_flow_style=False)
|
||||
# Cast set to list to ensure proper YAML dumping
|
||||
cfg = device[0]
|
||||
for k, v in cfg.items():
|
||||
if isinstance(v, set):
|
||||
cfg[k] = list(v)
|
||||
text = yaml.dump(cfg, default_flow_style=False)
|
||||
self.stacked_layout.setCurrentWidget(self.monaco_editor)
|
||||
except Exception:
|
||||
content = traceback.format_exc()
|
||||
@@ -71,12 +87,14 @@ class DMConfigView(BECWidget, QtWidgets.QWidget):
|
||||
self.monaco_editor.set_readonly(True) # Disable editing again
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
widget.setLayout(layout)
|
||||
@@ -86,13 +104,14 @@ if __name__ == "__main__":
|
||||
layout.addWidget(config_view)
|
||||
combo_box = QtWidgets.QComboBox()
|
||||
config = config_view.client.device_manager._get_redis_device_config()
|
||||
combo_box.addItems([""] + [str(v) for v, item in enumerate(config)])
|
||||
combo_box.addItems([""] + [f"{v} : {item.get('name', '')}" for v, item in enumerate(config)])
|
||||
|
||||
def on_select(text):
|
||||
if text == "":
|
||||
config_view.on_select_config([])
|
||||
else:
|
||||
config_view.on_select_config([config[int(text)]])
|
||||
index = int(text.split(" : ")[0])
|
||||
config_view.on_select_config([config[index]])
|
||||
|
||||
combo_box.currentTextChanged.connect(on_select)
|
||||
layout.addWidget(combo_box)
|
||||
|
||||
@@ -5,11 +5,9 @@ from __future__ import annotations
|
||||
import inspect
|
||||
import re
|
||||
import textwrap
|
||||
import traceback
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import get_plugin_class, plugin_package_name
|
||||
from bec_lib.utils.rpc_utils import rgetattr
|
||||
from bec_lib.plugin_helper import get_plugin_class
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
@@ -86,7 +84,8 @@ class DocstringView(QtWidgets.QTextEdit):
|
||||
if len(device) != 1:
|
||||
self._set_text("")
|
||||
return
|
||||
device_class = device[0].get("deviceClass", "")
|
||||
device_name = list(device[0].keys())[0]
|
||||
device_class = device[0][device_name].get("deviceClass", "")
|
||||
self.set_device_class(device_class)
|
||||
|
||||
@SafeSlot(str)
|
||||
@@ -102,7 +101,7 @@ class DocstringView(QtWidgets.QTextEdit):
|
||||
self._set_text(f"*Error retrieving docstring for `{device_class_str}`*")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
"""Module to run a static tests for devices from a yaml config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import re
|
||||
from collections import deque
|
||||
from concurrent.futures import CancelledError, Future, ThreadPoolExecutor
|
||||
from html import escape
|
||||
from threading import Event, RLock
|
||||
from typing import Any, Iterable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
READY_TO_TEST = False
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
try:
|
||||
import bec_server
|
||||
import ophyd_devices
|
||||
|
||||
READY_TO_TEST = True
|
||||
except ImportError:
|
||||
logger.warning(f"Optional dependencies not available: {ImportError}")
|
||||
ophyd_devices = None
|
||||
bec_server = None
|
||||
|
||||
try:
|
||||
from ophyd_devices.utils.static_device_test import StaticDeviceTest
|
||||
except ImportError:
|
||||
StaticDeviceTest = None
|
||||
|
||||
|
||||
class ValidationStatus(int, enum.Enum):
|
||||
"""Validation status for device configurations."""
|
||||
|
||||
PENDING = 0 # colors.default
|
||||
VALID = 1 # colors.highlight
|
||||
FAILED = 2 # colors.emergency
|
||||
|
||||
|
||||
class DeviceValidationResult(QtCore.QObject):
|
||||
"""Simple object to inject validation signals into QRunnable."""
|
||||
|
||||
# Device validation signal, device_name, ValidationStatus as int, error message or ''
|
||||
device_validated = QtCore.Signal(str, bool, str)
|
||||
|
||||
|
||||
class DeviceTester(QtCore.QRunnable):
|
||||
def __init__(self, config: dict) -> None:
|
||||
super().__init__()
|
||||
self.signals = DeviceValidationResult()
|
||||
self.shutdown_event = Event()
|
||||
|
||||
self._config = config
|
||||
|
||||
self._max_threads = 4
|
||||
self._pending_event = Event()
|
||||
self._lock = RLock()
|
||||
self._test_executor = ThreadPoolExecutor(self._max_threads, "device_manager_tester")
|
||||
|
||||
self._pending_queue: deque[tuple[str, dict]] = deque([])
|
||||
self._active: set[str] = set()
|
||||
|
||||
QtWidgets.QApplication.instance().aboutToQuit.connect(lambda: self.shutdown_event.set())
|
||||
|
||||
def run(self):
|
||||
if StaticDeviceTest is None:
|
||||
logger.error("Ophyd devices or bec_server not available, cannot run validation.")
|
||||
return
|
||||
while not self.shutdown_event.is_set():
|
||||
self._pending_event.wait(timeout=0.5) # check if shutting down every 0.5s
|
||||
if len(self._active) >= self._max_threads:
|
||||
self._pending_event.clear() # it will be set again on removing something from active
|
||||
continue
|
||||
with self._lock:
|
||||
if len(self._pending_queue) > 0:
|
||||
item, cfg, connect = self._pending_queue.pop()
|
||||
self._active.add(item)
|
||||
fut = self._test_executor.submit(self._run_test, item, {item: cfg}, connect)
|
||||
fut.__dict__["__device_name"] = item
|
||||
fut.add_done_callback(self._done_cb)
|
||||
self._safe_check_and_clear()
|
||||
self._cleanup()
|
||||
|
||||
def submit(self, devices: Iterable[tuple[str, dict, bool]]):
|
||||
with self._lock:
|
||||
self._pending_queue.extend(devices)
|
||||
self._pending_event.set()
|
||||
|
||||
@staticmethod
|
||||
def _run_test(name: str, config: dict, connect: bool) -> tuple[str, bool, str]:
|
||||
tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None
|
||||
results = tester.run_with_list_output(connect=connect)
|
||||
return name, results[0].success, results[0].message
|
||||
|
||||
def _safe_check_and_clear(self):
|
||||
with self._lock:
|
||||
if len(self._pending_queue) == 0:
|
||||
self._pending_event.clear()
|
||||
|
||||
def _safe_remove_from_active(self, name: str):
|
||||
with self._lock:
|
||||
self._active.remove(name)
|
||||
self._pending_event.set() # check again once a completed task is removed
|
||||
|
||||
def _done_cb(self, future: Future):
|
||||
try:
|
||||
name, success, message = future.result()
|
||||
except CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
name, success, message = future.__dict__["__device_name"], False, str(e)
|
||||
finally:
|
||||
self._safe_remove_from_active(future.__dict__["__device_name"])
|
||||
self.signals.device_validated.emit(name, success, message)
|
||||
|
||||
def _cleanup(self): ...
|
||||
|
||||
|
||||
class ValidationListItem(QtWidgets.QWidget):
|
||||
"""Custom list item widget showing device name and validation status."""
|
||||
|
||||
def __init__(self, device_name: str, device_config: dict, parent=None):
|
||||
"""
|
||||
Initialize the validation list item.
|
||||
|
||||
Args:
|
||||
device_name (str): The name of the device.
|
||||
device_config (dict): The configuration of the device.
|
||||
validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status.
|
||||
parent (QtWidgets.QWidget, optional): The parent widget.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.main_layout = QtWidgets.QHBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(2, 2, 2, 2)
|
||||
self.main_layout.setSpacing(4)
|
||||
self.device_name = device_name
|
||||
self.device_config = device_config
|
||||
self.validation_msg = "Validation in progress..."
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the UI for the list item."""
|
||||
label = QtWidgets.QLabel(self.device_name)
|
||||
self.main_layout.addWidget(label)
|
||||
self.main_layout.addStretch()
|
||||
self._spinner = SpinnerWidget(parent=self)
|
||||
self._spinner.speed = 80
|
||||
self._spinner.setFixedSize(24, 24)
|
||||
self.main_layout.addWidget(self._spinner)
|
||||
self._base_style = "font-weight: bold;"
|
||||
self.setStyleSheet(self._base_style)
|
||||
self._start_spinner()
|
||||
|
||||
def _start_spinner(self):
|
||||
"""Start the spinner animation."""
|
||||
self._spinner.start()
|
||||
|
||||
def _stop_spinner(self):
|
||||
"""Stop the spinner animation."""
|
||||
self._spinner.stop()
|
||||
self._spinner.setVisible(False)
|
||||
|
||||
@SafeSlot()
|
||||
def on_validation_restart(self):
|
||||
"""Handle validation restart."""
|
||||
self.validation_msg = ""
|
||||
self._start_spinner()
|
||||
self.setStyleSheet("") # Check if this works as expected
|
||||
|
||||
@SafeSlot(str)
|
||||
def on_validation_failed(self, error_msg: str):
|
||||
"""Handle validation failure."""
|
||||
self.validation_msg = error_msg
|
||||
colors = get_accent_colors()
|
||||
self._stop_spinner()
|
||||
self.main_layout.removeWidget(self._spinner)
|
||||
self._spinner.deleteLater()
|
||||
label = QtWidgets.QLabel("")
|
||||
icon = material_icon("error", color=colors.emergency, size=(24, 24))
|
||||
label.setPixmap(icon)
|
||||
self.main_layout.addWidget(label)
|
||||
|
||||
|
||||
class DMOphydTest(BECWidget, QtWidgets.QWidget):
|
||||
"""Widget to test device configurations using ophyd devices."""
|
||||
|
||||
# Signal to emit the validation status of a device
|
||||
device_validated = QtCore.Signal(str, int)
|
||||
# validation_msg in markdown format
|
||||
validation_msg_md = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(parent=parent, client=client)
|
||||
if not READY_TO_TEST:
|
||||
self.setDisabled(True)
|
||||
self.tester = None
|
||||
else:
|
||||
self.tester = DeviceTester({})
|
||||
self.tester.signals.device_validated.connect(self._on_device_validated)
|
||||
QtCore.QThreadPool.globalInstance().start(self.tester)
|
||||
self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {}
|
||||
# TODO Consider using the thread pool from BECConnector instead of fetching the global instance!
|
||||
self._thread_pool = QtCore.QThreadPool.globalInstance()
|
||||
|
||||
self._main_layout = QtWidgets.QVBoxLayout(self)
|
||||
self._main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._main_layout.setSpacing(0)
|
||||
|
||||
# We add a splitter between the list and the text box
|
||||
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
|
||||
self._main_layout.addWidget(self.splitter)
|
||||
|
||||
self._setup_list_ui()
|
||||
|
||||
def _setup_list_ui(self):
|
||||
"""Setup the list UI."""
|
||||
self._list_widget = QtWidgets.QListWidget(self)
|
||||
self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
|
||||
self.splitter.addWidget(self._list_widget)
|
||||
# Connect signals
|
||||
self._list_widget.currentItemChanged.connect(self._on_current_item_changed)
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
@SafeSlot(list, bool, bool)
|
||||
def change_device_configs(
|
||||
self, device_configs: list[dict[str, Any]], added: bool, connect: bool = False
|
||||
) -> None:
|
||||
"""Receive an update with device configs.
|
||||
|
||||
Args:
|
||||
device_configs (list[dict[str, Any]]): The updated device configurations.
|
||||
"""
|
||||
for cfg in device_configs:
|
||||
name = cfg.get("name", "<not found>")
|
||||
if added:
|
||||
if name in self._device_list_items:
|
||||
continue
|
||||
if self.tester:
|
||||
self._add_device(name, cfg)
|
||||
self.tester.submit([(name, cfg, connect)])
|
||||
continue
|
||||
if name not in self._device_list_items:
|
||||
continue
|
||||
self._remove_list_item(name)
|
||||
|
||||
def _add_device(self, name, cfg):
|
||||
item = QtWidgets.QListWidgetItem(self._list_widget)
|
||||
widget = ValidationListItem(device_name=name, device_config=cfg)
|
||||
|
||||
# wrap it in a QListWidgetItem
|
||||
item.setSizeHint(widget.sizeHint())
|
||||
self._list_widget.addItem(item)
|
||||
self._list_widget.setItemWidget(item, widget)
|
||||
self._device_list_items[name] = item
|
||||
|
||||
def _remove_list_item(self, device_name: str):
|
||||
"""Remove a device from the list."""
|
||||
# Get the list item
|
||||
item = self._device_list_items.pop(device_name)
|
||||
|
||||
# Retrieve the custom widget attached to the item
|
||||
widget = self._list_widget.itemWidget(item)
|
||||
if widget is not None:
|
||||
widget.deleteLater() # clean up custom widget
|
||||
|
||||
# Remove the item from the QListWidget
|
||||
row = self._list_widget.row(item)
|
||||
self._list_widget.takeItem(row)
|
||||
|
||||
@SafeSlot(str, bool, str)
|
||||
def _on_device_validated(self, device_name: str, success: bool, message: str):
|
||||
"""Handle the device validation result.
|
||||
|
||||
Args:
|
||||
device_name (str): The name of the device.
|
||||
success (bool): Whether the validation was successful.
|
||||
message (str): The validation message.
|
||||
"""
|
||||
logger.info(f"Device {device_name} validation result: {success}, message: {message}")
|
||||
item = self._device_list_items.get(device_name, None)
|
||||
if not item:
|
||||
logger.error(f"Device {device_name} not found in the list.")
|
||||
return
|
||||
if success:
|
||||
self._remove_list_item(device_name=device_name)
|
||||
self.device_validated.emit(device_name, ValidationStatus.VALID.value)
|
||||
else:
|
||||
widget: ValidationListItem = self._list_widget.itemWidget(item)
|
||||
widget.on_validation_failed(message)
|
||||
self.device_validated.emit(device_name, ValidationStatus.FAILED.value)
|
||||
|
||||
def _on_current_item_changed(
|
||||
self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem
|
||||
):
|
||||
"""Handle the current item change in the list widget.
|
||||
|
||||
Args:
|
||||
current (QListWidgetItem): The currently selected item.
|
||||
previous (QListWidgetItem): The previously selected item.
|
||||
"""
|
||||
widget: ValidationListItem = self._list_widget.itemWidget(current)
|
||||
if widget:
|
||||
try:
|
||||
formatted_md = self._format_markdown_text(widget.device_name, widget.validation_msg)
|
||||
self.validation_msg_md.emit(formatted_md)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"##Error formatting validation message for device {widget.device_name}:\n{e}"
|
||||
)
|
||||
self.validation_msg_md.emit(widget.validation_msg)
|
||||
else:
|
||||
self.validation_msg_md.emit("")
|
||||
|
||||
def _format_markdown_text(self, device_name: str, raw_msg: str) -> str:
|
||||
"""
|
||||
Simple HTML formatting for validation messages, wrapping text naturally.
|
||||
|
||||
Args:
|
||||
device_name (str): The name of the device.
|
||||
raw_msg (str): The raw validation message.
|
||||
"""
|
||||
if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...":
|
||||
return f"### Validation in progress for {device_name}... \n\n"
|
||||
|
||||
# Regex to capture repeated ERROR patterns
|
||||
pat = re.compile(
|
||||
r"ERROR:\s*(?P<device>[^\s]+)\s+"
|
||||
r"(?P<status>is not valid|is not connectable|failed):\s*"
|
||||
r"(?P<detail>.*?)(?=ERROR:|$)",
|
||||
re.DOTALL,
|
||||
)
|
||||
blocks = []
|
||||
for m in pat.finditer(raw_msg):
|
||||
dev = m.group("device")
|
||||
status = m.group("status")
|
||||
detail = m.group("detail").strip()
|
||||
lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"]
|
||||
blocks.append("\n\n".join(lines))
|
||||
|
||||
# Fallback: If no patterns matched, return the raw message
|
||||
if not blocks:
|
||||
return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```"
|
||||
|
||||
return "\n\n---\n\n".join(blocks)
|
||||
|
||||
def validation_running(self):
|
||||
return self._device_list_items != {}
|
||||
|
||||
@SafeSlot()
|
||||
def clear_list(self):
|
||||
"""Clear the device list."""
|
||||
self._thread_pool.clear()
|
||||
if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish
|
||||
logger.error("Failed to wait for threads to finish. Removing items from the list.")
|
||||
self._device_list_items.clear()
|
||||
self._list_widget.clear()
|
||||
self.validation_msg_md.emit("")
|
||||
|
||||
def remove_device(self, device_name: str):
|
||||
"""Remove a device from the list."""
|
||||
item = self._device_list_items.pop(device_name, None)
|
||||
if item:
|
||||
self._list_widget.removeItemWidget(item)
|
||||
|
||||
def cleanup(self):
|
||||
if self.tester:
|
||||
self.tester.shutdown_event.set()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
|
||||
# pylint: disable=ungrouped-imports
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
wid = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(wid)
|
||||
wid.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
device_manager_ophyd_test = DMOphydTest()
|
||||
try:
|
||||
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
|
||||
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading config: {e}")
|
||||
import os
|
||||
|
||||
import bec_lib
|
||||
|
||||
config_path = os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml")
|
||||
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
|
||||
|
||||
config.append({"name": "non_existing_device", "type": "NonExistingDevice"})
|
||||
device_manager_ophyd_test.change_device_configs(config, True, True)
|
||||
layout.addWidget(device_manager_ophyd_test)
|
||||
device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test")
|
||||
device_manager_ophyd_test.resize(800, 600)
|
||||
text_box = QtWidgets.QTextEdit()
|
||||
text_box.setReadOnly(True)
|
||||
layout.addWidget(text_box)
|
||||
device_manager_ophyd_test.validation_msg_md.connect(text_box.setMarkdown)
|
||||
wid.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,8 @@
|
||||
from .ophyd_validation_utils import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
DeviceTestModel,
|
||||
format_error_to_md,
|
||||
get_validation_icons,
|
||||
)
|
||||
from .validation_list_item import ValidationButton, ValidationListItem
|
||||
+825
@@ -0,0 +1,825 @@
|
||||
"""
|
||||
Module with a test widget that allows to run the ophyd_devices static tests
|
||||
utilities for a device config test. Results are displayed in two lists (running, completed).
|
||||
In addition, it allows to configure the test parameters.
|
||||
|
||||
-> Connect: Try to establish a connection to the device
|
||||
-> Timeout: Timeout for connection attempt. Default here is 5s.
|
||||
-> Force Connect: To force connection even if already connected.
|
||||
Mostly relevant for ADBase integrations.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import weakref
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_list import BECList
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
DeviceTestModel,
|
||||
ValidationButton,
|
||||
ValidationListItem,
|
||||
format_error_to_md,
|
||||
get_validation_icons,
|
||||
)
|
||||
|
||||
READY_TO_TEST = False
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
try:
|
||||
import bec_server # type: ignore
|
||||
import ophyd_devices # type: ignore
|
||||
|
||||
READY_TO_TEST = True
|
||||
except ImportError:
|
||||
logger.warning(f"Optional dependencies not available: {ImportError}")
|
||||
ophyd_devices = None
|
||||
bec_server = None
|
||||
|
||||
try:
|
||||
from ophyd_devices.utils.static_device_test import StaticDeviceTest
|
||||
except ImportError:
|
||||
StaticDeviceTest = None
|
||||
|
||||
|
||||
class DeviceTestResult(QtCore.QObject):
|
||||
"""Simple object to inject device validation signal to DeviceTest QRunnable."""
|
||||
|
||||
# ValidationResult: device_config, config_status, connection_status, error_message
|
||||
device_validated = QtCore.Signal(dict, int, int, str)
|
||||
device_validation_started = QtCore.Signal(str)
|
||||
|
||||
|
||||
class DeviceTest(QtCore.QRunnable):
|
||||
"""QRunnable to run a device test in the QT thread pool."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_model: DeviceTestModel,
|
||||
enable_connect: bool,
|
||||
force_connect: bool,
|
||||
timeout: float,
|
||||
):
|
||||
super().__init__()
|
||||
self.uuid = device_model.uuid
|
||||
test_config = {device_model.device_name: device_model.device_config}
|
||||
self.tester = StaticDeviceTest(config_dict=test_config)
|
||||
self.signals = DeviceTestResult()
|
||||
self.device_config = device_model.device_config
|
||||
self.enable_connect = enable_connect
|
||||
self.force_connect = force_connect
|
||||
self.timeout = timeout
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self):
|
||||
"""Cancel the device test."""
|
||||
self._cancelled = True
|
||||
|
||||
def run(self):
|
||||
"""Run the device test."""
|
||||
if not READY_TO_TEST:
|
||||
logger.error("Cannot run device test: dependencies not available.")
|
||||
return
|
||||
device_name = self.device_config.get("name", "")
|
||||
self.signals.device_validation_started.emit(device_name) # Emit started signal
|
||||
if self._cancelled:
|
||||
logger.debug("Device test cancelled before start.")
|
||||
self.signals.device_validated.emit(
|
||||
self.device_config,
|
||||
ConfigStatus.UNKNOWN.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
f"{self.device_config.get('name')} was cancelled by user.",
|
||||
)
|
||||
return
|
||||
results = self.tester.run_with_list_output(
|
||||
connect=self.enable_connect,
|
||||
force_connect=self.force_connect,
|
||||
timeout_per_device=self.timeout,
|
||||
)
|
||||
if not results:
|
||||
self.signals.device_validated.emit(
|
||||
self.device_config,
|
||||
ConfigStatus.UNKNOWN.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
"Results from OphydDevices StaticDeviceTest are empty.",
|
||||
)
|
||||
return
|
||||
try:
|
||||
config_is_valid = int(results[0].config_is_valid)
|
||||
connection_status = (
|
||||
int(results[0].success) if self.enable_connect else ConnectionStatus.UNKNOWN.value
|
||||
)
|
||||
error_message = results[0].message or ""
|
||||
self.signals.device_validated.emit(
|
||||
self.device_config, config_is_valid, connection_status, error_message
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading results from device test: {e}")
|
||||
self.signals.device_validated.emit(
|
||||
self.device_config,
|
||||
ConfigStatus.UNKNOWN.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
f"Error processing device test results: {e}",
|
||||
)
|
||||
|
||||
|
||||
class ThreadPoolManager(QtCore.QObject):
|
||||
"""
|
||||
Manager wrapping QThreadPool to expose a queue for jobs.
|
||||
It allows queued jobs to be cancelled if they have not yet started.
|
||||
|
||||
Args:
|
||||
max_workers (int): Maximum number of concurrent workers.
|
||||
poll_interval_ms (int): Poll interval in milliseconds to check for new jobs.
|
||||
"""
|
||||
|
||||
validations_are_running = QtCore.Signal(bool)
|
||||
device_validation_started = QtCore.Signal(str)
|
||||
device_validated = QtCore.Signal(dict, int, int, str)
|
||||
|
||||
def __init__(self, parent=None, max_workers: int = 4, poll_interval_ms: int = 100):
|
||||
super().__init__(parent=parent)
|
||||
self.pool = QtCore.QThreadPool(parent=parent)
|
||||
self.pool.setMaxThreadCount(max_workers)
|
||||
|
||||
self._queue = queue.Queue()
|
||||
self._timer = QtCore.QTimer(parent=parent)
|
||||
self._timer.timeout.connect(self._process_queue)
|
||||
self.poll_interval_ms = poll_interval_ms
|
||||
self._timer.setInterval(self.poll_interval_ms)
|
||||
self._active_tests: dict[str, weakref.ReferenceType[DeviceTest]] = {}
|
||||
|
||||
def start_polling(self):
|
||||
"""Start the polling timer."""
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
|
||||
def stop_polling(self):
|
||||
"""Stop the polling timer."""
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
|
||||
def _emit_device_validation_started(self, device_name: str):
|
||||
"""Emit device validation started signal."""
|
||||
self.device_validation_started.emit(device_name)
|
||||
|
||||
def _emit_device_validated(
|
||||
self, device_config: dict, config_status: int, connection_status: int, error_message: str
|
||||
):
|
||||
"""Emit device validated signal."""
|
||||
self.device_validated.emit(device_config, config_status, connection_status, error_message)
|
||||
|
||||
def submit(self, device_name: str, device_test: DeviceTest):
|
||||
"""Queue a job for execution."""
|
||||
device_test.signals.device_validation_started.connect(self._emit_device_validation_started)
|
||||
device_test.signals.device_validated.connect(self._emit_device_validated)
|
||||
self._queue.put((device_name, device_test))
|
||||
|
||||
def clear_device_in_queue(self, device_name: str):
|
||||
"""Remove a specific device test from the queue."""
|
||||
if device_name in self._active_tests:
|
||||
try:
|
||||
ref = self._active_tests.pop(device_name)
|
||||
obj = ref()
|
||||
if obj and hasattr(obj, "cancel"):
|
||||
obj.cancel()
|
||||
obj.signals.device_validated.disconnect()
|
||||
except KeyError:
|
||||
logger.debug(f"Device {device_name} not found in active tests during cancellation.")
|
||||
return
|
||||
|
||||
with self._queue.mutex:
|
||||
for name, runnable in self._queue.queue:
|
||||
if name == device_name: # found the device to remove, discard it
|
||||
runnable.cancel()
|
||||
runnable.signals.device_validated.disconnect()
|
||||
self._queue.queue = queue.deque(
|
||||
item for item in self._queue.queue if item[0] != device_name
|
||||
)
|
||||
break
|
||||
|
||||
def clear_queue(self):
|
||||
"""Remove all queued (not yet started) jobs."""
|
||||
running = self.get_active_tests()
|
||||
scheduled = self.get_scheduled_tests()
|
||||
for device_name in running + scheduled:
|
||||
self.clear_device_in_queue(device_name)
|
||||
|
||||
def get_active_tests(self) -> list[str]:
|
||||
"""Return a list of currently active test device names."""
|
||||
return list(self._active_tests.keys())
|
||||
|
||||
def get_scheduled_tests(self) -> list[str]:
|
||||
"""Return a list of currently scheduled (queued) test device names."""
|
||||
with self._queue.mutex:
|
||||
return [device_name for device_name, _ in list(self._queue.queue)]
|
||||
|
||||
def _process_queue(self):
|
||||
"""Start new jobs if there is capacity. Runs with specified poll interval."""
|
||||
while not self._queue.empty() and len(self._active_tests) < self.pool.maxThreadCount():
|
||||
device_name, runnable = self._queue.get()
|
||||
runnable.signals.device_validated.connect(self._on_task_finished)
|
||||
self._active_tests[device_name] = weakref.ref(runnable)
|
||||
self.pool.start(runnable)
|
||||
self.validations_are_running.emit(len(self._active_tests) > 0)
|
||||
|
||||
@SafeSlot(dict, int, int, str)
|
||||
def _on_task_finished(
|
||||
self, device_config: dict, config_status: int, connection_status: int, error_message: str
|
||||
):
|
||||
"""Handle task finished signal to update active thread count."""
|
||||
device_name = device_config.get("name", None)
|
||||
if device_name:
|
||||
self._active_tests.pop(device_name, None)
|
||||
|
||||
|
||||
class LegendLabel(QtWidgets.QWidget):
|
||||
"""Wrapper widget for legend labels with icon and text for OphydValidation."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self._icons = get_validation_icons(
|
||||
colors=get_accent_colors(), icon_size=(18, 18), convert_to_pixmap=False
|
||||
)
|
||||
layout = QtWidgets.QGridLayout(self)
|
||||
layout.setContentsMargins(4, 0, 4, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Config Status Legend
|
||||
config_legend = QtWidgets.QLabel("Config Legend:")
|
||||
layout.addWidget(config_legend, 0, 0)
|
||||
for ii, status in enumerate(
|
||||
[ConfigStatus.UNKNOWN, ConfigStatus.INVALID, ConfigStatus.VALID]
|
||||
):
|
||||
icon = self._icons["config_status"][status]
|
||||
icon_widget = ValidationButton(parent=self, icon=icon)
|
||||
icon_widget.setEnabled(False)
|
||||
icon_widget.set_enabled_style(False)
|
||||
icon_widget.setToolTip(f"Device Configuration: {status.description()}")
|
||||
layout.addWidget(icon_widget, 0, ii + 1)
|
||||
|
||||
# Connection Status Legend
|
||||
connection_status_legend = QtWidgets.QLabel("Connect Legend:")
|
||||
layout.addWidget(connection_status_legend, 1, 0)
|
||||
for ii, status in enumerate(
|
||||
[
|
||||
ConnectionStatus.UNKNOWN,
|
||||
ConnectionStatus.CANNOT_CONNECT,
|
||||
ConnectionStatus.CAN_CONNECT,
|
||||
ConnectionStatus.CONNECTED,
|
||||
]
|
||||
):
|
||||
icon = self._icons["connection_status"][status]
|
||||
icon_widget = ValidationButton(parent=self, icon=icon)
|
||||
icon_widget.setEnabled(False)
|
||||
icon_widget.set_enabled_style(False)
|
||||
icon_widget.setToolTip(f"Connection Status: {status.description()}")
|
||||
layout.addWidget(icon_widget, 1, ii + 1)
|
||||
layout.setColumnStretch(layout.columnCount(), 1) # Counts as a column
|
||||
|
||||
|
||||
class OphydValidation(BECWidget, QtWidgets.QWidget):
|
||||
"""
|
||||
Widget to manage and run ophyd device tests.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): Parent widget. Defaults to None.
|
||||
client (BECClient, optional): BEC client instance. Defaults to None.
|
||||
hide_legend (bool, optional): Whether to hide the legend. Defaults to False.
|
||||
"""
|
||||
|
||||
RPC = False
|
||||
|
||||
# ValidationResult: device_config, config_status, connection_status, error_message
|
||||
validation_completed = QtCore.Signal(dict, int, int, str)
|
||||
# ValidationResult: device_name, config_status, connection_status, error_message, formatted_error_message
|
||||
item_clicked = QtCore.Signal(str, int, int, str, str)
|
||||
# Signal to indicate if validations are currently running
|
||||
validations_are_running = QtCore.Signal(bool)
|
||||
# Signal to emit list of ValidationResults (device_config, config_status, connection_status, error_message) at once
|
||||
multiple_validations_completed = QtCore.Signal(list)
|
||||
|
||||
def __init__(self, parent=None, client=None, hide_legend: bool = False):
|
||||
super().__init__(parent=parent, client=client, theme_update=True)
|
||||
self._running_ophyd_tests = False
|
||||
if not READY_TO_TEST:
|
||||
self.setDisabled(True)
|
||||
self.thread_pool_manager = None
|
||||
else:
|
||||
self.thread_pool_manager = ThreadPoolManager(parent=self, max_workers=4)
|
||||
self.thread_pool_manager.validations_are_running.connect(self._set_running_ophyd_tests)
|
||||
self.thread_pool_manager.device_validated.connect(self._on_device_test_completed)
|
||||
self.thread_pool_manager.device_validation_started.connect(
|
||||
self._trigger_validation_started
|
||||
)
|
||||
|
||||
self._validation_icons = get_validation_icons(
|
||||
colors=get_accent_colors(), icon_size=(32, 32), convert_to_pixmap=False
|
||||
)
|
||||
|
||||
self._main_layout = QtWidgets.QVBoxLayout(self)
|
||||
self._main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._main_layout.setSpacing(4)
|
||||
self._colors = get_accent_colors()
|
||||
|
||||
# Setup main UI
|
||||
self.list_widget = self._create_list_widget_with_label("Running & Failed Validations")
|
||||
if not hide_legend:
|
||||
legend_widget = LegendLabel(parent=self)
|
||||
self._main_layout.addWidget(legend_widget)
|
||||
self._thread_pool_poll_loop()
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""Apply the current theme to the widget."""
|
||||
self._colors = get_accent_colors()
|
||||
# TODO consider removing as accent colors are the same across themes, or am I wrong?
|
||||
self._stop_validation_button.setStyleSheet(
|
||||
f"background-color: {self._colors.emergency.name()}; color: white; font-weight: bold; padding: 4px;"
|
||||
)
|
||||
|
||||
def _thread_pool_poll_loop(self):
|
||||
"""Start the thread pool polling loop."""
|
||||
if self.thread_pool_manager:
|
||||
self.thread_pool_manager.start_polling()
|
||||
|
||||
def _create_list_widget_with_label(self, label_text: str) -> BECList:
|
||||
"""Setup the running validations section."""
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(2)
|
||||
|
||||
# Section title
|
||||
title_layout = QtWidgets.QHBoxLayout()
|
||||
title_layout.setContentsMargins(0, 0, 0, 0)
|
||||
title_label = QtWidgets.QLabel(label_text)
|
||||
title_label.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;")
|
||||
status_label = QtWidgets.QLabel("Config | Connect")
|
||||
status_label.setStyleSheet("font-weight: bold; font-size: 9px; padding: 2px;")
|
||||
title_layout.addWidget(title_label)
|
||||
title_layout.addStretch(1)
|
||||
title_layout.addWidget(status_label)
|
||||
layout.addLayout(title_layout)
|
||||
|
||||
# Separator line
|
||||
separator = QtWidgets.QFrame()
|
||||
separator.setFrameShape(QtWidgets.QFrame.HLine)
|
||||
separator.setFrameShadow(QtWidgets.QFrame.Sunken)
|
||||
layout.addWidget(separator)
|
||||
|
||||
# List widget for running validations
|
||||
list_w = BECList(parent=self)
|
||||
list_w.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
|
||||
list_w.itemClicked.connect(self._on_item_clicked)
|
||||
list_w.currentItemChanged.connect(self._on_current_item_changed)
|
||||
layout.addWidget(list_w)
|
||||
|
||||
# Stop Running validation button
|
||||
self._stop_validation_button = QtWidgets.QPushButton("Stop Running Validations")
|
||||
self._stop_validation_button.clicked.connect(self.cancel_all_validations)
|
||||
self._stop_validation_button.setStyleSheet(
|
||||
f"background-color: {self._colors.emergency.name()}; color: white; font-weight: bold; padding: 4px;"
|
||||
)
|
||||
self._stop_validation_button.setVisible(False)
|
||||
layout.addWidget(self._stop_validation_button)
|
||||
self.validations_are_running.connect(self._stop_validation_button.setVisible)
|
||||
self._main_layout.addWidget(widget)
|
||||
|
||||
return list_w
|
||||
|
||||
##########################
|
||||
### Event Handlers
|
||||
##########################
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _set_running_ophyd_tests(self, running: bool):
|
||||
"""Set the running ophyd tests state."""
|
||||
self.running_ophyd_tests = running
|
||||
|
||||
@SafeSlot(QtWidgets.QListWidgetItem, QtWidgets.QListWidgetItem)
|
||||
def _on_current_item_changed(
|
||||
self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem
|
||||
):
|
||||
"""Handle current item changed."""
|
||||
widget: ValidationListItem = self.list_widget.get_widget_for_item(current)
|
||||
if widget:
|
||||
self._emit_item_clicked(widget)
|
||||
|
||||
@SafeSlot(QtWidgets.QListWidgetItem)
|
||||
def _on_item_clicked(self, item: QtWidgets.QListWidgetItem):
|
||||
"""Handle click on running item."""
|
||||
widget: ValidationListItem = self.list_widget.get_widget_for_item(item)
|
||||
if widget:
|
||||
self._emit_item_clicked(widget)
|
||||
|
||||
def _emit_item_clicked(self, widget: ValidationListItem):
|
||||
format_error_msg = format_error_to_md(
|
||||
widget.device_model.device_name, widget.device_model.validation_msg
|
||||
)
|
||||
self.item_clicked.emit(
|
||||
widget.device_model.device_name,
|
||||
widget.device_model.config_status,
|
||||
widget.device_model.connection_status,
|
||||
widget.device_model.validation_msg,
|
||||
format_error_msg,
|
||||
)
|
||||
|
||||
###########################
|
||||
### Properties
|
||||
###########################
|
||||
|
||||
@SafeProperty(bool, notify=validations_are_running)
|
||||
# pylint: disable=method-hidden
|
||||
def running_ophyd_tests(self) -> bool:
|
||||
"""Indicates if validations are currently running."""
|
||||
return self._running_ophyd_tests
|
||||
|
||||
@running_ophyd_tests.setter
|
||||
def running_ophyd_tests(self, value: bool) -> None:
|
||||
if self._running_ophyd_tests != value:
|
||||
self._running_ophyd_tests = value
|
||||
self.validations_are_running.emit(value)
|
||||
|
||||
###########################
|
||||
### Public Methods
|
||||
###########################
|
||||
|
||||
@SafeSlot()
|
||||
def clear_all(self):
|
||||
"""Clear all running and failed validations."""
|
||||
self.thread_pool_manager.clear_queue()
|
||||
self.list_widget.clear_widgets()
|
||||
|
||||
def get_device_configs(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get the current device configurations being tested.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: List of device configurations.
|
||||
"""
|
||||
widgets: list[ValidationListItem] = self.list_widget.get_widgets()
|
||||
return [widget.device_model.device_config for widget in widgets]
|
||||
|
||||
@SafeSlot(list, bool)
|
||||
@SafeSlot(list, bool, bool)
|
||||
@SafeSlot(list, bool, bool, bool, float)
|
||||
def change_device_configs(
|
||||
self,
|
||||
device_configs: list[dict[str, Any]],
|
||||
added: bool,
|
||||
connect: bool = False,
|
||||
force_connect: bool = False,
|
||||
timeout: float = 5.0,
|
||||
) -> None:
|
||||
"""
|
||||
Change the device configuration to test. If added is False, existing devices are removed.
|
||||
Device tests will be removed based on device names. No duplicates are allowed.
|
||||
|
||||
Args:
|
||||
device_configs (list[dict[str, Any]]): List of device configurations.
|
||||
added (bool): Whether the devices are added to the existing list.
|
||||
connect (bool, optional): Whether to attempt connection during validation. Defaults to False.
|
||||
force_connect (bool, optional): Whether to force connection during validation. Defaults to False.
|
||||
timeout (float, optional): Timeout for connection attempt. Defaults to 5.0.
|
||||
"""
|
||||
if not READY_TO_TEST:
|
||||
logger.error("Cannot change device configs: dependencies not available.")
|
||||
return
|
||||
# Track all devices that are already in the running session from the
|
||||
# config updates to avoid sending multiple single device validation signals.
|
||||
# Sending successive single updates may affect the UI performance on the receiving end.
|
||||
devices_already_in_session = []
|
||||
for cfg in device_configs:
|
||||
device_name = cfg.get("name", None)
|
||||
if device_name is None: # Config missing name, will be skipped..
|
||||
logger.error(f"Device config missing 'name': {cfg}. Config will be skipped.")
|
||||
continue
|
||||
if not added: # Remove requested
|
||||
self._remove_device_config(cfg)
|
||||
continue
|
||||
if self._is_device_in_redis_session(cfg.get("name"), cfg):
|
||||
logger.debug(
|
||||
f"Device {device_name} already in running session with same config. Skipping."
|
||||
)
|
||||
devices_already_in_session.append(
|
||||
(
|
||||
cfg,
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
"Device already in session.",
|
||||
)
|
||||
)
|
||||
self._remove_device_config(cfg)
|
||||
continue
|
||||
if not self._device_already_exists(cfg.get("name")): # New device case
|
||||
self._add_device_config(
|
||||
cfg, connect=connect, force_connect=force_connect, timeout=timeout
|
||||
)
|
||||
else: # Update existing, but removing first
|
||||
logger.info(f"Device {cfg.get('name')} already exists, re-adding it.")
|
||||
self._remove_device_config(cfg)
|
||||
self._add_device_config(
|
||||
cfg, connect=connect, force_connect=force_connect, timeout=timeout
|
||||
)
|
||||
# Send out batch of updates for devices already in session
|
||||
if devices_already_in_session:
|
||||
self.multiple_validations_completed.emit(devices_already_in_session)
|
||||
|
||||
def cancel_validation(self, device_name: str) -> None:
|
||||
"""Cancel a running validation for a specific device.
|
||||
|
||||
Args:
|
||||
device_name (str): Name of the device to cancel validation for.
|
||||
"""
|
||||
if not READY_TO_TEST:
|
||||
logger.error("Cannot cancel validation: dependencies not available.")
|
||||
return
|
||||
if self.thread_pool_manager:
|
||||
self.thread_pool_manager.clear_device_in_queue(device_name)
|
||||
widget: ValidationListItem = self.list_widget.get_widget(device_name)
|
||||
if widget:
|
||||
self._on_device_test_completed(
|
||||
widget.device_model.device_config,
|
||||
ConfigStatus.UNKNOWN.value,
|
||||
ConnectionStatus.UNKNOWN.value,
|
||||
f"{widget.device_model.device_name} was cancelled by user.",
|
||||
)
|
||||
|
||||
def cancel_all_validations(self) -> None:
|
||||
"""Cancel all running validations."""
|
||||
if not READY_TO_TEST:
|
||||
logger.error("Cannot cancel validations: dependencies not available.")
|
||||
return
|
||||
running = self.thread_pool_manager.get_active_tests()
|
||||
scheduled = self.thread_pool_manager.get_scheduled_tests()
|
||||
for device_name in running + scheduled:
|
||||
self.cancel_validation(device_name)
|
||||
|
||||
#################
|
||||
### Private methods
|
||||
#################
|
||||
|
||||
def _device_already_exists(self, device_name: str) -> bool:
|
||||
return device_name in self.list_widget
|
||||
|
||||
def _add_device_config(
|
||||
self, device_config: dict[str, Any], connect: bool, force_connect: bool, timeout: float
|
||||
) -> None:
|
||||
device_name = device_config.get("name")
|
||||
# Check if device is in redis session with same config, if yes don't even bother testing..
|
||||
device_test_model = DeviceTestModel(
|
||||
uuid=f"device_test_{device_name}_uuid_{uuid4()}",
|
||||
device_name=device_name,
|
||||
device_config=device_config,
|
||||
)
|
||||
|
||||
widget = ValidationListItem(
|
||||
parent=self, device_model=device_test_model, validation_icons=self._validation_icons
|
||||
)
|
||||
widget.request_rerun_validation.connect(self._on_request_rerun_validation)
|
||||
self.list_widget.add_widget_item(device_name, widget)
|
||||
self.__delayed_submit_test(widget, connect, force_connect, timeout)
|
||||
|
||||
def _remove_device_config(self, device_config: dict[str, Any]) -> None:
|
||||
device_name = device_config.get("name")
|
||||
if not device_name:
|
||||
logger.error(f"Device config missing 'name': {device_config}. Cannot remove device.")
|
||||
return
|
||||
if not self._device_already_exists(device_name):
|
||||
logger.debug(
|
||||
f"Device with name {device_name} not found in OphydValidation, can't remove it."
|
||||
)
|
||||
return
|
||||
if self.thread_pool_manager:
|
||||
self.thread_pool_manager.clear_device_in_queue(device_name)
|
||||
self.list_widget.remove_widget_item(device_name)
|
||||
|
||||
@SafeSlot(str, dict, bool, bool, float)
|
||||
def _on_request_rerun_validation(
|
||||
self,
|
||||
device_name: str,
|
||||
device_config: dict[str, Any],
|
||||
connect: bool,
|
||||
force_connect: bool,
|
||||
timeout: float,
|
||||
) -> None:
|
||||
"""Handle request to re-run validation for a device."""
|
||||
if not self._device_already_exists(device_name):
|
||||
logger.debug(
|
||||
f"Device with name {device_name} not found in OphydValidation, can't re-run."
|
||||
)
|
||||
return
|
||||
widget: ValidationListItem = self.list_widget.get_widget(device_name)
|
||||
if widget and not widget.is_running:
|
||||
self.__delayed_submit_test(widget, connect, force_connect, timeout)
|
||||
else:
|
||||
logger.debug(f"Device {device_name} is already running validation, cannot re-run.")
|
||||
|
||||
def _emit_device_in_redis_session(self, device_config: dict) -> None:
|
||||
self.validation_completed.emit(
|
||||
device_config,
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
f"{device_config.get('name')} is OK. Already loaded in running session.",
|
||||
)
|
||||
|
||||
def __delayed_submit_test(
|
||||
self, widget: ValidationListItem, connect: bool, force_connect: bool, timeout: float
|
||||
) -> None:
|
||||
"""Delayed submission of device test to ensure UI updates."""
|
||||
QtCore.QTimer.singleShot(
|
||||
0, lambda: self._submit_test(widget, connect, force_connect, timeout)
|
||||
)
|
||||
|
||||
def _submit_test(
|
||||
self, widget: ValidationListItem, connect: bool, force_connect: bool, timeout: float
|
||||
) -> None:
|
||||
"""Submit a device test to the thread pool."""
|
||||
if not READY_TO_TEST or StaticDeviceTest is None:
|
||||
logger.error("Cannot submit device test: dependencies not available.")
|
||||
return
|
||||
# Check if device is already in redis session with same config
|
||||
if self._is_device_in_redis_session(
|
||||
widget.device_model.device_name, widget.device_model.device_config
|
||||
):
|
||||
logger.info(
|
||||
f"Device {widget.device_model.device_name} already in running session with same config. "
|
||||
"Skipping validation."
|
||||
)
|
||||
self.validation_completed.emit(
|
||||
widget.device_model.device_config,
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
f"{widget.device_model.device_name} is OK. Already loaded in running session.",
|
||||
)
|
||||
# Remove widget from list as it's safe to assume it can be loaded.
|
||||
self._remove_device_config(widget.device_model.device_config)
|
||||
return
|
||||
runnable = DeviceTest(
|
||||
device_model=widget.device_model,
|
||||
enable_connect=connect,
|
||||
force_connect=force_connect,
|
||||
timeout=timeout,
|
||||
)
|
||||
widget.validation_scheduled()
|
||||
if self.thread_pool_manager:
|
||||
self.thread_pool_manager.submit(widget.device_model.device_name, runnable)
|
||||
|
||||
def _trigger_validation_started(self, device_name: str) -> None:
|
||||
"""Trigger validation started for a specific device."""
|
||||
widget: ValidationListItem = self.list_widget.get_widget(device_name)
|
||||
if widget:
|
||||
widget.validation_started()
|
||||
|
||||
def _on_device_test_completed(
|
||||
self, device_config: dict, config_status: int, connection_status: int, error_message: str
|
||||
) -> None:
|
||||
"""Handle device test completion."""
|
||||
device_name = device_config.get("name")
|
||||
if not self._device_already_exists(device_name):
|
||||
logger.debug(f"Received test result for unknown device {device_name}. Ignoring.")
|
||||
return
|
||||
if config_status == ConfigStatus.VALID.value and connection_status in [
|
||||
ConnectionStatus.CONNECTED.value,
|
||||
ConnectionStatus.CAN_CONNECT.value,
|
||||
]:
|
||||
# Validated successfully, remove item from running list
|
||||
self.list_widget.remove_widget_item(device_name)
|
||||
self.validation_completed.emit(
|
||||
device_config, config_status, connection_status, error_message
|
||||
)
|
||||
return
|
||||
widget = self.list_widget.get_widget(device_name)
|
||||
if widget:
|
||||
widget.on_validation_finished(
|
||||
validation_msg=error_message,
|
||||
config_status=config_status,
|
||||
connection_status=connection_status,
|
||||
)
|
||||
self.validation_completed.emit(
|
||||
device_config, config_status, connection_status, error_message
|
||||
)
|
||||
|
||||
def _is_device_in_redis_session(self, device_name: str, device_config: dict) -> bool:
|
||||
"""Check if a device is in the running section."""
|
||||
dev_obj = self.client.device_manager.devices.get(device_name, None)
|
||||
if dev_obj is None or dev_obj.enabled is False:
|
||||
return False
|
||||
return self._compare_device_configs(dev_obj._config, device_config)
|
||||
|
||||
def _compare_device_configs(self, config1: dict, config2: dict) -> bool:
|
||||
"""Compare two device configurations through the Device model in bec_lib.atlas_models.
|
||||
|
||||
Args:
|
||||
config1 (dict): The first device configuration.
|
||||
config2 (dict): The second device configuration.
|
||||
|
||||
Returns:
|
||||
bool: True if the configurations are equivalent, False otherwise.
|
||||
"""
|
||||
try:
|
||||
model1 = DeviceModel.model_validate(config1)
|
||||
model2 = DeviceModel.model_validate(config2)
|
||||
return model1 == model2
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
import os
|
||||
import random
|
||||
|
||||
import bec_lib
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
apply_theme("light")
|
||||
# Main widget
|
||||
wid = QtWidgets.QWidget()
|
||||
w_layout = QtWidgets.QVBoxLayout(wid)
|
||||
w_layout.setContentsMargins(0, 0, 0, 0)
|
||||
w_layout.setSpacing(0)
|
||||
wid.setLayout(w_layout)
|
||||
# Check if plugin is installed
|
||||
|
||||
plugin_path = plugin_repo_path()
|
||||
plugin_name = plugin_package_name()
|
||||
cfgs = [""]
|
||||
cfgs.extend([os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml")])
|
||||
if plugin_path:
|
||||
print(f"Adding configs from plugin {plugin_name} at {plugin_path}")
|
||||
cfg_base_path = os.path.join(plugin_path, plugin_name, "device_configs")
|
||||
config_files = os.listdir(cfg_base_path)
|
||||
cfgs.extend(
|
||||
[os.path.join(cfg_base_path, f) for f in config_files if f.endswith((".yaml", ".yml"))]
|
||||
)
|
||||
|
||||
combo_box_configs = QtWidgets.QComboBox()
|
||||
combo_box_configs.addItems(cfgs)
|
||||
combo_box_configs.setCurrentIndex(0)
|
||||
|
||||
but_layout = QtWidgets.QHBoxLayout()
|
||||
but_layout.addWidget(combo_box_configs)
|
||||
button_reset = QtWidgets.QPushButton("Clear All")
|
||||
but_layout.addWidget(button_reset)
|
||||
button_clear_random = QtWidgets.QPushButton("Clear random amount")
|
||||
but_layout.addWidget(button_clear_random)
|
||||
w_layout.addLayout(but_layout)
|
||||
|
||||
def _load_config(config_path: str):
|
||||
current_config = device_manager_ophyd_test.get_device_configs()
|
||||
device_manager_ophyd_test.change_device_configs(current_config, False)
|
||||
if not config_path: # empty escape
|
||||
return
|
||||
try:
|
||||
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
|
||||
config.append({"name": "non_existing_device", "type": "NonExistingDevice"})
|
||||
device_manager_ophyd_test.change_device_configs(config, True, False, False, 2.0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading config {config_path}: {e}")
|
||||
|
||||
def _clear_random_entries():
|
||||
current_config = device_manager_ophyd_test.get_device_configs()
|
||||
n_remove = random.randint(1, len(current_config))
|
||||
to_remove = random.sample(current_config, n_remove)
|
||||
device_manager_ophyd_test.change_device_configs(to_remove, False)
|
||||
|
||||
device_manager_ophyd_test = OphydValidation()
|
||||
button_reset.clicked.connect(device_manager_ophyd_test.clear_all)
|
||||
combo_box_configs.currentTextChanged.connect(_load_config)
|
||||
button_clear_random.clicked.connect(_clear_random_entries)
|
||||
|
||||
w_layout.addWidget(device_manager_ophyd_test)
|
||||
|
||||
# Add text box for results
|
||||
text_box = QtWidgets.QTextEdit()
|
||||
text_box.setReadOnly(True)
|
||||
w_layout.addWidget(text_box)
|
||||
|
||||
def _validation_callback(
|
||||
device_name: str,
|
||||
config_status: int,
|
||||
connection_status: int,
|
||||
error_message: str,
|
||||
formatted_error_message: str,
|
||||
): # type: ignore
|
||||
text_box.setMarkdown(formatted_error_message)
|
||||
|
||||
device_manager_ophyd_test.item_clicked.connect(_validation_callback)
|
||||
wid.resize(600, 1000)
|
||||
wid.show()
|
||||
sys.exit(app.exec_())
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import re
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from typing import Any, Literal
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy import QtGui
|
||||
|
||||
from bec_widgets.utils.colors import AccentColors
|
||||
|
||||
|
||||
def format_error_to_md(device_name: str, raw_msg: str) -> str:
|
||||
"""
|
||||
Method to format a raw validation method into markdown for display.
|
||||
The recognized patterns are:
|
||||
- "'DEVICE_NAME' is OK. DETAIL"
|
||||
- "ERROR: 'DEVICE_NAME' is not valid: DETAIL"
|
||||
- "ERROR: 'DEVICE_NAME' is not connectable: DETAIL"
|
||||
- "ERROR: 'DEVICE_NAME' failed: DETAIL"
|
||||
If no patterns matched, the raw message is returned as a code block.
|
||||
|
||||
Args:
|
||||
device_name (str): The name of the device.
|
||||
raw_msg (str): The raw validation message.
|
||||
|
||||
Returns:
|
||||
str: The formatted markdown message.
|
||||
"""
|
||||
if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...":
|
||||
return f"### Validation in progress for {device_name}... \n\n"
|
||||
|
||||
# Regex to catch OK pattern
|
||||
ok_pat = re.compile(r"(?P<device>\S+)\s+is\s+OK\.?(?:\s*(?P<detail>.*))?$", re.IGNORECASE)
|
||||
ok_match = ok_pat.search(raw_msg)
|
||||
if ok_match:
|
||||
device = ok_match.group("device")
|
||||
detail = ok_match.group("detail").strip(".").strip()
|
||||
return f"## Validation Success for {device}\n```\n{detail}\n```"
|
||||
|
||||
# Regex to capture repeated ERROR patterns
|
||||
pat = re.compile(
|
||||
r"ERROR:\s*(?P<device>[^\s]+)\s+"
|
||||
r"(?P<status>is not valid|is not connectable|failed):\s*"
|
||||
r"(?P<detail>.*?)(?=ERROR:|$)",
|
||||
re.DOTALL,
|
||||
)
|
||||
blocks = []
|
||||
for m in pat.finditer(raw_msg):
|
||||
dev = m.group("device")
|
||||
status = m.group("status")
|
||||
detail = m.group("detail").strip()
|
||||
lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"]
|
||||
blocks.append("\n\n".join(lines))
|
||||
|
||||
# Fallback: If no patterns matched, return the raw message
|
||||
if not blocks:
|
||||
return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```"
|
||||
|
||||
return "\n\n---\n\n".join(blocks)
|
||||
|
||||
|
||||
############################
|
||||
### Status Enums
|
||||
############################
|
||||
|
||||
|
||||
class ConfigStatus(IntEnum):
|
||||
"""Validation status for device config validity. This includes the deviceClass check."""
|
||||
|
||||
INVALID = 0
|
||||
VALID = 1
|
||||
UNKNOWN = 2
|
||||
|
||||
def description(self) -> str:
|
||||
"""Get a human-readable description of the config status.
|
||||
|
||||
Returns:
|
||||
str: The description of the config status.
|
||||
"""
|
||||
descriptions = {
|
||||
ConfigStatus.INVALID: "Invalid Configuration",
|
||||
ConfigStatus.VALID: "Valid Configuration",
|
||||
ConfigStatus.UNKNOWN: "Unknown",
|
||||
}
|
||||
return descriptions.get(self, "Unknown")
|
||||
|
||||
|
||||
class ConnectionStatus(IntEnum):
|
||||
"""Connection status for device connectivity."""
|
||||
|
||||
CANNOT_CONNECT = 0
|
||||
CAN_CONNECT = 1
|
||||
CONNECTED = 2
|
||||
UNKNOWN = 3
|
||||
|
||||
def description(self) -> str:
|
||||
"""Get a human-readable description of the connection status.
|
||||
|
||||
Returns:
|
||||
str: The description of the connection status.
|
||||
"""
|
||||
descriptions = {
|
||||
ConnectionStatus.CANNOT_CONNECT: "Cannot Connect",
|
||||
ConnectionStatus.CAN_CONNECT: "Can Connect",
|
||||
ConnectionStatus.CONNECTED: "Connected and Loaded",
|
||||
ConnectionStatus.UNKNOWN: "Unknown",
|
||||
}
|
||||
return descriptions.get(self, "Unknown")
|
||||
|
||||
|
||||
class DeviceTestModel(BaseModel):
|
||||
"""Model to hold device test parameters and results."""
|
||||
|
||||
uuid: str
|
||||
device_name: str
|
||||
device_config: dict[str, Any]
|
||||
config_status: int = Field(
|
||||
default=ConfigStatus.UNKNOWN.value,
|
||||
description="Validation status of the device configuration.",
|
||||
)
|
||||
connection_status: int = Field(
|
||||
default=ConnectionStatus.UNKNOWN.value, description="Connection status of the device."
|
||||
)
|
||||
validation_msg: str = Field(default="", description="Message from the last validation attempt.")
|
||||
|
||||
|
||||
def get_validation_icons(
|
||||
colors: AccentColors, icon_size: tuple[int, int], convert_to_pixmap: bool = False
|
||||
) -> dict[Literal["config_status", "connection_status"], dict[int, QtGui.QPixmap | QtGui.QIcon]]:
|
||||
"""Get icons for validation statuses for ConfigStatus and ConnectionStatus.
|
||||
|
||||
Args:
|
||||
colors (AccentColors): The accent colors to use for the icons.
|
||||
icon_size (tuple[int, int]): The size of the icons.
|
||||
convert_to_pixmap (bool, optional): Whether to convert icons to pixmaps. Defaults to False.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with icons for config and connection statuses.
|
||||
"""
|
||||
material_icon_partial = partial(
|
||||
material_icon, size=icon_size, convert_to_pixmap=convert_to_pixmap
|
||||
)
|
||||
icons = {
|
||||
"config_status": {
|
||||
ConfigStatus.UNKNOWN.value: material_icon_partial(
|
||||
icon_name="question_mark", color=colors.default
|
||||
),
|
||||
ConfigStatus.VALID.value: material_icon_partial(
|
||||
icon_name="check_circle", color=colors.success
|
||||
),
|
||||
ConfigStatus.INVALID.value: material_icon_partial(
|
||||
icon_name="error", color=colors.emergency
|
||||
),
|
||||
},
|
||||
"connection_status": {
|
||||
ConnectionStatus.UNKNOWN.value: material_icon_partial(
|
||||
icon_name="question_mark", color=colors.default
|
||||
),
|
||||
ConnectionStatus.CANNOT_CONNECT.value: material_icon_partial(
|
||||
icon_name="cable", color=colors.emergency
|
||||
),
|
||||
ConnectionStatus.CAN_CONNECT.value: material_icon_partial(
|
||||
icon_name="cable", color=colors.success
|
||||
),
|
||||
ConnectionStatus.CONNECTED.value: material_icon_partial(
|
||||
icon_name="cast_connected", color=colors.success
|
||||
),
|
||||
},
|
||||
}
|
||||
return icons
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
"""Module with validation items and a validation button for device testing UI."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
DeviceTestModel,
|
||||
get_validation_icons,
|
||||
)
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ValidationButton(QtWidgets.QPushButton):
|
||||
"""
|
||||
Validation button with flat style and disabled appearance.
|
||||
|
||||
Args:
|
||||
parent (QtWidgets.QWidget | None): Parent widget.
|
||||
icon (QtGui.QIcon | None): Icon to display on the button.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, parent: QtWidgets.QWidget | None = None, icon: QtGui.QIcon | None = None
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self.transparent_style = "background-color: transparent; border: none;"
|
||||
if icon:
|
||||
self.setIcon(icon)
|
||||
self.setFlat(True)
|
||||
self.setEnabled(True)
|
||||
|
||||
def setEnabled(self, enabled: bool) -> None:
|
||||
self.set_enabled_style(enabled)
|
||||
return super().setEnabled(enabled)
|
||||
|
||||
def set_enabled_style(self, enabled: bool) -> None:
|
||||
"""Set the enabled state of the button with style update.
|
||||
|
||||
Args:
|
||||
enabled (bool): Whether the button should be enabled.
|
||||
"""
|
||||
if enabled:
|
||||
self.setStyleSheet("")
|
||||
else:
|
||||
self.setStyleSheet(self.transparent_style)
|
||||
|
||||
|
||||
class ValidationDialog(QtWidgets.QDialog):
|
||||
"""
|
||||
Dialog to confirm re-validation with optional parameters. Once accepted,
|
||||
the settings timeout, connect and force_connect can be retrieved through .result().
|
||||
|
||||
Args:
|
||||
parent (QtWidgets.QWidget, optional): The parent widget.
|
||||
timeout (float, optional): The timeout for the validation.
|
||||
connect (bool, optional): Whether to attempt connection during validation.
|
||||
force_connect (bool, optional): Whether to force connection during validation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, parent=None, timeout: float = 5.0, connect: bool = False, force_connect: bool = False
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self._result: tuple[float, bool, bool] = (timeout, connect, force_connect)
|
||||
# Setup Dialog UI
|
||||
self.setWindowTitle("Run Validation")
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.setSpacing(8)
|
||||
# label
|
||||
self.label = QtWidgets.QLabel(
|
||||
"Do you want to re-run validation with the following options?"
|
||||
)
|
||||
self.label.setWordWrap(True)
|
||||
layout.addWidget(self.label)
|
||||
|
||||
# Setup options (note timeout will be simplified to int)
|
||||
option_layout = QtWidgets.QVBoxLayout()
|
||||
option_layout.setSpacing(16)
|
||||
option_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Timeout
|
||||
timeout_layout = QtWidgets.QHBoxLayout()
|
||||
label_timeout = QtWidgets.QLabel("Timeout(s):")
|
||||
self.timeout_spin = QtWidgets.QSpinBox()
|
||||
self.timeout_spin.setRange(1, 300)
|
||||
self.timeout_spin.setValue(int(timeout))
|
||||
timeout_layout.addWidget(label_timeout)
|
||||
timeout_layout.addWidget(self.timeout_spin)
|
||||
|
||||
# Connect checkbox
|
||||
self.connect_checkbox = QtWidgets.QCheckBox("Test Connection")
|
||||
self.connect_checkbox.setChecked(connect)
|
||||
|
||||
# Force Connect checkbox
|
||||
self.force_connect_checkbox = QtWidgets.QCheckBox("Force Connect")
|
||||
self.force_connect_checkbox.setChecked(force_connect)
|
||||
if self.connect_checkbox.isChecked() is False:
|
||||
self.force_connect_checkbox.setEnabled(False)
|
||||
# Deactivated if connect is unchecked
|
||||
self.connect_checkbox.stateChanged.connect(self.force_connect_checkbox.setEnabled)
|
||||
|
||||
# Add widgets to layout
|
||||
option_layout.addLayout(timeout_layout)
|
||||
option_layout.addWidget(self.connect_checkbox)
|
||||
option_layout.addWidget(self.force_connect_checkbox)
|
||||
layout.addLayout(option_layout)
|
||||
|
||||
# Dialog Buttons: equal size, stacked horizontally
|
||||
self.button_box = QtWidgets.QDialogButtonBox(
|
||||
QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
|
||||
)
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(self.button_box)
|
||||
self.adjustSize()
|
||||
|
||||
def accept(self):
|
||||
"""Process the dialog acceptance and store the result."""
|
||||
self._result = (
|
||||
float(self.timeout_spin.value()),
|
||||
self.connect_checkbox.isChecked(),
|
||||
self.force_connect_checkbox.isChecked(),
|
||||
)
|
||||
super().accept()
|
||||
|
||||
def result(self):
|
||||
return self._result
|
||||
|
||||
|
||||
class ValidationListItem(QtWidgets.QWidget):
|
||||
"""List item to display device test validation status."""
|
||||
|
||||
request_rerun_validation = QtCore.Signal(str, dict, bool, bool, float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget | None = None,
|
||||
device_model: DeviceTestModel | None = None,
|
||||
validation_icons: (
|
||||
dict[Literal["config_status", "connection_status"], dict[int, QtGui.QIcon]] | None
|
||||
) = None,
|
||||
icon_size: tuple[int, int] = (32, 32),
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
if device_model is None:
|
||||
logger.debug("No device config provided to ValidationListItem.")
|
||||
return
|
||||
self.device_model: DeviceTestModel = device_model
|
||||
self.is_running: bool = False
|
||||
self._colors = get_accent_colors()
|
||||
self._icon_size = icon_size
|
||||
self._validation_icons = validation_icons or get_validation_icons(
|
||||
colors=self._colors, icon_size=self._icon_size, convert_to_pixmap=False
|
||||
)
|
||||
|
||||
self.main_layout = QtWidgets.QHBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(2, 2, 2, 2)
|
||||
self.main_layout.setSpacing(4)
|
||||
self._setup_ui()
|
||||
|
||||
######################
|
||||
### UI Setup Methods
|
||||
######################
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
"""Setup the UI elements of the widget."""
|
||||
# Device Name Label
|
||||
label = QtWidgets.QLabel(self.device_model.device_name)
|
||||
self.main_layout.addWidget(label)
|
||||
self.main_layout.addStretch()
|
||||
|
||||
button_layout = QtWidgets.QHBoxLayout()
|
||||
button_layout.setContentsMargins(0, 0, 0, 0)
|
||||
button_layout.setSpacing(8)
|
||||
|
||||
# Spinner
|
||||
self._spinner = SpinnerWidget()
|
||||
self._spinner.speed = 80
|
||||
self._spinner.setFixedSize(self._icon_size[0] // 1.5, self._icon_size[1] // 1.5)
|
||||
self._spinner.setVisible(False)
|
||||
|
||||
# Add to button layout
|
||||
button_layout.addWidget(self._spinner)
|
||||
|
||||
# Config Status Icon
|
||||
self.status_button = ValidationButton(
|
||||
icon=self._validation_icons["config_status"][self.device_model.config_status]
|
||||
)
|
||||
self.status_button.setToolTip("Configuration Status")
|
||||
self.status_button.clicked.connect(self._on_status_button_clicked)
|
||||
button_layout.addWidget(self.status_button)
|
||||
|
||||
# Connection Status Icon
|
||||
self.connection_button = ValidationButton(
|
||||
icon=self._validation_icons["connection_status"][self.device_model.connection_status]
|
||||
)
|
||||
self.connection_button.setToolTip("Connection Status")
|
||||
self.connection_button.clicked.connect(self._on_connection_button_clicked)
|
||||
button_layout.addWidget(self.connection_button)
|
||||
self.main_layout.addLayout(button_layout)
|
||||
|
||||
#######################
|
||||
### Event Handlers
|
||||
#######################
|
||||
|
||||
def _on_status_button_clicked(self) -> None:
|
||||
"""Handle status button click event."""
|
||||
timeout, connect, force_connect = 5, False, False
|
||||
dialog = self._create_validation_dialog_box(timeout, connect, force_connect)
|
||||
if dialog.exec(): # Only procs in success
|
||||
timeout, connect, force_connect = dialog.result()
|
||||
self.request_rerun_validation.emit(
|
||||
self.device_model.device_name,
|
||||
self.device_model.model_dump(),
|
||||
connect,
|
||||
force_connect,
|
||||
timeout,
|
||||
)
|
||||
|
||||
def _on_connection_button_clicked(self) -> None:
|
||||
"""Handle connection button click event."""
|
||||
timeout, connect, force_connect = 5, True, False
|
||||
dialog = self._create_validation_dialog_box(timeout, connect, force_connect)
|
||||
if dialog.exec(): # Only procs in success
|
||||
timeout, connect, force_connect = dialog.result()
|
||||
self.request_rerun_validation.emit(
|
||||
self.device_model.device_name,
|
||||
self.device_model.model_dump(),
|
||||
connect,
|
||||
force_connect,
|
||||
timeout,
|
||||
)
|
||||
|
||||
#########################
|
||||
### Helper Methods
|
||||
#########################
|
||||
|
||||
def _start_spinner(self):
|
||||
"""Start the spinner animation."""
|
||||
self._spinner.start()
|
||||
|
||||
def _stop_spinner(self):
|
||||
"""Stop the spinner animation."""
|
||||
self._spinner.stop()
|
||||
self._spinner.setVisible(False)
|
||||
|
||||
def _create_validation_dialog_box(
|
||||
self, timeout: float, connect: bool, force_connect: bool
|
||||
) -> QtWidgets.QDialog:
|
||||
"""Create a dialog box to confirm re-validation."""
|
||||
return ValidationDialog(
|
||||
parent=self, timeout=timeout, connect=connect, force_connect=force_connect
|
||||
)
|
||||
|
||||
def _update_validation_status(
|
||||
self, validation_msg: str, config_status: int, connection_status: int
|
||||
):
|
||||
"""
|
||||
Update the validation status icons and message.
|
||||
|
||||
Args:
|
||||
validation_msg (str): The validation message.
|
||||
config_status (int): The configuration status.
|
||||
connection_status (int): The connection status.
|
||||
"""
|
||||
# Update device config model
|
||||
self.device_model.validation_msg = validation_msg
|
||||
self.device_model.config_status = ConfigStatus(config_status).value
|
||||
self.device_model.connection_status = ConnectionStatus(connection_status).value
|
||||
|
||||
# Update icons
|
||||
self.status_button.setIcon(
|
||||
self._validation_icons["config_status"][self.device_model.config_status]
|
||||
)
|
||||
self.connection_button.setIcon(
|
||||
self._validation_icons["connection_status"][self.device_model.connection_status]
|
||||
)
|
||||
|
||||
##########################
|
||||
### Public Methods
|
||||
##########################
|
||||
|
||||
@SafeSlot(str, int, int)
|
||||
def on_validation_finished(
|
||||
self, validation_msg: str, config_status: int, connection_status: int
|
||||
):
|
||||
"""Handle validation finished event.
|
||||
|
||||
Args:
|
||||
validation_msg (str): The validation message.
|
||||
config_status (int): The configuration status.
|
||||
connection_status (int): The connection status.
|
||||
"""
|
||||
self.is_running = False
|
||||
self._stop_spinner()
|
||||
self._update_validation_status(validation_msg, config_status, connection_status)
|
||||
|
||||
# Enable/disable buttons based on status
|
||||
config_but_en = config_status in [ConfigStatus.UNKNOWN, ConfigStatus.INVALID]
|
||||
self.status_button.setEnabled(config_but_en)
|
||||
self.status_button.set_enabled_style(config_but_en)
|
||||
connect_but_en = connection_status in [
|
||||
ConnectionStatus.UNKNOWN,
|
||||
ConnectionStatus.CANNOT_CONNECT,
|
||||
]
|
||||
self.connection_button.setEnabled(connect_but_en)
|
||||
self.connection_button.set_enabled_style(connect_but_en)
|
||||
|
||||
@SafeSlot()
|
||||
def validation_scheduled(self):
|
||||
"""Handle validation scheduled event."""
|
||||
self._update_validation_status(
|
||||
"Validation scheduled...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN
|
||||
)
|
||||
self.status_button.setEnabled(False)
|
||||
self.status_button.set_enabled_style(False)
|
||||
self.connection_button.setEnabled(False)
|
||||
self.connection_button.set_enabled_style(False)
|
||||
self._spinner.setVisible(True)
|
||||
|
||||
@SafeSlot()
|
||||
def validation_started(self):
|
||||
"""Start validation process."""
|
||||
self.is_running = True
|
||||
self._start_spinner()
|
||||
self._update_validation_status(
|
||||
"Validation running...", ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def start_validation(self):
|
||||
"""Start validation process."""
|
||||
self.validation_scheduled()
|
||||
self.validation_started()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
w = QtWidgets.QWidget()
|
||||
l = QtWidgets.QVBoxLayout(w)
|
||||
|
||||
# Example device model
|
||||
device_model = DeviceTestModel(
|
||||
uuid="1234",
|
||||
device_name="Test Device",
|
||||
device_config={"param1": "value1"},
|
||||
config_status=ConfigStatus.INVALID.value,
|
||||
connection_status=ConnectionStatus.CANNOT_CONNECT.value,
|
||||
validation_msg="Initial validation failed.",
|
||||
)
|
||||
|
||||
# Create validation list item
|
||||
validation_item = ValidationListItem(parent=w, device_model=device_model)
|
||||
l.addWidget(validation_item)
|
||||
|
||||
but = QtWidgets.QPushButton("Start Validation")
|
||||
but2 = QtWidgets.QPushButton("Finish Validation")
|
||||
but.clicked.connect(validation_item.start_validation)
|
||||
but2.clicked.connect(
|
||||
lambda: validation_item.on_validation_finished(
|
||||
"Validation successful.",
|
||||
ConfigStatus.VALID.value,
|
||||
ConnectionStatus.CANNOT_CONNECT.value,
|
||||
)
|
||||
)
|
||||
l.addWidget(but)
|
||||
l.addWidget(but2)
|
||||
|
||||
def _print_callback(name, cfg, conn, force, to):
|
||||
print(
|
||||
f"Re-run validation requested for dev {name} for config {cfg} with timeout={to}, connect={conn}, force={force}"
|
||||
)
|
||||
|
||||
validation_item.request_rerun_validation.connect(_print_callback)
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,4 +0,0 @@
|
||||
"""
|
||||
This module provides an implementation for the device config view.
|
||||
The widget is the entry point for users to edit device configurations.
|
||||
"""
|
||||
@@ -142,12 +142,9 @@ class MonacoDock(DockAreaWidget):
|
||||
# Temporarily disable read-only mode if the editor is read-only
|
||||
# so we can clear the content for reuse
|
||||
monaco_widget.set_readonly(False)
|
||||
monaco_widget.set_text("", reset=True)
|
||||
monaco_widget.set_text("")
|
||||
dock.setWindowTitle("Untitled")
|
||||
dock.setTabToolTip("Untitled")
|
||||
monaco_widget.metadata["scope"] = ""
|
||||
icon = self._resolve_dock_icon(monaco_widget, dock_icon=None, apply_widget_icon=True)
|
||||
dock.setIcon(icon)
|
||||
return
|
||||
|
||||
# Otherwise, proceed to close and delete the dock
|
||||
@@ -252,15 +249,10 @@ class MonacoDock(DockAreaWidget):
|
||||
self.last_focused_editor = dock
|
||||
return dock
|
||||
|
||||
def open_file(self, file_name: str, scope: str = "") -> None:
|
||||
def open_file(self, file_name: str, scope: str | None = None) -> None:
|
||||
"""
|
||||
Open a file in the specified area. If the file is already open, activate it.
|
||||
|
||||
Args:
|
||||
file_name (str): The path to the file to open.
|
||||
scope (str): The scope to set for the editor metadata.
|
||||
"""
|
||||
|
||||
open_files = self._get_open_files()
|
||||
if file_name in open_files:
|
||||
dock = self._get_editor_dock(file_name)
|
||||
@@ -289,7 +281,8 @@ class MonacoDock(DockAreaWidget):
|
||||
editor_dock.setWindowTitle(file)
|
||||
editor_dock.setTabToolTip(file_name)
|
||||
editor_widget.open_file(file_name)
|
||||
editor_widget.metadata["scope"] = scope
|
||||
if scope is not None:
|
||||
editor_widget.metadata["scope"] = scope
|
||||
self.last_focused_editor = editor_dock
|
||||
return
|
||||
|
||||
@@ -297,7 +290,8 @@ class MonacoDock(DockAreaWidget):
|
||||
editor_dock = self.add_editor(title=file, tooltip=file_name)
|
||||
widget = cast(MonacoWidget, editor_dock.widget())
|
||||
widget.open_file(file_name)
|
||||
widget.metadata["scope"] = scope
|
||||
if scope is not None:
|
||||
widget.metadata["scope"] = scope
|
||||
editor_dock.setAsCurrentTab()
|
||||
self.last_focused_editor = editor_dock
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ class ScanMetadata(PydanticModelForm):
|
||||
def set_schema_from_scan(self, scan_name: str | None):
|
||||
self._scan_name = scan_name or ""
|
||||
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
|
||||
self.populate()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from louie.saferef import safe_ref
|
||||
from pydantic import BaseModel
|
||||
from qtpy.QtCore import QTimer, QUrl, Signal, qInstallMessageHandler
|
||||
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QApplication, QPushButton, QTabWidget, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
@@ -16,6 +18,15 @@ from bec_widgets.utils.error_popups import SafeProperty
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class PageOwnerInfo(BaseModel):
|
||||
owner_gui_id: str | None = None
|
||||
widget_ids: list[str] = []
|
||||
page: QWebEnginePage | None = None
|
||||
initialized: bool = False
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
|
||||
class WebConsoleRegistry:
|
||||
"""
|
||||
A registry for the WebConsole class to manage its instances.
|
||||
@@ -29,6 +40,7 @@ class WebConsoleRegistry:
|
||||
self._server_process = None
|
||||
self._server_port = None
|
||||
self._token = secrets.token_hex(16)
|
||||
self._page_registry: dict[str, PageOwnerInfo] = {}
|
||||
|
||||
def register(self, instance: WebConsole):
|
||||
"""
|
||||
@@ -37,6 +49,9 @@ class WebConsoleRegistry:
|
||||
self._instances[instance.gui_id] = safe_ref(instance)
|
||||
self.cleanup()
|
||||
|
||||
if instance._unique_id:
|
||||
self._register_page(instance)
|
||||
|
||||
if self._server_process is None:
|
||||
# Start the ttyd server if not already running
|
||||
self.start_ttyd()
|
||||
@@ -141,8 +156,127 @@ class WebConsoleRegistry:
|
||||
if instance.gui_id in self._instances:
|
||||
del self._instances[instance.gui_id]
|
||||
|
||||
if instance._unique_id:
|
||||
self._unregister_page(instance._unique_id, instance.gui_id)
|
||||
|
||||
self.cleanup()
|
||||
|
||||
def _register_page(self, instance: WebConsole):
|
||||
"""
|
||||
Register a page in the registry. Please note that this does not transfer ownership;
|
||||
it simply records which widget currently owns the page.
|
||||
Use transfer_page_ownership to change ownership.
|
||||
|
||||
Args:
|
||||
instance (WebConsole): The instance to register.
|
||||
"""
|
||||
|
||||
unique_id = instance._unique_id
|
||||
gui_id = instance.gui_id
|
||||
|
||||
if unique_id is None:
|
||||
return
|
||||
|
||||
if unique_id not in self._page_registry:
|
||||
page = BECWebEnginePage()
|
||||
page.authenticationRequired.connect(instance._authenticate)
|
||||
self._page_registry[unique_id] = PageOwnerInfo(
|
||||
owner_gui_id=gui_id, widget_ids=[gui_id], page=page
|
||||
)
|
||||
return
|
||||
|
||||
if gui_id not in self._page_registry[unique_id].widget_ids:
|
||||
self._page_registry[unique_id].widget_ids.append(gui_id)
|
||||
|
||||
def _unregister_page(self, unique_id: str, gui_id: str):
|
||||
"""
|
||||
Unregister a page from the registry.
|
||||
|
||||
Args:
|
||||
unique_id (str): The unique identifier for the page.
|
||||
gui_id (str): The GUI ID of the widget.
|
||||
"""
|
||||
if unique_id not in self._page_registry:
|
||||
return
|
||||
page_info = self._page_registry[unique_id]
|
||||
if gui_id in page_info.widget_ids:
|
||||
page_info.widget_ids.remove(gui_id)
|
||||
if page_info.owner_gui_id == gui_id:
|
||||
page_info.owner_gui_id = None
|
||||
if not page_info.widget_ids:
|
||||
if page_info.page:
|
||||
page_info.page.deleteLater()
|
||||
del self._page_registry[unique_id]
|
||||
|
||||
logger.info(f"Unregistered page {unique_id} for {gui_id}")
|
||||
|
||||
def get_page_info(self, unique_id: str) -> PageOwnerInfo | None:
|
||||
"""
|
||||
Get a page from the registry.
|
||||
|
||||
Args:
|
||||
unique_id (str): The unique identifier for the page.
|
||||
|
||||
Returns:
|
||||
PageOwnerInfo | None: The page info if found, None otherwise.
|
||||
"""
|
||||
if unique_id not in self._page_registry:
|
||||
return None
|
||||
return self._page_registry[unique_id]
|
||||
|
||||
def take_page_ownership(self, unique_id: str, new_owner_gui_id: str) -> QWebEnginePage | None:
|
||||
"""
|
||||
Transfer ownership of a page to a new owner.
|
||||
|
||||
Args:
|
||||
unique_id (str): The unique identifier for the page.
|
||||
new_owner_gui_id (str): The GUI ID of the new owner.
|
||||
|
||||
Returns:
|
||||
QWebEnginePage | None: The page if ownership transfer was successful, None otherwise.
|
||||
"""
|
||||
if unique_id not in self._page_registry:
|
||||
logger.warning(f"Page {unique_id} not found in registry")
|
||||
return None
|
||||
|
||||
page_info = self._page_registry[unique_id]
|
||||
page_info.owner_gui_id = new_owner_gui_id
|
||||
|
||||
logger.info(f"Transferred ownership of page {unique_id} to {new_owner_gui_id}")
|
||||
return page_info.page
|
||||
|
||||
def yield_ownership(self, gui_id: str) -> bool:
|
||||
"""
|
||||
Yield ownership of a page without destroying it. The page remains in the
|
||||
registry with no owner, available for another widget to claim.
|
||||
|
||||
Args:
|
||||
gui_id (str): The GUI ID of the widget yielding ownership.
|
||||
|
||||
Returns:
|
||||
bool: True if ownership was yielded, False otherwise.
|
||||
"""
|
||||
if gui_id not in self._instances:
|
||||
return False
|
||||
|
||||
instance = self._instances[gui_id]()
|
||||
if instance is None:
|
||||
return False
|
||||
|
||||
unique_id = instance._unique_id
|
||||
if instance is None:
|
||||
return False
|
||||
|
||||
if unique_id not in self._page_registry:
|
||||
return False
|
||||
|
||||
page_owner_info = self._page_registry[unique_id]
|
||||
if page_owner_info.owner_gui_id != gui_id:
|
||||
return False
|
||||
|
||||
page_owner_info.owner_gui_id = None
|
||||
return True
|
||||
|
||||
|
||||
_web_console_registry = WebConsoleRegistry()
|
||||
|
||||
@@ -179,33 +313,60 @@ class WebConsole(BECWidget, QWidget):
|
||||
client=None,
|
||||
gui_id=None,
|
||||
startup_cmd: str | None = "bec --nogui",
|
||||
unique_id: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self._startup_cmd = startup_cmd
|
||||
self._is_initialized = False
|
||||
_web_console_registry.register(self)
|
||||
self._token = _web_console_registry._token
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.browser = QWebEngineView(self)
|
||||
self.page = BECWebEnginePage(self)
|
||||
self.page.authenticationRequired.connect(self._authenticate)
|
||||
self.browser.setPage(self.page)
|
||||
layout.addWidget(self.browser)
|
||||
self.setLayout(layout)
|
||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
||||
self._unique_id = unique_id
|
||||
self.page = None # Will be set in _set_up_page
|
||||
|
||||
self._set_up_page()
|
||||
|
||||
self._startup_timer = QTimer()
|
||||
self._startup_timer.setInterval(500)
|
||||
self._startup_timer.timeout.connect(self._check_page_ready)
|
||||
self._startup_timer.start()
|
||||
self._js_callback.connect(self._on_js_callback)
|
||||
|
||||
def _set_up_page(self):
|
||||
"""
|
||||
Set up the web page and UI elements.
|
||||
"""
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.browser = QWebEngineView(self)
|
||||
|
||||
layout.addWidget(self.browser)
|
||||
self.setLayout(layout)
|
||||
|
||||
_web_console_registry.register(self)
|
||||
self._token = _web_console_registry._token
|
||||
|
||||
# If no unique_id is provided, create a new page
|
||||
if not self._unique_id:
|
||||
self.page = BECWebEnginePage(self)
|
||||
self.page.authenticationRequired.connect(self._authenticate)
|
||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
||||
self.browser.setPage(self.page)
|
||||
return
|
||||
|
||||
# Try to get the page from the registry
|
||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
||||
if page_info and page_info.page:
|
||||
self.page = page_info.page
|
||||
if not page_info.owner_gui_id or page_info.owner_gui_id == self.gui_id:
|
||||
self.browser.setPage(self.page)
|
||||
# Only set URL if this is a newly created page (no URL set yet)
|
||||
if self.page.url().isEmpty():
|
||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
||||
|
||||
def _check_page_ready(self):
|
||||
"""
|
||||
Check if the page is ready and stop the timer if it is.
|
||||
"""
|
||||
if self.page.isLoading():
|
||||
if not self.page or self.page.isLoading():
|
||||
return
|
||||
|
||||
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
|
||||
@@ -219,7 +380,15 @@ class WebConsole(BECWidget, QWidget):
|
||||
self._is_initialized = True
|
||||
self._startup_timer.stop()
|
||||
if self._startup_cmd:
|
||||
self.write(self._startup_cmd)
|
||||
if self._unique_id:
|
||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
||||
if page_info is None:
|
||||
return
|
||||
if not page_info.initialized:
|
||||
page_info.initialized = True
|
||||
self.write(self._startup_cmd)
|
||||
else:
|
||||
self.write(self._startup_cmd)
|
||||
self.initialized.emit()
|
||||
|
||||
@SafeProperty(str)
|
||||
@@ -241,11 +410,112 @@ class WebConsole(BECWidget, QWidget):
|
||||
def write(self, data: str, send_return: bool = True):
|
||||
"""
|
||||
Send data to the web page
|
||||
|
||||
Args:
|
||||
data (str): The data to send.
|
||||
send_return (bool): Whether to send a return after the data.
|
||||
"""
|
||||
self.page.runJavaScript(f"window.term.paste('{data}');")
|
||||
cmd = f"window.term.paste({json.dumps(data)});"
|
||||
self.page.runJavaScript(cmd)
|
||||
if send_return:
|
||||
self.send_return()
|
||||
|
||||
def take_page_ownership(self, unique_id: str | None = None):
|
||||
"""
|
||||
Take ownership of a web page from the registry. This will transfer the page
|
||||
from its current owner (if any) to this widget.
|
||||
|
||||
Args:
|
||||
unique_id (str): The unique identifier of the page to take ownership of.
|
||||
If None, uses this widget's unique_id.
|
||||
"""
|
||||
if unique_id is None:
|
||||
unique_id = self._unique_id
|
||||
|
||||
if not unique_id:
|
||||
logger.warning("Cannot take page ownership without a unique_id")
|
||||
return
|
||||
|
||||
# Get the page from registry
|
||||
page = _web_console_registry.take_page_ownership(unique_id, self.gui_id)
|
||||
|
||||
if not page:
|
||||
logger.warning(f"Page {unique_id} not found in registry")
|
||||
return
|
||||
|
||||
self.page = page
|
||||
self.browser.setPage(page)
|
||||
self.browser.setVisible(True)
|
||||
logger.info(f"Widget {self.gui_id} took ownership of page {unique_id}")
|
||||
|
||||
def _on_ownership_lost(self):
|
||||
"""
|
||||
Called when this widget loses ownership of its page.
|
||||
Shows the retake button and hides the browser.
|
||||
"""
|
||||
# self._retake_button.setVisible(True)
|
||||
self.browser.setVisible(False)
|
||||
logger.info(f"Widget {self.gui_id} lost ownership of page {self._unique_id}")
|
||||
|
||||
def _on_retake_button_clicked(self):
|
||||
"""
|
||||
Called when the retake ownership button is clicked.
|
||||
"""
|
||||
if self._unique_id:
|
||||
self.take_page_ownership(self._unique_id)
|
||||
else:
|
||||
logger.warning("Cannot retake ownership without a unique_id")
|
||||
|
||||
def yield_ownership(self):
|
||||
"""
|
||||
Yield ownership of the page. The page remains in the registry with no owner,
|
||||
available for another widget to claim. This is automatically called when the
|
||||
widget becomes hidden.
|
||||
"""
|
||||
if not self._unique_id:
|
||||
return
|
||||
success = _web_console_registry.yield_ownership(self.gui_id)
|
||||
if success:
|
||||
self._on_ownership_lost()
|
||||
logger.info(f"Widget {self.gui_id} yielded ownership of page {self._unique_id}")
|
||||
|
||||
def has_ownership(self) -> bool:
|
||||
"""
|
||||
Check if this widget currently has ownership of a page.
|
||||
|
||||
Returns:
|
||||
bool: True if this widget owns a page, False otherwise.
|
||||
"""
|
||||
if not self._unique_id:
|
||||
return False
|
||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
||||
if page_info is None:
|
||||
return False
|
||||
return page_info.owner_gui_id == self.gui_id
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""
|
||||
Called when the widget is hidden. Automatically yields ownership.
|
||||
"""
|
||||
if self.has_ownership():
|
||||
self.yield_ownership()
|
||||
super().hideEvent(event)
|
||||
|
||||
def showEvent(self, event):
|
||||
"""
|
||||
Called when the widget is shown. Updates UI state based on ownership.
|
||||
"""
|
||||
super().showEvent(event)
|
||||
if self._unique_id and not self.has_ownership():
|
||||
# If the page does not have an owner, take ownership
|
||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
||||
if page_info is not None and page_info.owner_gui_id is None:
|
||||
self.take_page_ownership(self._unique_id)
|
||||
return
|
||||
# We have a unique_id but no ownership, show the retake button
|
||||
# self._retake_button.setVisible(True)
|
||||
self.browser.setVisible(False)
|
||||
|
||||
def _authenticate(self, _, auth):
|
||||
"""
|
||||
Authenticate the request with the provided username and password.
|
||||
@@ -289,7 +559,30 @@ class WebConsole(BECWidget, QWidget):
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = WebConsole()
|
||||
widget = QTabWidget()
|
||||
|
||||
# Create two consoles with different unique_ids
|
||||
web_console1 = WebConsole(startup_cmd="bec --nogui", unique_id="console1")
|
||||
web_console2 = WebConsole(startup_cmd="htop")
|
||||
web_console3 = WebConsole(startup_cmd="bec --nogui", unique_id="console1")
|
||||
widget.addTab(web_console1, "Console 1")
|
||||
widget.addTab(web_console2, "Console 2")
|
||||
widget.addTab(web_console3, "Console 3 -- mirror of Console 1")
|
||||
widget.show()
|
||||
|
||||
# Demonstrate page sharing:
|
||||
# After initialization, web_console2 can take ownership of console1's page:
|
||||
# web_console2.take_page_ownership("console1")
|
||||
|
||||
widget.resize(800, 600)
|
||||
|
||||
def _close_cons1():
|
||||
web_console2.close()
|
||||
web_console2.deleteLater()
|
||||
|
||||
# QTimer.singleShot(3000, _close_cons1)
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
@@ -8,7 +9,7 @@ import pyqtgraph as pg
|
||||
from bec_lib import bec_logger, messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal
|
||||
from qtpy.QtGui import QTransform
|
||||
from scipy.interpolate import (
|
||||
CloughTocher2DInterpolator,
|
||||
@@ -26,6 +27,7 @@ from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.widgets.plots.heatmap.settings.heatmap_setting import HeatmapSettings
|
||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -77,47 +79,97 @@ class HeatmapConfig(ConnectionConfig):
|
||||
_validate_color_palette = field_validator("color_map")(Colors.validate_color_map)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _InterpolationRequest:
|
||||
"""Immutable payload describing an interpolation request for the worker thread.
|
||||
|
||||
Args:
|
||||
x_data: X coordinates collected so far.
|
||||
y_data: Y coordinates collected so far.
|
||||
z_data: Z values associated with x/y.
|
||||
data_version: Number of points at request time (len(z_data)); used to reject stale results.
|
||||
scan_id: Identifier for the scan that produced the data.
|
||||
interpolation: Interpolation method to apply.
|
||||
oversampling_factor: Oversampling factor for the interpolation grid.
|
||||
"""
|
||||
|
||||
x_data: list[float]
|
||||
y_data: list[float]
|
||||
z_data: list[float]
|
||||
data_version: int
|
||||
scan_id: str
|
||||
interpolation: str
|
||||
oversampling_factor: float
|
||||
|
||||
|
||||
class _StepInterpolationWorker(QObject):
|
||||
"""Worker for performing step-scan interpolation in a background thread.
|
||||
|
||||
This worker computes the interpolated heatmap image using the provided data
|
||||
and settings, then emits the result or a failure signal.
|
||||
|
||||
Signals:
|
||||
finished(image, transform, data_version, scan_id):
|
||||
Emitted when interpolation is successful.
|
||||
- image: The resulting image (numpy array or similar).
|
||||
- transform: The QTransform for the image.
|
||||
- data_version: The data version for the request.
|
||||
- scan_id: The scan identifier.
|
||||
failed(error_message, data_version, scan_id):
|
||||
Emitted when interpolation fails.
|
||||
- error_message: The error message string.
|
||||
- data_version: The data version for the request.
|
||||
- scan_id: The scan identifier.
|
||||
"""
|
||||
|
||||
finished = Signal(object, object, int, str)
|
||||
failed = Signal(str, int, str)
|
||||
|
||||
def __init__(self, parent: QObject | None = None):
|
||||
super().__init__(parent=parent)
|
||||
self._active_request: _InterpolationRequest | None = None
|
||||
self._processing = False
|
||||
|
||||
@property
|
||||
def is_processing(self) -> bool:
|
||||
"""Return whether the worker is currently processing a request."""
|
||||
return self._processing
|
||||
|
||||
@SafeSlot(object, int)
|
||||
def process(self, request: _InterpolationRequest, data_version: int):
|
||||
"""
|
||||
Process an interpolation request in the worker thread.
|
||||
|
||||
Args:
|
||||
request(_InterpolationRequest): The interpolation request payload.
|
||||
data_version(int): The data version for the request.
|
||||
"""
|
||||
self._active_request = request
|
||||
self._processing = True
|
||||
try:
|
||||
image, transform = Heatmap.compute_step_scan_image(
|
||||
x_data=np.asarray(request.x_data, dtype=float),
|
||||
y_data=np.asarray(request.y_data, dtype=float),
|
||||
z_data=np.asarray(request.z_data, dtype=float),
|
||||
oversampling_factor=request.oversampling_factor,
|
||||
interpolation_method=request.interpolation,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(f"Step-scan interpolation failed with: {exc}")
|
||||
self.failed.emit(str(exc), data_version, request.scan_id)
|
||||
self._processing = False
|
||||
return
|
||||
self._processing = False
|
||||
self.finished.emit(image, transform, data_version, request.scan_id)
|
||||
|
||||
|
||||
class Heatmap(ImageBase):
|
||||
"""
|
||||
Heatmap widget for visualizing 2d grid data with color mapping for the z-axis.
|
||||
"""
|
||||
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
*PlotBase.USER_ACCESS,
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
@@ -127,8 +179,6 @@ class Heatmap(ImageBase):
|
||||
"v_min.setter",
|
||||
"v_max",
|
||||
"v_max.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"autorange",
|
||||
"autorange.setter",
|
||||
"autorange_mode",
|
||||
@@ -163,6 +213,7 @@ class Heatmap(ImageBase):
|
||||
new_scan_id = Signal(str)
|
||||
sync_signal_update = Signal()
|
||||
heatmap_property_changed = Signal()
|
||||
interpolation_requested = Signal(object, int)
|
||||
|
||||
def __init__(self, parent=None, config: HeatmapConfig | None = None, **kwargs):
|
||||
if config is None:
|
||||
@@ -185,6 +236,12 @@ class Heatmap(ImageBase):
|
||||
self.scan_item = None
|
||||
self.status_message = None
|
||||
self._grid_index = None
|
||||
# Highest data_version we have dispatched for the current scan; used to drop stale results.
|
||||
# Initialized to -1 so the first real request (len(z_data) >= 0) always supersedes it.
|
||||
self._latest_interpolation_version = -1
|
||||
self._interpolation_thread: QThread | None = None
|
||||
self._interpolation_worker: _StepInterpolationWorker | None = None
|
||||
self._pending_interpolation_request: _InterpolationRequest | None = None
|
||||
self.heatmap_dialog = None
|
||||
bg_color = pg.mkColor((240, 240, 240, 150))
|
||||
self.config_label = pg.LegendItem(
|
||||
@@ -303,6 +360,20 @@ class Heatmap(ImageBase):
|
||||
if show_config_label is None:
|
||||
show_config_label = self._image_config.show_config_label
|
||||
|
||||
def _device_key(device: HeatmapDeviceSignal | None) -> tuple[str | None, str | None]:
|
||||
return (device.name if device else None, device.entry if device else None)
|
||||
|
||||
prev_cfg = getattr(self, "_image_config", None)
|
||||
config_changed = False
|
||||
if prev_cfg and prev_cfg.x_device and prev_cfg.y_device and prev_cfg.z_device:
|
||||
config_changed = any(
|
||||
(
|
||||
_device_key(prev_cfg.x_device) != (x_name, x_entry),
|
||||
_device_key(prev_cfg.y_device) != (y_name, y_entry),
|
||||
_device_key(prev_cfg.z_device) != (z_name, z_entry),
|
||||
)
|
||||
)
|
||||
|
||||
self._image_config = HeatmapConfig(
|
||||
parent_id=self.gui_id,
|
||||
x_device=HeatmapDeviceSignal(name=x_name, entry=x_entry),
|
||||
@@ -317,7 +388,10 @@ class Heatmap(ImageBase):
|
||||
show_config_label=show_config_label,
|
||||
)
|
||||
self.color_map = color_map
|
||||
self.reload = reload
|
||||
self.reload = reload or config_changed
|
||||
if config_changed:
|
||||
self._grid_index = None
|
||||
self.main_image.clear()
|
||||
self.update_labels()
|
||||
|
||||
self._fetch_running_scan()
|
||||
@@ -444,6 +518,7 @@ class Heatmap(ImageBase):
|
||||
if current_scan_id is None:
|
||||
return
|
||||
if current_scan_id != self.scan_id:
|
||||
self._invalidate_interpolation_generation() # Invalidate any pending interpolation work when a new scan starts
|
||||
self.reset()
|
||||
self.new_scan.emit()
|
||||
self.new_scan_id.emit(current_scan_id)
|
||||
@@ -549,13 +624,38 @@ class Heatmap(ImageBase):
|
||||
if self._image_config.show_config_label:
|
||||
self.redraw_config_label()
|
||||
|
||||
img, transform = self.get_image_data(x_data=x_data, y_data=y_data, z_data=z_data)
|
||||
if img is None:
|
||||
if self._is_grid_scan_supported(scan_msg):
|
||||
img, transform = self.get_grid_scan_image(z_data, scan_msg)
|
||||
self._apply_image_update(img, transform)
|
||||
return
|
||||
|
||||
if len(z_data) < 4:
|
||||
# LinearNDInterpolator requires at least 4 points to interpolate
|
||||
logger.warning("Not enough data points to interpolate; skipping update.")
|
||||
return
|
||||
|
||||
self._request_step_scan_interpolation(x_data, y_data, z_data, scan_msg)
|
||||
|
||||
def _apply_image_update(self, img: np.ndarray | None, transform: QTransform | None):
|
||||
"""Apply interpolated image and transform to the heatmap display.
|
||||
|
||||
This method updates the main image with the computed data and emits
|
||||
the image_updated signal. Color bar signals are temporarily blocked
|
||||
during the update to prevent cascading updates.
|
||||
|
||||
Args:
|
||||
img(np.ndarray): The interpolated image data, or None if unavailable
|
||||
transform(QTransform): QTransform mapping pixel to world coordinates, or None if unavailable
|
||||
"""
|
||||
if img is None or transform is None:
|
||||
logger.warning("Image data is None; skipping update.")
|
||||
return
|
||||
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(True)
|
||||
if self.main_image is None:
|
||||
logger.warning("Main image item is None; cannot update image.")
|
||||
return
|
||||
self.main_image.set_data(img, transform=transform)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(False)
|
||||
@@ -563,6 +663,128 @@ class Heatmap(ImageBase):
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.update_markers_on_image_change()
|
||||
|
||||
def _request_step_scan_interpolation(
|
||||
self,
|
||||
x_data: list[float],
|
||||
y_data: list[float],
|
||||
z_data: list[float],
|
||||
msg: messages.ScanStatusMessage,
|
||||
):
|
||||
"""Request step-scan interpolation in a background thread.
|
||||
|
||||
If a thread is already running, the request is queued as a pending request
|
||||
and will be processed when the current interpolation completes.
|
||||
|
||||
Args:
|
||||
x_data(list[float]): X coordinates of data points
|
||||
y_data(list[float]): Y coordinates of data points
|
||||
z_data(list[float]): Z values at each point
|
||||
msg(messages.ScanStatusMessage): Scan status message containing scan metadata
|
||||
"""
|
||||
request = _InterpolationRequest(
|
||||
x_data=list(x_data),
|
||||
y_data=list(y_data),
|
||||
z_data=list(z_data),
|
||||
data_version=len(z_data),
|
||||
scan_id=msg.scan_id,
|
||||
interpolation=self._image_config.interpolation,
|
||||
oversampling_factor=self._image_config.oversampling_factor,
|
||||
)
|
||||
|
||||
if self._interpolation_worker is not None and self._interpolation_worker.is_processing:
|
||||
self._pending_interpolation_request = request
|
||||
return
|
||||
|
||||
self._start_step_scan_interpolation(request)
|
||||
|
||||
def _ensure_interpolation_thread(self):
|
||||
if self._interpolation_thread is None:
|
||||
self._interpolation_thread = QThread()
|
||||
self._interpolation_worker = _StepInterpolationWorker()
|
||||
self._interpolation_worker.moveToThread(self._interpolation_thread)
|
||||
self.interpolation_requested.connect(
|
||||
self._interpolation_worker.process, Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
self._interpolation_worker.finished.connect(
|
||||
self._on_interpolation_finished, Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
self._interpolation_worker.failed.connect(
|
||||
self._on_interpolation_failed, Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
if self._interpolation_thread is not None and not self._interpolation_thread.isRunning():
|
||||
self._interpolation_thread.start()
|
||||
|
||||
def _start_step_scan_interpolation(self, request: _InterpolationRequest):
|
||||
# data_version = len(z_data) at the time of the request; keep the latest to gate results.
|
||||
self._ensure_interpolation_thread()
|
||||
if self._interpolation_thread is not None and not self._interpolation_thread.isRunning():
|
||||
self._interpolation_thread.start()
|
||||
self._latest_interpolation_version = request.data_version
|
||||
self.interpolation_requested.emit(request, request.data_version)
|
||||
|
||||
def _on_interpolation_finished(
|
||||
self, img: np.ndarray, transform: QTransform, data_version: int, scan_id: str
|
||||
):
|
||||
# Only accept results that match the latest dispatched version for the active scan.
|
||||
if data_version == self._latest_interpolation_version and scan_id == self.scan_id:
|
||||
self._apply_image_update(img, transform)
|
||||
else:
|
||||
logger.info("Discarding outdated interpolation result.")
|
||||
self._maybe_start_pending_interpolation()
|
||||
|
||||
def _on_interpolation_failed(self, error: str, data_version: int, scan_id: str):
|
||||
logger.warning(f"Interpolation failed for scan {scan_id} (version {data_version}): {error}")
|
||||
self._maybe_start_pending_interpolation()
|
||||
|
||||
def _finish_interpolation_thread(self):
|
||||
self._pending_interpolation_request = None
|
||||
if self._interpolation_worker is not None:
|
||||
try:
|
||||
self.interpolation_requested.disconnect(self._interpolation_worker.process)
|
||||
except (TypeError, RuntimeError) as ext:
|
||||
logger.warning(f"Processing thread already disconnected: {ext}")
|
||||
pass
|
||||
self._interpolation_worker.deleteLater()
|
||||
self._interpolation_worker = None
|
||||
if self._interpolation_thread is not None:
|
||||
if self._interpolation_thread.isRunning():
|
||||
self._interpolation_thread.quit()
|
||||
if not self._interpolation_thread.wait(3000): # 3s timeout
|
||||
logger.error(
|
||||
f"Interpolation thread of widget {self.gui_id} did not stop within timeout 3s; leaving it dangling."
|
||||
)
|
||||
self._interpolation_thread.deleteLater()
|
||||
self._interpolation_thread = None
|
||||
logger.info(f"Interpolation thread finished of widget {self.gui_id}")
|
||||
|
||||
def _maybe_start_pending_interpolation(self):
|
||||
if self._pending_interpolation_request is None:
|
||||
return
|
||||
if self._pending_interpolation_request.scan_id != self.scan_id:
|
||||
self._pending_interpolation_request = None
|
||||
return
|
||||
if self._interpolation_worker is not None and self._interpolation_worker.is_processing:
|
||||
return
|
||||
|
||||
pending = self._pending_interpolation_request
|
||||
self._pending_interpolation_request = None
|
||||
self._start_step_scan_interpolation(pending)
|
||||
|
||||
def _cancel_interpolation(self):
|
||||
"""Cancel any pending interpolation request without invalidating in-flight work.
|
||||
|
||||
This clears the pending request queue but does not invalidate in-flight work,
|
||||
allowing any currently running interpolation to complete and update the display
|
||||
if it matches the current scan.
|
||||
"""
|
||||
self._pending_interpolation_request = None
|
||||
# Do not change the active data version so an in-flight worker can still deliver.
|
||||
|
||||
def _invalidate_interpolation_generation(self):
|
||||
"""Invalidate all pending interpolation results and ignore in-flight updates."""
|
||||
self._pending_interpolation_request = None
|
||||
self._latest_interpolation_version = -1
|
||||
|
||||
def redraw_config_label(self):
|
||||
scan_msg = self.status_message
|
||||
if scan_msg is None:
|
||||
@@ -608,21 +830,35 @@ class Heatmap(ImageBase):
|
||||
logger.warning("x, y, or z data is None; skipping update.")
|
||||
return None, None
|
||||
|
||||
if msg.scan_name == "grid_scan" and not self._image_config.enforce_interpolation:
|
||||
# We only support the grid scan mode if both scanning motors
|
||||
# are configured in the heatmap config.
|
||||
device_x = self._image_config.x_device.entry
|
||||
device_y = self._image_config.y_device.entry
|
||||
if (
|
||||
device_x in msg.request_inputs["arg_bundle"]
|
||||
and device_y in msg.request_inputs["arg_bundle"]
|
||||
):
|
||||
return self.get_grid_scan_image(z_data, msg)
|
||||
if self._is_grid_scan_supported(msg):
|
||||
return self.get_grid_scan_image(z_data, msg)
|
||||
if len(z_data) < 4:
|
||||
# LinearNDInterpolator requires at least 4 points to interpolate
|
||||
return None, None
|
||||
return self.get_step_scan_image(x_data, y_data, z_data, msg)
|
||||
|
||||
def _is_grid_scan_supported(self, msg: messages.ScanStatusMessage) -> bool:
|
||||
"""Check if the scan can use optimized grid_scan rendering.
|
||||
|
||||
Grid scans can avoid interpolation if both X and Y devices match
|
||||
the configured devices and interpolation is not enforced.
|
||||
|
||||
Args:
|
||||
msg(messages.ScanStatusMessage): Scan status message containing scan metadata
|
||||
|
||||
Returns:
|
||||
True if grid_scan optimization is applicable, False otherwise
|
||||
"""
|
||||
if msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation:
|
||||
return False
|
||||
|
||||
device_x = self._image_config.x_device.entry
|
||||
device_y = self._image_config.y_device.entry
|
||||
return (
|
||||
device_x in msg.request_inputs["arg_bundle"]
|
||||
and device_y in msg.request_inputs["arg_bundle"]
|
||||
)
|
||||
|
||||
def get_grid_scan_image(
|
||||
self, z_data: list[float], msg: messages.ScanStatusMessage
|
||||
) -> tuple[np.ndarray, QTransform]:
|
||||
@@ -638,55 +874,51 @@ class Heatmap(ImageBase):
|
||||
|
||||
args = self.arg_bundle_to_dict(4, msg.request_inputs["arg_bundle"])
|
||||
|
||||
shape = (
|
||||
args[self._image_config.x_device.entry][-1],
|
||||
args[self._image_config.y_device.entry][-1],
|
||||
)
|
||||
x_entry = self._image_config.x_device.entry
|
||||
y_entry = self._image_config.y_device.entry
|
||||
shape = (args[x_entry][-1], args[y_entry][-1])
|
||||
|
||||
data = self.main_image.raw_data
|
||||
|
||||
if data is None or data.shape != shape:
|
||||
data = np.empty(shape)
|
||||
data.fill(np.nan)
|
||||
|
||||
def _get_grid_data(axis, snaked=True):
|
||||
x_grid, y_grid = np.meshgrid(axis[0], axis[1])
|
||||
if snaked:
|
||||
y_grid.T[::2] = np.fliplr(y_grid.T[::2])
|
||||
x_flat = x_grid.T.ravel()
|
||||
y_flat = y_grid.T.ravel()
|
||||
positions = np.vstack((x_flat, y_flat)).T
|
||||
return positions
|
||||
elif self.reload:
|
||||
data.fill(np.nan)
|
||||
|
||||
snaked = msg.request_inputs["kwargs"].get("snaked", True)
|
||||
|
||||
# If the scan's fast axis is x, we need to swap the x and y axes
|
||||
swap = bool(msg.request_inputs["arg_bundle"][4] == self._image_config.x_device.entry)
|
||||
slow_entry, fast_entry = (
|
||||
msg.request_inputs["arg_bundle"][0],
|
||||
msg.request_inputs["arg_bundle"][4],
|
||||
)
|
||||
|
||||
# calculate the QTransform to put (0,0) at the axis origin
|
||||
scan_pos = np.asarray(msg.info["positions"])
|
||||
x_min = min(scan_pos[:, 0])
|
||||
x_max = max(scan_pos[:, 0])
|
||||
y_min = min(scan_pos[:, 1])
|
||||
y_max = max(scan_pos[:, 1])
|
||||
scan_pos = np.asarray(msg.info["positions"], dtype=float)
|
||||
relative = bool(msg.request_inputs["kwargs"].get("relative", False))
|
||||
|
||||
x_range = x_max - x_min
|
||||
y_range = y_max - y_min
|
||||
def _axis_column(entry: str) -> int:
|
||||
return 0 if entry == slow_entry else 1
|
||||
|
||||
pixel_size_x = x_range / (shape[0] - 1)
|
||||
pixel_size_y = y_range / (shape[1] - 1)
|
||||
def _axis_levels(entry: str, npts: int) -> np.ndarray:
|
||||
start, stop = args[entry][:2]
|
||||
if relative:
|
||||
origin = float(scan_pos[0, _axis_column(entry)] - start)
|
||||
return origin + np.linspace(start, stop, npts)
|
||||
return np.linspace(start, stop, npts)
|
||||
|
||||
x_levels = _axis_levels(x_entry, shape[0])
|
||||
y_levels = _axis_levels(y_entry, shape[1])
|
||||
|
||||
pixel_size_x = (
|
||||
float(x_levels[-1] - x_levels[0]) / max(shape[0] - 1, 1) if shape[0] > 1 else 1.0
|
||||
)
|
||||
pixel_size_y = (
|
||||
float(y_levels[-1] - y_levels[0]) / max(shape[1] - 1, 1) if shape[1] > 1 else 1.0
|
||||
)
|
||||
|
||||
transform = QTransform()
|
||||
if swap:
|
||||
transform.scale(pixel_size_y, pixel_size_x)
|
||||
transform.translate(y_min / pixel_size_y - 0.5, x_min / pixel_size_x - 0.5)
|
||||
else:
|
||||
transform.scale(pixel_size_x, pixel_size_y)
|
||||
transform.translate(x_min / pixel_size_x - 0.5, y_min / pixel_size_y - 0.5)
|
||||
|
||||
target_positions = _get_grid_data(
|
||||
(np.arange(shape[int(swap)]), np.arange(shape[int(not swap)])), snaked=snaked
|
||||
)
|
||||
transform.scale(pixel_size_x, pixel_size_y)
|
||||
transform.translate(x_levels[0] / pixel_size_x - 0.5, y_levels[0] / pixel_size_y - 0.5)
|
||||
|
||||
# Fill the data array with the z values
|
||||
if self._grid_index is None or self.reload:
|
||||
@@ -694,7 +926,16 @@ class Heatmap(ImageBase):
|
||||
self.reload = False
|
||||
|
||||
for i in range(self._grid_index, len(z_data)):
|
||||
data[target_positions[i, int(swap)], target_positions[i, int(not swap)]] = z_data[i]
|
||||
slow_i, fast_i = divmod(i, args[fast_entry][-1])
|
||||
if snaked and (slow_i % 2 == 1):
|
||||
fast_i = args[fast_entry][-1] - 1 - fast_i
|
||||
|
||||
if x_entry == fast_entry:
|
||||
x_i, y_i = fast_i, slow_i
|
||||
else:
|
||||
x_i, y_i = slow_i, fast_i
|
||||
|
||||
data[x_i, y_i] = z_data[i]
|
||||
self._grid_index = len(z_data)
|
||||
return data, transform
|
||||
|
||||
@@ -717,17 +958,49 @@ class Heatmap(ImageBase):
|
||||
Returns:
|
||||
tuple[np.ndarray, QTransform]: The image data and the QTransform.
|
||||
"""
|
||||
xy_data = np.column_stack((x_data, y_data))
|
||||
grid_x, grid_y, transform = self.get_image_grid(xy_data)
|
||||
return self.compute_step_scan_image(
|
||||
x_data=x_data,
|
||||
y_data=y_data,
|
||||
z_data=z_data,
|
||||
oversampling_factor=self._image_config.oversampling_factor,
|
||||
interpolation_method=self._image_config.interpolation,
|
||||
)
|
||||
|
||||
# Interpolate the z data onto the grid
|
||||
if self._image_config.interpolation == "linear":
|
||||
@staticmethod
|
||||
def compute_step_scan_image(
|
||||
x_data: list[float] | np.ndarray,
|
||||
y_data: list[float] | np.ndarray,
|
||||
z_data: list[float] | np.ndarray,
|
||||
oversampling_factor: float,
|
||||
interpolation_method: str,
|
||||
) -> tuple[np.ndarray, QTransform]:
|
||||
"""Compute interpolated heatmap image from step-scan data.
|
||||
|
||||
This static method is suitable for execution in a background thread
|
||||
as it doesn't access any instance state.
|
||||
|
||||
Args:
|
||||
x_data(list[float]): X coordinates of data points
|
||||
y_data(list[float]): Y coordinates of data points
|
||||
z_data(list[float]): Z values at each point
|
||||
oversampling_factor(float): Grid resolution multiplier (>1.0 for higher resolution)
|
||||
interpolation_method(str): One of 'linear', 'nearest', or 'clough'
|
||||
|
||||
Returns:
|
||||
(tuple[np.ndarray, QTransform]):Tuple of (interpolated_grid, transform) where transform maps pixel to world coordinates
|
||||
"""
|
||||
xy_data = np.column_stack((x_data, y_data))
|
||||
grid_x, grid_y, transform = Heatmap.build_image_grid(
|
||||
positions=xy_data, oversampling_factor=oversampling_factor
|
||||
)
|
||||
|
||||
if interpolation_method == "linear":
|
||||
interp = LinearNDInterpolator(xy_data, z_data)
|
||||
elif self._image_config.interpolation == "nearest":
|
||||
elif interpolation_method == "nearest":
|
||||
interp = NearestNDInterpolator(xy_data, z_data)
|
||||
elif self._image_config.interpolation == "clough":
|
||||
elif interpolation_method == "clough":
|
||||
interp = CloughTocher2DInterpolator(xy_data, z_data)
|
||||
else:
|
||||
else: # pragma: no cover - guarded by validation
|
||||
raise ValueError(
|
||||
"Interpolation method must be either 'linear', 'nearest', or 'clough'."
|
||||
)
|
||||
@@ -746,22 +1019,33 @@ class Heatmap(ImageBase):
|
||||
Returns:
|
||||
tuple[np.ndarray, np.ndarray, QTransform]: The grid x and y coordinates and the QTransform.
|
||||
"""
|
||||
base_width, base_height = self.estimate_image_resolution(positions)
|
||||
return self.build_image_grid(
|
||||
positions=positions, oversampling_factor=self._image_config.oversampling_factor
|
||||
)
|
||||
|
||||
# Apply oversampling factor
|
||||
factor = self._image_config.oversampling_factor
|
||||
@staticmethod
|
||||
def build_image_grid(
|
||||
positions: np.ndarray, oversampling_factor: float
|
||||
) -> tuple[np.ndarray, np.ndarray, QTransform]:
|
||||
"""Build an interpolation grid covering the data positions.
|
||||
|
||||
# Apply oversampling
|
||||
width = int(base_width * factor)
|
||||
height = int(base_height * factor)
|
||||
Args:
|
||||
positions: (N, 2) array of (x, y) coordinates
|
||||
oversampling_factor: Grid resolution multiplier (>1.0 for higher resolution)
|
||||
|
||||
Returns:
|
||||
Tuple of (grid_x, grid_y, transform) where grid_x/grid_y are meshgrids
|
||||
for interpolation and transform maps pixel to world coordinates
|
||||
"""
|
||||
base_width, base_height = Heatmap.estimate_image_resolution(positions)
|
||||
width = max(1, int(base_width * oversampling_factor))
|
||||
height = max(1, int(base_height * oversampling_factor))
|
||||
|
||||
# Create grid
|
||||
grid_x, grid_y = np.mgrid[
|
||||
min(positions[:, 0]) : max(positions[:, 0]) : width * 1j,
|
||||
min(positions[:, 1]) : max(positions[:, 1]) : height * 1j,
|
||||
]
|
||||
|
||||
# Calculate transform
|
||||
x_min, x_max = min(positions[:, 0]), max(positions[:, 0])
|
||||
y_min, y_max = min(positions[:, 1]), max(positions[:, 1])
|
||||
x_range = x_max - x_min
|
||||
@@ -845,6 +1129,7 @@ class Heatmap(ImageBase):
|
||||
return scan_devices, "value"
|
||||
|
||||
def reset(self):
|
||||
self._cancel_interpolation()
|
||||
self._grid_index = None
|
||||
self.main_image.clear()
|
||||
if self.crosshair is not None:
|
||||
@@ -979,6 +1264,10 @@ class Heatmap(ImageBase):
|
||||
"""
|
||||
self.main_image.transpose = enable
|
||||
|
||||
def cleanup(self):
|
||||
self._finish_interpolation_thread()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
@@ -22,6 +22,7 @@ from bec_widgets.widgets.control.device_input.base_classes.device_input_base imp
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -59,41 +60,7 @@ class Image(ImageBase):
|
||||
RPC = True
|
||||
ICON_NAME = "image"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
*PlotBase.USER_ACCESS,
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
@@ -103,8 +70,6 @@ class Image(ImageBase):
|
||||
"v_min.setter",
|
||||
"v_max",
|
||||
"v_max.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"autorange",
|
||||
"autorange.setter",
|
||||
"autorange_mode",
|
||||
|
||||
@@ -17,7 +17,9 @@ from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||
from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings
|
||||
from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import (
|
||||
MotorSelectionAction,
|
||||
MotorSelection,
|
||||
MotorSelectionConnection,
|
||||
motor_selection_bundle,
|
||||
)
|
||||
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
|
||||
|
||||
@@ -90,47 +92,7 @@ class MotorMap(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "my_location"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
*PlotBase.USER_ACCESS,
|
||||
# motor_map specific
|
||||
"color",
|
||||
"color.setter",
|
||||
@@ -147,6 +109,10 @@ class MotorMap(PlotBase):
|
||||
"map",
|
||||
"reset_history",
|
||||
"get_data",
|
||||
"x_motor",
|
||||
"x_motor.setter",
|
||||
"y_motor",
|
||||
"y_motor.setter",
|
||||
]
|
||||
|
||||
update_signal = Signal()
|
||||
@@ -195,11 +161,10 @@ class MotorMap(PlotBase):
|
||||
"""
|
||||
Initialize the toolbar for the motor map widget.
|
||||
"""
|
||||
motor_selection = MotorSelectionAction(parent=self)
|
||||
self.toolbar.add_action("motor_selection", motor_selection)
|
||||
|
||||
motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed)
|
||||
motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed)
|
||||
self.toolbar.add_bundle(motor_selection_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"motor_selection", MotorSelectionConnection(self.toolbar.components, target_widget=self)
|
||||
)
|
||||
|
||||
self.toolbar.components.get_action("reset_legend").action.setVisible(False)
|
||||
|
||||
@@ -228,12 +193,19 @@ class MotorMap(PlotBase):
|
||||
if self.ui_mode == UIMode.POPUP:
|
||||
bundles.append("axis_popup")
|
||||
self.toolbar.show_bundles(bundles)
|
||||
self._sync_motor_map_selection_toolbar()
|
||||
|
||||
@SafeSlot()
|
||||
def on_motor_selection_changed(self, _):
|
||||
action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection")
|
||||
motor_x = action.motor_x.currentText()
|
||||
motor_y = action.motor_y.currentText()
|
||||
action = self.toolbar.components.get_action("motor_selection")
|
||||
motor_selection: MotorSelection = action.widget
|
||||
motor_x = motor_selection.motor_x.currentText()
|
||||
motor_y = motor_selection.motor_y.currentText()
|
||||
|
||||
if motor_x and not self._validate_motor_name(motor_x):
|
||||
return
|
||||
if motor_y and not self._validate_motor_name(motor_y):
|
||||
return
|
||||
|
||||
if motor_x != "" and motor_y != "":
|
||||
if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name:
|
||||
@@ -286,6 +258,36 @@ class MotorMap(PlotBase):
|
||||
# Widget Specific Properties
|
||||
################################################################################
|
||||
|
||||
@SafeProperty(str)
|
||||
def x_motor(self) -> str:
|
||||
"""Name of the motor shown on the X axis."""
|
||||
return self.config.x_motor.name or ""
|
||||
|
||||
@x_motor.setter
|
||||
def x_motor(self, motor_name: str) -> None:
|
||||
motor_name = motor_name or ""
|
||||
if motor_name == (self.config.x_motor.name or ""):
|
||||
return
|
||||
if motor_name and self.y_motor:
|
||||
self.map(motor_name, self.y_motor, suppress_errors=True)
|
||||
return
|
||||
self._set_motor_name(axis="x", motor_name=motor_name)
|
||||
|
||||
@SafeProperty(str)
|
||||
def y_motor(self) -> str:
|
||||
"""Name of the motor shown on the Y axis."""
|
||||
return self.config.y_motor.name or ""
|
||||
|
||||
@y_motor.setter
|
||||
def y_motor(self, motor_name: str) -> None:
|
||||
motor_name = motor_name or ""
|
||||
if motor_name == (self.config.y_motor.name or ""):
|
||||
return
|
||||
if motor_name and self.x_motor:
|
||||
self.map(self.x_motor, motor_name, suppress_errors=True)
|
||||
return
|
||||
self._set_motor_name(axis="y", motor_name=motor_name)
|
||||
|
||||
# color_scatter for designer, color for CLI to not bother users with QColor
|
||||
@SafeProperty("QColor")
|
||||
def color_scatter(self) -> QtGui.QColor:
|
||||
@@ -427,11 +429,47 @@ class MotorMap(PlotBase):
|
||||
self.update_signal.emit()
|
||||
self.property_changed.emit("scatter_size", scatter_size)
|
||||
|
||||
def _validate_motor_name(self, motor_name: str) -> bool:
|
||||
"""
|
||||
Check motor validity against BEC without raising.
|
||||
|
||||
Args:
|
||||
motor_name(str): Name of the motor to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if motor is valid, False otherwise.
|
||||
"""
|
||||
if not motor_name:
|
||||
return False
|
||||
try:
|
||||
self.entry_validator.validate_signal(motor_name, None)
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - validator can raise multiple error types
|
||||
return False
|
||||
|
||||
def _set_motor_name(self, axis: str, motor_name: str, *, sync_toolbar: bool = True) -> None:
|
||||
"""
|
||||
Update stored motor name for given axis and optionally refresh the toolbar selection.
|
||||
"""
|
||||
motor_name = motor_name or ""
|
||||
motor_config = self.config.x_motor if axis == "x" else self.config.y_motor
|
||||
|
||||
if motor_config.name == motor_name:
|
||||
return
|
||||
|
||||
motor_config.name = motor_name
|
||||
self.property_changed.emit(f"{axis}_motor", motor_name)
|
||||
|
||||
if sync_toolbar:
|
||||
self._sync_motor_map_selection_toolbar()
|
||||
|
||||
################################################################################
|
||||
# High Level methods for API
|
||||
################################################################################
|
||||
@SafeSlot()
|
||||
def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None:
|
||||
def map(
|
||||
self, x_name: str, y_name: str, validate_bec: bool = True, suppress_errors=False
|
||||
) -> None:
|
||||
"""
|
||||
Set the x and y motor names.
|
||||
|
||||
@@ -439,15 +477,23 @@ class MotorMap(PlotBase):
|
||||
x_name(str): The name of the x motor.
|
||||
y_name(str): The name of the y motor.
|
||||
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
|
||||
suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied.
|
||||
"""
|
||||
self.plot_item.clear()
|
||||
|
||||
if validate_bec:
|
||||
self.entry_validator.validate_signal(x_name, None)
|
||||
self.entry_validator.validate_signal(y_name, None)
|
||||
if suppress_errors:
|
||||
try:
|
||||
self.entry_validator.validate_signal(x_name, None)
|
||||
self.entry_validator.validate_signal(y_name, None)
|
||||
except Exception:
|
||||
return
|
||||
else:
|
||||
self.entry_validator.validate_signal(x_name, None)
|
||||
self.entry_validator.validate_signal(y_name, None)
|
||||
|
||||
self.config.x_motor.name = x_name
|
||||
self.config.y_motor.name = y_name
|
||||
self._set_motor_name(axis="x", motor_name=x_name, sync_toolbar=False)
|
||||
self._set_motor_name(axis="y", motor_name=y_name, sync_toolbar=False)
|
||||
|
||||
motor_x_limit = self._get_motor_limit(self.config.x_motor.name)
|
||||
motor_y_limit = self._get_motor_limit(self.config.y_motor.name)
|
||||
@@ -774,21 +820,24 @@ class MotorMap(PlotBase):
|
||||
"""
|
||||
Sync the motor map selection toolbar with the current motor map.
|
||||
"""
|
||||
motor_selection = self.toolbar.components.get_action("motor_selection")
|
||||
try:
|
||||
motor_selection_action = self.toolbar.components.get_action("motor_selection")
|
||||
except Exception: # noqa: BLE001 - toolbar might not be ready during early init
|
||||
logger.warning(f"MotorMap ({self.object_name}) toolbar was not ready during init.")
|
||||
return
|
||||
if motor_selection_action is None:
|
||||
return
|
||||
motor_selection: MotorSelection = motor_selection_action.widget
|
||||
target_x = self.config.x_motor.name or ""
|
||||
target_y = self.config.y_motor.name or ""
|
||||
|
||||
motor_x = motor_selection.motor_x.currentText()
|
||||
motor_y = motor_selection.motor_y.currentText()
|
||||
if (
|
||||
motor_selection.motor_x.currentText() == target_x
|
||||
and motor_selection.motor_y.currentText() == target_y
|
||||
):
|
||||
return
|
||||
|
||||
if motor_x != self.config.x_motor.name:
|
||||
motor_selection.motor_x.blockSignals(True)
|
||||
motor_selection.motor_x.set_device(self.config.x_motor.name)
|
||||
motor_selection.motor_x.check_validity(self.config.x_motor.name)
|
||||
motor_selection.motor_x.blockSignals(False)
|
||||
if motor_y != self.config.y_motor.name:
|
||||
motor_selection.motor_y.blockSignals(True)
|
||||
motor_selection.motor_y.set_device(self.config.y_motor.name)
|
||||
motor_selection.motor_y.check_validity(self.config.y_motor.name)
|
||||
motor_selection.motor_y.blockSignals(False)
|
||||
motor_selection.set_motors(target_x, target_y)
|
||||
|
||||
################################################################################
|
||||
# Export Methods
|
||||
|
||||
@@ -1,43 +1,55 @@
|
||||
from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget
|
||||
from qtpy.QtWidgets import QHBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction
|
||||
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
|
||||
class MotorSelectionAction(ToolBarAction):
|
||||
class MotorSelection(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(icon_path=None, tooltip=None, checkable=False)
|
||||
self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.motor_x = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER])
|
||||
self.motor_x.addItem("", None)
|
||||
self.motor_x.setCurrentText("")
|
||||
self.motor_x.setToolTip("Select Motor X")
|
||||
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
|
||||
self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER])
|
||||
self.motor_x.setEditable(True)
|
||||
self.motor_y = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER])
|
||||
self.motor_y.addItem("", None)
|
||||
self.motor_y.setCurrentText("")
|
||||
self.motor_y.setToolTip("Select Motor Y")
|
||||
self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
|
||||
self.motor_y.setEditable(True)
|
||||
|
||||
self.container = QWidget(parent)
|
||||
layout = QHBoxLayout(self.container)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.motor_x)
|
||||
layout.addWidget(self.motor_y)
|
||||
self.container.setLayout(layout)
|
||||
self.action = self.container
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the widget to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
|
||||
toolbar.addWidget(self.container)
|
||||
def set_motors(self, motor_x: str | None, motor_y: str | None) -> None:
|
||||
"""Set the displayed motors without emitting selection signals."""
|
||||
motor_x = motor_x or ""
|
||||
motor_y = motor_y or ""
|
||||
self.motor_x.blockSignals(True)
|
||||
self.motor_y.blockSignals(True)
|
||||
try:
|
||||
if motor_x:
|
||||
self.motor_x.set_device(motor_x)
|
||||
self.motor_x.check_validity(motor_x)
|
||||
else:
|
||||
self.motor_x.setCurrentText("")
|
||||
if motor_y:
|
||||
self.motor_y.set_device(motor_y)
|
||||
self.motor_y.check_validity(motor_y)
|
||||
else:
|
||||
self.motor_y.setCurrentText("")
|
||||
finally:
|
||||
self.motor_x.blockSignals(False)
|
||||
self.motor_y.blockSignals(False)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
@@ -47,5 +59,57 @@ class MotorSelectionAction(ToolBarAction):
|
||||
self.motor_x.deleteLater()
|
||||
self.motor_y.close()
|
||||
self.motor_y.deleteLater()
|
||||
self.container.close()
|
||||
self.container.deleteLater()
|
||||
|
||||
|
||||
def motor_selection_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a workspace toolbar bundle for MotorMap.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The workspace toolbar bundle.
|
||||
"""
|
||||
|
||||
motor_selection_widget = MotorSelection(parent=components.toolbar)
|
||||
components.add_safe(
|
||||
"motor_selection", WidgetAction(widget=motor_selection_widget, adjust_size=False)
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("motor_selection", components)
|
||||
bundle.add_action("motor_selection")
|
||||
return bundle
|
||||
|
||||
|
||||
class MotorSelectionConnection(BundleConnection):
|
||||
"""
|
||||
Connection helper for the motor selection bundle.
|
||||
"""
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
super().__init__(parent=components.toolbar)
|
||||
self.bundle_name = "motor_selection"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
self._connected = False
|
||||
|
||||
def _widget(self) -> MotorSelection:
|
||||
return self.components.get_action("motor_selection").widget
|
||||
|
||||
def connect(self):
|
||||
if self._connected:
|
||||
return
|
||||
widget = self._widget()
|
||||
widget.motor_x.currentTextChanged.connect(self.target_widget.on_motor_selection_changed)
|
||||
widget.motor_y.currentTextChanged.connect(self.target_widget.on_motor_selection_changed)
|
||||
self._connected = True
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
widget = self._widget()
|
||||
widget.motor_x.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed)
|
||||
widget.motor_y.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed)
|
||||
self._connected = False
|
||||
widget.cleanup()
|
||||
|
||||
@@ -56,49 +56,7 @@ class MultiWaveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "ssid_chart"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
*PlotBase.USER_ACCESS,
|
||||
# MultiWaveform Specific RPC Access
|
||||
"highlighted_index",
|
||||
"highlighted_index.setter",
|
||||
|
||||
@@ -63,6 +63,50 @@ class UIMode(Enum):
|
||||
class PlotBase(BECWidget, QWidget):
|
||||
PLUGIN = False
|
||||
RPC = False
|
||||
BASE_USER_ACCESS = [
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"screenshot",
|
||||
]
|
||||
USER_ACCESS = [*BECWidget.USER_ACCESS, *BASE_USER_ACCESS]
|
||||
|
||||
# Custom Signals
|
||||
property_changed = Signal(str, object)
|
||||
@@ -844,6 +888,40 @@ class PlotBase(BECWidget, QWidget):
|
||||
self._apply_y_label()
|
||||
self.property_changed.emit("inner_axes", value)
|
||||
|
||||
@SafeProperty(bool, doc="Invert X axis.")
|
||||
def invert_x(self) -> bool:
|
||||
"""
|
||||
Invert X axis.
|
||||
"""
|
||||
return self.plot_item.vb.state.get("xInverted", False)
|
||||
|
||||
@invert_x.setter
|
||||
def invert_x(self, value: bool):
|
||||
"""
|
||||
Invert X axis.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.vb.invertX(value)
|
||||
|
||||
@SafeProperty(bool, doc="Invert Y axis.")
|
||||
def invert_y(self) -> bool:
|
||||
"""
|
||||
Invert Y axis.
|
||||
"""
|
||||
return self.plot_item.vb.state.get("yInverted", False)
|
||||
|
||||
@invert_y.setter
|
||||
def invert_y(self, value: bool):
|
||||
"""
|
||||
Invert Y axis.
|
||||
|
||||
Args:
|
||||
value(bool): The value to set.
|
||||
"""
|
||||
self.plot_item.vb.invertY(value)
|
||||
|
||||
@SafeProperty(bool, doc="Lock aspect ratio of the plot widget.")
|
||||
def lock_aspect_ratio(self) -> bool:
|
||||
"""
|
||||
|
||||
@@ -558,8 +558,8 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
|
||||
def get_coordinates(self, typed: bool | None = None) -> dict | tuple:
|
||||
"""
|
||||
Returns the coordinates of a rectangle's corners. Supports returning them
|
||||
as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
Returns the coordinates of a rectangle's corners, rectangle center and dimensions.
|
||||
Supports returning them as either a dictionary with descriptive keys or a tuple of coordinates.
|
||||
|
||||
Args:
|
||||
typed (bool | None): If True, returns coordinates as a dictionary with
|
||||
@@ -567,13 +567,17 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
the value of `self.description`.
|
||||
|
||||
Returns:
|
||||
dict | tuple: The rectangle's corner coordinates, where the format
|
||||
dict | tuple: The rectangle's corner coordinates, rectangle center and dimensions, where the format
|
||||
depends on the `typed` parameter.
|
||||
"""
|
||||
if typed is None:
|
||||
typed = self.description
|
||||
|
||||
x_left, y_bottom, x_right, y_top = self._normalized_edges()
|
||||
width = x_right - x_left
|
||||
height = y_top - y_bottom
|
||||
cx = x_left + width / 2
|
||||
cy = y_bottom + height / 2
|
||||
|
||||
if typed:
|
||||
return {
|
||||
@@ -581,8 +585,19 @@ class RectangularROI(BaseROI, pg.RectROI):
|
||||
"bottom_right": (x_right, y_bottom),
|
||||
"top_left": (x_left, y_top),
|
||||
"top_right": (x_right, y_top),
|
||||
"center_x": cx,
|
||||
"center_y": cy,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
return (x_left, y_bottom), (x_right, y_bottom), (x_left, y_top), (x_right, y_top)
|
||||
return (
|
||||
(x_left, y_bottom),
|
||||
(x_right, y_bottom),
|
||||
(x_left, y_top),
|
||||
(x_right, y_top),
|
||||
(cx, cy),
|
||||
(width, height),
|
||||
)
|
||||
|
||||
def _lookup_scene_image(self):
|
||||
"""
|
||||
|
||||
@@ -43,49 +43,7 @@ class ScatterWaveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "scatter_plot"
|
||||
USER_ACCESS = [
|
||||
# General PlotBase Settings
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
*PlotBase.USER_ACCESS,
|
||||
# Scatter Waveform Specific RPC Access
|
||||
"main_curve",
|
||||
"color_map",
|
||||
|
||||
@@ -206,6 +206,7 @@ class Curve(BECConnector, pg.PlotDataItem):
|
||||
"""
|
||||
if self.config.source in ["custom", "history"]:
|
||||
self.setData(x, y)
|
||||
self.parent_item.request_dap_update.emit()
|
||||
else:
|
||||
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtGui import QValidator
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
|
||||
class ScanIndexValidator(QValidator):
|
||||
|
||||
@@ -67,52 +67,8 @@ class Waveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "show_chart"
|
||||
USER_ACCESS = [
|
||||
# BECWidget Base Class
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# General PlotBase Settings
|
||||
*PlotBase.USER_ACCESS,
|
||||
"_config_dict",
|
||||
"enable_toolbar",
|
||||
"enable_toolbar.setter",
|
||||
"enable_side_panel",
|
||||
"enable_side_panel.setter",
|
||||
"enable_fps_monitor",
|
||||
"enable_fps_monitor.setter",
|
||||
"set",
|
||||
"title",
|
||||
"title.setter",
|
||||
"x_label",
|
||||
"x_label.setter",
|
||||
"y_label",
|
||||
"y_label.setter",
|
||||
"x_limits",
|
||||
"x_limits.setter",
|
||||
"y_limits",
|
||||
"y_limits.setter",
|
||||
"x_grid",
|
||||
"x_grid.setter",
|
||||
"y_grid",
|
||||
"y_grid.setter",
|
||||
"inner_axes",
|
||||
"inner_axes.setter",
|
||||
"outer_axes",
|
||||
"outer_axes.setter",
|
||||
"lock_aspect_ratio",
|
||||
"lock_aspect_ratio.setter",
|
||||
"auto_range_x",
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"auto_range",
|
||||
"x_log",
|
||||
"x_log.setter",
|
||||
"y_log",
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
# Waveform Specific RPC Access
|
||||
"curves",
|
||||
"x_mode",
|
||||
@@ -762,9 +718,9 @@ class Waveform(PlotBase):
|
||||
y_entry(str): The name of the entry for the y-axis.
|
||||
color(str): The color of the curve.
|
||||
label(str): The label of the curve.
|
||||
dap(str): The dap model to use for the curve, only available for sync devices.
|
||||
If not specified, none will be added.
|
||||
Use the same string as is the name of the LMFit model.
|
||||
dap(str): The dap model to use for the curve. When provided, a DAP curve is
|
||||
attached automatically for device, history, or custom data sources. Use
|
||||
the same string as the LMFit model name.
|
||||
scan_id(str): Optional scan ID. When provided, the curve is treated as a **history** curve and
|
||||
the y‑data (and optional x‑data) are fetched from that historical scan. Such curves are
|
||||
never cleared by live‑scan resets.
|
||||
@@ -853,7 +809,7 @@ class Waveform(PlotBase):
|
||||
# CREATE THE CURVE
|
||||
curve = self._add_curve(config=config, x_data=x_data, y_data=y_data)
|
||||
|
||||
if dap is not None and source == "device":
|
||||
if dap is not None and curve.config.source in ("device", "history", "custom"):
|
||||
self.add_dap_curve(device_label=curve.name(), dap_name=dap, **kwargs)
|
||||
|
||||
return curve
|
||||
@@ -870,11 +826,12 @@ class Waveform(PlotBase):
|
||||
**kwargs,
|
||||
) -> Curve:
|
||||
"""
|
||||
Create a new DAP curve referencing the existing device curve `device_label`,
|
||||
with the data processing model `dap_name`.
|
||||
Create a new DAP curve referencing the existing curve `device_label`, with the
|
||||
data processing model `dap_name`. DAP curves can be attached to curves that
|
||||
originate from live devices, history, or fully custom data sources.
|
||||
|
||||
Args:
|
||||
device_label(str): The label of the device curve to add DAP to.
|
||||
device_label(str): The label of the source curve to add DAP to.
|
||||
dap_name(str): The name of the DAP model to use.
|
||||
color(str): The color of the curve.
|
||||
dap_oversample(int): The oversampling factor for the DAP curve.
|
||||
@@ -884,17 +841,22 @@ class Waveform(PlotBase):
|
||||
Curve: The new DAP curve.
|
||||
"""
|
||||
|
||||
# 1) Find the existing device curve by label
|
||||
# 1) Find the existing curve by label
|
||||
device_curve = self._find_curve_by_label(device_label)
|
||||
if not device_curve:
|
||||
raise ValueError(f"No existing curve found with label '{device_label}'.")
|
||||
if device_curve.config.source not in ("device", "history"):
|
||||
if device_curve.config.source not in ("device", "history", "custom"):
|
||||
raise ValueError(
|
||||
f"Curve '{device_label}' is not a device curve. Only device curves can have DAP."
|
||||
f"Curve '{device_label}' is not compatible with DAP. "
|
||||
f"Only device, history, or custom curves support fitting."
|
||||
)
|
||||
|
||||
dev_name = device_curve.config.signal.name
|
||||
dev_entry = device_curve.config.signal.entry
|
||||
dev_name = getattr(getattr(device_curve.config, "signal", None), "name", None)
|
||||
dev_entry = getattr(getattr(device_curve.config, "signal", None), "entry", None)
|
||||
if dev_name is None:
|
||||
dev_name = device_label
|
||||
if dev_entry is None:
|
||||
dev_entry = "custom"
|
||||
|
||||
# 2) Build a label for the new DAP curve
|
||||
dap_label = f"{device_label}-{dap_name}"
|
||||
@@ -1558,7 +1520,7 @@ class Waveform(PlotBase):
|
||||
|
||||
self.request_dap_update.emit()
|
||||
|
||||
def _check_async_signal_found(self, name: str, signal: str) -> bool:
|
||||
def _check_async_signal_found(self, name: str, signal: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if the async signal is found in the BEC device manager.
|
||||
|
||||
@@ -1567,13 +1529,16 @@ class Waveform(PlotBase):
|
||||
signal(str): The entry of the async signal.
|
||||
|
||||
Returns:
|
||||
bool: True if the async signal is found, False otherwise.
|
||||
tuple[bool, str]: A tuple where the first element is True if the async signal is found (False otherwise),
|
||||
and the second element is the signal name (either the original signal or the storage_name for AsyncMultiSignal).
|
||||
"""
|
||||
bec_async_signals = self.client.device_manager.get_bec_signals("AsyncSignal")
|
||||
bec_async_signals = self.client.device_manager.get_bec_signals(
|
||||
["AsyncSignal", "AsyncMultiSignal"]
|
||||
)
|
||||
for entry_name, _, entry_data in bec_async_signals:
|
||||
if entry_name == name and entry_data.get("obj_name") == signal:
|
||||
return True
|
||||
return False
|
||||
return True, entry_data.get("storage_name")
|
||||
return False, signal
|
||||
|
||||
def _setup_async_curve(self, curve: Curve):
|
||||
"""
|
||||
@@ -1584,7 +1549,7 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
name = curve.config.signal.name
|
||||
signal = curve.config.signal.entry
|
||||
async_signal_found = self._check_async_signal_found(name, signal)
|
||||
async_signal_found, signal = self._check_async_signal_found(name, signal)
|
||||
|
||||
try:
|
||||
curve.clear_data()
|
||||
@@ -1666,6 +1631,9 @@ class Waveform(PlotBase):
|
||||
continue
|
||||
# Ensure we have numpy array for data_plot_y
|
||||
data_plot_y = np.asarray(data_plot_y)
|
||||
if data_plot_y.ndim == 0:
|
||||
# Convert scalars/0d arrays to 1d so len() and stacking work
|
||||
data_plot_y = data_plot_y.reshape(1)
|
||||
# Add
|
||||
if instruction == "add":
|
||||
if len(max_shape) > 1:
|
||||
@@ -2373,7 +2341,7 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Waveform Demo")
|
||||
self.resize(800, 600)
|
||||
self.resize(1200, 600)
|
||||
self.main_widget = QWidget(self)
|
||||
self.layout = QHBoxLayout(self.main_widget)
|
||||
self.setCentralWidget(self.main_widget)
|
||||
@@ -2385,8 +2353,31 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
self.waveform_side.plot(y_name="bpm3a", y_entry="bpm3a")
|
||||
|
||||
self.custom_waveform = Waveform(popups=True)
|
||||
self._populate_custom_curve_demo()
|
||||
|
||||
self.layout.addWidget(self.waveform_side)
|
||||
self.layout.addWidget(self.waveform_popup)
|
||||
self.layout.addWidget(self.custom_waveform)
|
||||
|
||||
def _populate_custom_curve_demo(self):
|
||||
"""
|
||||
Showcase how to attach a DAP fit to a fully custom curve.
|
||||
|
||||
The example generates a noisy Gaussian trace, plots it as custom data, and
|
||||
immediately adds a Gaussian model fit. When the widget is plugged into a
|
||||
running BEC instance, the fit curve will be requested like any other device
|
||||
signal. This keeps the example minimal while demonstrating the new workflow.
|
||||
"""
|
||||
x = np.linspace(-4, 4, 600)
|
||||
rng = np.random.default_rng(42)
|
||||
noise = rng.normal(loc=0, scale=0.05, size=x.size)
|
||||
amplitude = 3.5
|
||||
center = 0.5
|
||||
sigma = 0.8
|
||||
y = amplitude * np.exp(-((x - center) ** 2) / (2 * sigma**2)) + noise
|
||||
|
||||
self.custom_waveform.plot(x=x, y=y, label="custom-gaussian", dap="GaussianModel")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -6,6 +6,7 @@ import time
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, QTimer, Signal
|
||||
@@ -270,22 +271,28 @@ class ScanProgressBar(BECWidget, QWidget):
|
||||
"""
|
||||
if not "queue" in msg_content:
|
||||
return
|
||||
primary_queue_info = msg_content["queue"].get("primary", {}).get("info", [])
|
||||
if "primary" not in msg_content["queue"]:
|
||||
return
|
||||
if (primary_queue := msg_content.get("queue").get("primary")) is None:
|
||||
return
|
||||
if not isinstance(primary_queue, messages.ScanQueueStatus):
|
||||
return
|
||||
primary_queue_info = primary_queue.info
|
||||
if len(primary_queue_info) == 0:
|
||||
return
|
||||
scan_info = primary_queue_info[0]
|
||||
if scan_info is None:
|
||||
return
|
||||
if scan_info.get("status").lower() == "running" and self.task is None:
|
||||
if scan_info.status.lower() == "running" and self.task is None:
|
||||
self.task = ProgressTask(parent=self)
|
||||
self.progress_started.emit()
|
||||
|
||||
active_request_block = scan_info.get("active_request_block", {})
|
||||
active_request_block = scan_info.active_request_block
|
||||
if active_request_block is None:
|
||||
return
|
||||
|
||||
self.scan_number = active_request_block.get("scan_number")
|
||||
report_instructions = active_request_block.get("report_instructions", [])
|
||||
self.scan_number = active_request_block.scan_number
|
||||
report_instructions = active_request_block.report_instructions
|
||||
if not report_instructions:
|
||||
return
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_lib import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Property, Qt, Signal, Slot
|
||||
@@ -147,7 +148,16 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
_metadata (dict): The metadata.
|
||||
"""
|
||||
# only show the primary queue for now
|
||||
queue_info = content.get("queue", {}).get("primary", {}).get("info", [])
|
||||
queues = content.get("queue", {})
|
||||
if not queues:
|
||||
self.reset_content()
|
||||
return
|
||||
primary_queue: messages.ScanQueueStatus | None = queues.get("primary")
|
||||
if not primary_queue:
|
||||
self.reset_content()
|
||||
return
|
||||
queue_info = primary_queue.info
|
||||
|
||||
self.table.setRowCount(len(queue_info))
|
||||
self.table.clearContents()
|
||||
|
||||
@@ -156,19 +166,19 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
return
|
||||
|
||||
for index, item in enumerate(queue_info):
|
||||
blocks = item.get("request_blocks", [])
|
||||
blocks = item.request_blocks
|
||||
scan_types = []
|
||||
scan_numbers = []
|
||||
scan_ids = []
|
||||
status = item.get("status", "")
|
||||
status = item.status
|
||||
for request_block in blocks:
|
||||
scan_type = request_block.get("content", {}).get("scan_type", "")
|
||||
scan_type = request_block.msg.scan_type
|
||||
if scan_type:
|
||||
scan_types.append(scan_type)
|
||||
scan_number = request_block.get("scan_number", "")
|
||||
scan_number = request_block.scan_number
|
||||
if scan_number:
|
||||
scan_numbers.append(str(scan_number))
|
||||
scan_id = request_block.get("scan_id", "")
|
||||
scan_id = request_block.scan_id
|
||||
if scan_id:
|
||||
scan_ids.append(scan_id)
|
||||
if scan_types:
|
||||
@@ -180,7 +190,7 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
self.set_row(index, scan_numbers, scan_types, status, scan_ids)
|
||||
busy = (
|
||||
False
|
||||
if all(item.get("status") in ("STOPPED", "COMPLETED", "IDLE") for item in queue_info)
|
||||
if all(item.status in ("STOPPED", "COMPLETED", "IDLE") for item in queue_info)
|
||||
else True
|
||||
)
|
||||
self.set_global_state("warning" if busy else "default")
|
||||
|
||||
@@ -47,7 +47,9 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
) -> None:
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
self._config_helper = ConfigHelper(self.client.connector, self.client._service_name)
|
||||
self._config_helper = ConfigHelper(
|
||||
self.client.connector, self.client._service_name, self.client.device_manager
|
||||
)
|
||||
self._q_threadpool = QThreadPool()
|
||||
self.ui = None
|
||||
self.init_ui()
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from qtpy.QtCore import QMargins, Qt, Signal
|
||||
from qtpy.QtGui import QIntValidator
|
||||
from qtpy.QtPdf import QPdfDocument
|
||||
from qtpy.QtPdfWidgets import QPdfView
|
||||
from qtpy.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
|
||||
|
||||
class PdfViewerWidget(BECWidget, QWidget):
|
||||
"""A widget to display PDF documents with toolbar controls."""
|
||||
|
||||
# Emitted when a PDF document is successfully loaded, providing the file path.
|
||||
document_ready = Signal(str)
|
||||
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
ICON_NAME = "picture_as_pdf"
|
||||
USER_ACCESS = [
|
||||
"load_pdf",
|
||||
"zoom_in",
|
||||
"zoom_out",
|
||||
"fit_to_width",
|
||||
"fit_to_page",
|
||||
"reset_zoom",
|
||||
"previous_page",
|
||||
"next_page",
|
||||
"toggle_continuous_scroll",
|
||||
"page_spacing",
|
||||
"page_spacing.setter",
|
||||
"side_margins",
|
||||
"side_margins.setter",
|
||||
"go_to_first_page",
|
||||
"go_to_last_page",
|
||||
"jump_to_page",
|
||||
"current_page",
|
||||
"current_file_path",
|
||||
"current_file_path.setter",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self, parent: Optional[QWidget] = None, config=None, client=None, gui_id=None, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id, **kwargs)
|
||||
|
||||
# Set up the layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create the PDF document and view first
|
||||
self._pdf_document = QPdfDocument(self)
|
||||
self.pdf_view = QPdfView()
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
|
||||
# Create toolbar after PDF components are initialized
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
self._setup_toolbar()
|
||||
|
||||
# Add widgets to layout
|
||||
layout.addWidget(self.toolbar)
|
||||
layout.addWidget(self.pdf_view)
|
||||
|
||||
# Current file path and spacing settings
|
||||
self._current_file_path = None
|
||||
self._page_spacing = 5 # Default spacing between pages in continuous mode
|
||||
self._side_margins = 10 # Default side margins (horizontal spacing)
|
||||
|
||||
def _setup_toolbar(self):
|
||||
"""Set up the toolbar with PDF control buttons."""
|
||||
# Create separate bundles for different control groups
|
||||
file_bundle = self.toolbar.new_bundle("file_controls")
|
||||
zoom_bundle = self.toolbar.new_bundle("zoom_controls")
|
||||
view_bundle = self.toolbar.new_bundle("view_controls")
|
||||
nav_bundle = self.toolbar.new_bundle("navigation_controls")
|
||||
|
||||
# File operations
|
||||
open_action = MaterialIconAction(
|
||||
icon_name="folder_open", tooltip="Open PDF File", parent=self
|
||||
)
|
||||
open_action.action.triggered.connect(self.open_file_dialog)
|
||||
self.toolbar.components.add("open_file", open_action)
|
||||
file_bundle.add_action("open_file")
|
||||
|
||||
# Zoom controls
|
||||
zoom_in_action = MaterialIconAction(icon_name="zoom_in", tooltip="Zoom In", parent=self)
|
||||
zoom_in_action.action.triggered.connect(self.zoom_in)
|
||||
self.toolbar.components.add("zoom_in", zoom_in_action)
|
||||
zoom_bundle.add_action("zoom_in")
|
||||
|
||||
zoom_out_action = MaterialIconAction(icon_name="zoom_out", tooltip="Zoom Out", parent=self)
|
||||
zoom_out_action.action.triggered.connect(self.zoom_out)
|
||||
self.toolbar.components.add("zoom_out", zoom_out_action)
|
||||
zoom_bundle.add_action("zoom_out")
|
||||
|
||||
fit_width_action = MaterialIconAction(
|
||||
icon_name="fit_screen", tooltip="Fit to Width", parent=self
|
||||
)
|
||||
fit_width_action.action.triggered.connect(self.fit_to_width)
|
||||
self.toolbar.components.add("fit_width", fit_width_action)
|
||||
zoom_bundle.add_action("fit_width")
|
||||
|
||||
fit_page_action = MaterialIconAction(
|
||||
icon_name="fullscreen", tooltip="Fit to Page", parent=self
|
||||
)
|
||||
fit_page_action.action.triggered.connect(self.fit_to_page)
|
||||
self.toolbar.components.add("fit_page", fit_page_action)
|
||||
zoom_bundle.add_action("fit_page")
|
||||
|
||||
reset_zoom_action = MaterialIconAction(
|
||||
icon_name="center_focus_strong", tooltip="Reset Zoom to 100%", parent=self
|
||||
)
|
||||
reset_zoom_action.action.triggered.connect(self.reset_zoom)
|
||||
self.toolbar.components.add("reset_zoom", reset_zoom_action)
|
||||
zoom_bundle.add_action("reset_zoom")
|
||||
|
||||
# View controls
|
||||
continuous_scroll_action = MaterialIconAction(
|
||||
icon_name="view_agenda", tooltip="Toggle Continuous Scroll", checkable=True, parent=self
|
||||
)
|
||||
continuous_scroll_action.action.toggled.connect(self.toggle_continuous_scroll)
|
||||
self.toolbar.components.add("continuous_scroll", continuous_scroll_action)
|
||||
view_bundle.add_action("continuous_scroll")
|
||||
|
||||
# Navigation controls
|
||||
prev_page_action = MaterialIconAction(
|
||||
icon_name="navigate_before", tooltip="Previous Page", parent=self
|
||||
)
|
||||
prev_page_action.action.triggered.connect(self.previous_page)
|
||||
self.toolbar.components.add("prev_page", prev_page_action)
|
||||
nav_bundle.add_action("prev_page")
|
||||
|
||||
next_page_action = MaterialIconAction(
|
||||
icon_name="navigate_next", tooltip="Next Page", parent=self
|
||||
)
|
||||
next_page_action.action.triggered.connect(self.next_page)
|
||||
self.toolbar.components.add("next_page", next_page_action)
|
||||
nav_bundle.add_action("next_page")
|
||||
|
||||
# Page jump widget (in navigation bundle)
|
||||
self._setup_page_jump_widget(nav_bundle)
|
||||
|
||||
# Show all bundles
|
||||
self.toolbar.show_bundles(
|
||||
["file_controls", "zoom_controls", "view_controls", "navigation_controls"]
|
||||
)
|
||||
|
||||
# Initialize navigation button tooltips for single page mode (default)
|
||||
self._update_navigation_buttons_for_mode(continuous=False)
|
||||
|
||||
# Initialize navigation button states
|
||||
self._update_navigation_button_states()
|
||||
|
||||
def _setup_page_jump_widget(self, nav_bundle):
|
||||
"""Set up the page jump widget (label + line edit)."""
|
||||
# Create a container widget for the page jump controls
|
||||
page_jump_container = QWidget()
|
||||
page_jump_layout = QHBoxLayout(page_jump_container)
|
||||
page_jump_layout.setContentsMargins(5, 0, 5, 0)
|
||||
page_jump_layout.setSpacing(3)
|
||||
|
||||
# Page input field
|
||||
self.page_input = QLineEdit()
|
||||
self.page_input.setValidator(QIntValidator(1, 100000)) # restrict to 1–100000
|
||||
self.page_input.setFixedWidth(50)
|
||||
self.page_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.page_input.setPlaceholderText("1")
|
||||
self.page_input.setToolTip("Enter page number and press Enter")
|
||||
self.page_input.returnPressed.connect(self._line_edit_jump_to_page)
|
||||
|
||||
# Total pages label
|
||||
self.total_pages_label = QLabel("/ 1")
|
||||
self.total_pages_label.setStyleSheet("color: #666; font-size: 12px;")
|
||||
|
||||
# Add widgets to layout
|
||||
page_jump_layout.addWidget(self.page_input)
|
||||
page_jump_layout.addWidget(self.total_pages_label)
|
||||
|
||||
# Create a WidgetAction for the page jump controls
|
||||
# No manual separator needed - bundles are automatically separated
|
||||
page_jump_action = WidgetAction(
|
||||
label="Page:", widget=page_jump_container, adjust_size=False, parent=self
|
||||
)
|
||||
self.toolbar.components.add("page_jump", page_jump_action)
|
||||
nav_bundle.add_action("page_jump")
|
||||
|
||||
def _line_edit_jump_to_page(self):
|
||||
"""Jump to the page entered in the line edit."""
|
||||
page_text = self.page_input.text().strip()
|
||||
if not page_text:
|
||||
return
|
||||
# We validated input to be integer, so safe to convert directly
|
||||
self.jump_to_page(int(page_text))
|
||||
|
||||
def _update_navigation_button_states(self):
|
||||
"""Update the enabled/disabled state of navigation buttons."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
# No document loaded - disable all navigation
|
||||
self._set_navigation_enabled(False, False)
|
||||
self._update_page_display(1, 1)
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
current_page = navigator.currentPage()
|
||||
total_pages = self._pdf_document.pageCount()
|
||||
|
||||
# Update button states
|
||||
prev_enabled = current_page > 0
|
||||
next_enabled = current_page < (total_pages - 1)
|
||||
self._set_navigation_enabled(prev_enabled, next_enabled)
|
||||
|
||||
# Update page display
|
||||
self._update_page_display(current_page + 1, total_pages)
|
||||
|
||||
def _set_navigation_enabled(self, prev_enabled: bool, next_enabled: bool):
|
||||
"""Set the enabled state of navigation buttons."""
|
||||
prev_action = self.toolbar.components.get_action("prev_page")
|
||||
if prev_action and hasattr(prev_action, "action") and prev_action.action:
|
||||
prev_action.action.setEnabled(prev_enabled)
|
||||
|
||||
next_action = self.toolbar.components.get_action("next_page")
|
||||
if next_action and hasattr(next_action, "action") and next_action.action:
|
||||
next_action.action.setEnabled(next_enabled)
|
||||
|
||||
def _update_page_display(self, current_page: int, total_pages: int):
|
||||
"""Update the page display in the toolbar."""
|
||||
if hasattr(self, "page_input"):
|
||||
self.page_input.setText(str(current_page))
|
||||
self.page_input.setPlaceholderText(str(current_page))
|
||||
|
||||
if hasattr(self, "total_pages_label"):
|
||||
self.total_pages_label.setText(f"/ {total_pages}")
|
||||
|
||||
@SafeProperty(str)
|
||||
def current_file_path(self):
|
||||
"""Get the current PDF file path."""
|
||||
return self._current_file_path
|
||||
|
||||
@current_file_path.setter
|
||||
def current_file_path(self, value: str):
|
||||
"""
|
||||
Set the current PDF file path and load the document.
|
||||
|
||||
Args:
|
||||
value (str): Path to the PDF file to load.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("current_file_path must be a string")
|
||||
self.load_pdf(value)
|
||||
|
||||
@SafeProperty(int)
|
||||
def page_spacing(self):
|
||||
"""Get the spacing between pages in continuous scroll mode."""
|
||||
return self._page_spacing
|
||||
|
||||
@property
|
||||
def current_page(self):
|
||||
"""Get the current page number (1-based index)."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return 0
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
return navigator.currentPage() + 1
|
||||
|
||||
@page_spacing.setter
|
||||
def page_spacing(self, value: int):
|
||||
"""
|
||||
Set the spacing between pages in continuous scroll mode.
|
||||
|
||||
Args:
|
||||
value (int): Spacing in pixels (non-negative integer).
|
||||
"""
|
||||
if not isinstance(value, int):
|
||||
raise ValueError("page_spacing must be an integer")
|
||||
if value < 0:
|
||||
raise ValueError("page_spacing must be non-negative")
|
||||
|
||||
self._page_spacing = value
|
||||
|
||||
# If currently in continuous scroll mode, update the spacing immediately
|
||||
if self.pdf_view.pageMode() == QPdfView.PageMode.MultiPage:
|
||||
self.pdf_view.setPageSpacing(self._page_spacing)
|
||||
|
||||
@SafeProperty(int)
|
||||
def side_margins(self):
|
||||
"""Get the horizontal margins (side spacing) around the PDF content."""
|
||||
return self._side_margins
|
||||
|
||||
@side_margins.setter
|
||||
def side_margins(self, value: int):
|
||||
"""Set the horizontal margins (side spacing) around the PDF content."""
|
||||
if not isinstance(value, int):
|
||||
raise ValueError("side_margins must be an integer")
|
||||
if value < 0:
|
||||
raise ValueError("side_margins must be non-negative")
|
||||
|
||||
self._side_margins = value
|
||||
|
||||
# Update the document margins immediately
|
||||
# setDocumentMargins takes a QMargins(left, top, right, bottom)
|
||||
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
|
||||
self.pdf_view.setDocumentMargins(margins)
|
||||
|
||||
def open_file_dialog(self):
|
||||
"""Open a file dialog to select a PDF file."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Open PDF File", "", "PDF Files (*.pdf);;All Files (*)"
|
||||
)
|
||||
if file_path:
|
||||
self.load_pdf(file_path)
|
||||
|
||||
@SafeSlot(str, popup_error=True)
|
||||
def load_pdf(self, file_path: str):
|
||||
"""
|
||||
Load a PDF file into the viewer.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the PDF file to load.
|
||||
"""
|
||||
# Validate file exists
|
||||
if not os.path.isfile(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
self._current_file_path = file_path
|
||||
|
||||
# Disconnect any existing signal connections
|
||||
try:
|
||||
self._pdf_document.statusChanged.disconnect(self._on_document_status_changed)
|
||||
except (TypeError, RuntimeError):
|
||||
pass
|
||||
|
||||
# Connect to statusChanged signal to handle when document is ready
|
||||
self._pdf_document.statusChanged.connect(self._on_document_status_changed)
|
||||
|
||||
# Load the document
|
||||
self._pdf_document.load(file_path)
|
||||
|
||||
# If already ready (synchronous loading), set document immediately
|
||||
if self._pdf_document.status() == QPdfDocument.Status.Ready:
|
||||
self._on_document_ready()
|
||||
|
||||
@SafeSlot(QPdfDocument.Status)
|
||||
def _on_document_status_changed(self, status: QPdfDocument.Status):
|
||||
"""Handle document status changes."""
|
||||
status = self._pdf_document.status()
|
||||
|
||||
if status == QPdfDocument.Status.Ready:
|
||||
self._on_document_ready()
|
||||
elif status == QPdfDocument.Status.Error:
|
||||
raise RuntimeError(f"Failed to load PDF document: {self._current_file_path}")
|
||||
|
||||
def _on_document_ready(self):
|
||||
"""Handle when document is ready to be displayed."""
|
||||
self.pdf_view.setDocument(self._pdf_document)
|
||||
|
||||
# Set initial margins
|
||||
margins = QMargins(self._side_margins, 0, self._side_margins, 0)
|
||||
self.pdf_view.setDocumentMargins(margins)
|
||||
|
||||
# Connect to page changes to update navigation button states
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
navigator.currentPageChanged.connect(self._on_page_changed)
|
||||
|
||||
# Make sure we start at the first page
|
||||
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
# Update initial navigation state
|
||||
self._update_navigation_button_states()
|
||||
self.document_ready.emit(self._current_file_path)
|
||||
|
||||
def _on_page_changed(self, _page):
|
||||
"""Handle page change events to update navigation states."""
|
||||
self._update_navigation_button_states()
|
||||
|
||||
# Toolbar action methods
|
||||
@SafeSlot()
|
||||
def zoom_in(self):
|
||||
"""Zoom in the PDF view."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
|
||||
current_factor = self.pdf_view.zoomFactor()
|
||||
new_factor = current_factor * 1.25
|
||||
self.pdf_view.setZoomFactor(new_factor)
|
||||
|
||||
@SafeSlot()
|
||||
def zoom_out(self):
|
||||
"""Zoom out the PDF view."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
|
||||
current_factor = self.pdf_view.zoomFactor()
|
||||
new_factor = max(current_factor / 1.25, 0.1)
|
||||
self.pdf_view.setZoomFactor(new_factor)
|
||||
|
||||
@SafeSlot()
|
||||
def fit_to_width(self):
|
||||
"""Fit PDF to width."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
|
||||
@SafeSlot()
|
||||
def fit_to_page(self):
|
||||
"""Fit PDF to page."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.FitInView)
|
||||
|
||||
@SafeSlot()
|
||||
def reset_zoom(self):
|
||||
"""Reset zoom to 100% (1.0 factor)."""
|
||||
self.pdf_view.setZoomMode(QPdfView.ZoomMode.Custom)
|
||||
self.pdf_view.setZoomFactor(1.0)
|
||||
|
||||
@SafeSlot()
|
||||
def previous_page(self):
|
||||
"""Go to previous page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
current_page = navigator.currentPage()
|
||||
if current_page == 0:
|
||||
self._update_navigation_button_states()
|
||||
return
|
||||
|
||||
try:
|
||||
target_page = current_page - 1
|
||||
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback: Use scroll to approximate position
|
||||
page_height = self.pdf_view.viewport().height()
|
||||
self.pdf_view.verticalScrollBar().setValue(
|
||||
self.pdf_view.verticalScrollBar().value() - page_height
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update navigation button states (in case signal doesn't fire)
|
||||
self._update_navigation_button_states()
|
||||
|
||||
@SafeSlot()
|
||||
def next_page(self):
|
||||
"""Go to next page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
current_page = navigator.currentPage()
|
||||
max_page = self._pdf_document.pageCount() - 1
|
||||
if current_page < max_page:
|
||||
try:
|
||||
target_page = current_page + 1
|
||||
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback: Use scroll to approximate position
|
||||
page_height = self.pdf_view.viewport().height()
|
||||
self.pdf_view.verticalScrollBar().setValue(
|
||||
self.pdf_view.verticalScrollBar().value() + page_height
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update navigation button states (in case signal doesn't fire)
|
||||
self._update_navigation_button_states()
|
||||
|
||||
@SafeSlot(bool)
|
||||
def toggle_continuous_scroll(self, checked: bool):
|
||||
"""
|
||||
Toggle between single page and continuous scroll mode.
|
||||
|
||||
Args:
|
||||
checked (bool): True to enable continuous scroll, False for single page mode.
|
||||
"""
|
||||
if checked:
|
||||
self.pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
|
||||
self.pdf_view.setPageSpacing(self._page_spacing)
|
||||
self._update_navigation_buttons_for_mode(continuous=True)
|
||||
tooltip = "Switch to Single Page Mode"
|
||||
else:
|
||||
self.pdf_view.setPageMode(QPdfView.PageMode.SinglePage)
|
||||
self._update_navigation_buttons_for_mode(continuous=False)
|
||||
tooltip = "Switch to Continuous Scroll Mode"
|
||||
|
||||
# Update navigation button states after mode change
|
||||
self._update_navigation_button_states()
|
||||
|
||||
# Update toggle button tooltip to reflect current state
|
||||
action = self.toolbar.components.get_action("continuous_scroll")
|
||||
if action and hasattr(action, "action") and action.action:
|
||||
action.action.setToolTip(tooltip)
|
||||
|
||||
def _update_navigation_buttons_for_mode(self, continuous: bool):
|
||||
"""Update navigation button tooltips based on current mode."""
|
||||
prev_action = self.toolbar.components.get_action("prev_page")
|
||||
next_action = self.toolbar.components.get_action("next_page")
|
||||
|
||||
if continuous:
|
||||
prev_actions_tooltip = "Previous Page (use scroll in continuous mode)"
|
||||
next_actions_tooltip = "Next Page (use scroll in continuous mode)"
|
||||
else:
|
||||
prev_actions_tooltip = "Previous Page"
|
||||
next_actions_tooltip = "Next Page"
|
||||
|
||||
if prev_action and hasattr(prev_action, "action") and prev_action.action:
|
||||
prev_action.action.setToolTip(prev_actions_tooltip)
|
||||
if next_action and hasattr(next_action, "action") and next_action.action:
|
||||
next_action.action.setToolTip(next_actions_tooltip)
|
||||
|
||||
@SafeSlot()
|
||||
def go_to_first_page(self):
|
||||
"""Go to the first page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
navigator.update(0, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
@SafeSlot()
|
||||
def go_to_last_page(self):
|
||||
"""Go to the last page."""
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
return
|
||||
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
last_page = self._pdf_document.pageCount() - 1
|
||||
navigator.update(last_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
@SafeSlot(int)
|
||||
def jump_to_page(self, page_number: int):
|
||||
"""Jump to a specific page number (1-based index)."""
|
||||
if not isinstance(page_number, int):
|
||||
raise ValueError("page_number must be an integer")
|
||||
|
||||
if not self._pdf_document or self._pdf_document.status() != QPdfDocument.Status.Ready:
|
||||
raise RuntimeError("No PDF document loaded")
|
||||
|
||||
max_page = self._pdf_document.pageCount()
|
||||
page_number = max(min(page_number, max_page), 1)
|
||||
|
||||
target_page = page_number - 1 # Convert to 0-based index
|
||||
navigator = self.pdf_view.pageNavigator()
|
||||
navigator.update(target_page, navigator.currentLocation(), navigator.currentZoom())
|
||||
|
||||
def cleanup(self):
|
||||
"""Handle widget close event to prevent segfaults."""
|
||||
if hasattr(self, "_pdf_document") and self._pdf_document:
|
||||
self._pdf_document.statusChanged.disconnect()
|
||||
empty_doc = QPdfDocument(self)
|
||||
self.pdf_view.setDocument(empty_doc)
|
||||
|
||||
if hasattr(self, "toolbar"):
|
||||
self.toolbar.cleanup()
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
# apply_theme("dark")
|
||||
viewer = PdfViewerWidget()
|
||||
# viewer.load_pdf("/Path/To/Your/TestDocument.pdf")
|
||||
viewer.next_page()
|
||||
# viewer.page_spacing = 0
|
||||
# viewer.side_margins = 0
|
||||
viewer.resize(1000, 700)
|
||||
viewer.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['pdf_viewer.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='PdfViewerWidget' name='pdf_viewer_widget'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class PdfViewerWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = PdfViewerWidget(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Utils"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(PdfViewerWidget.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "pdf_viewer_widget"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "PdfViewerWidget"
|
||||
|
||||
def toolTip(self):
|
||||
return "A widget to display PDF documents with toolbar controls."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,17 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer_widget_plugin import (
|
||||
PdfViewerWidgetPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(PdfViewerWidgetPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 498 KiB |
@@ -0,0 +1,119 @@
|
||||
(user.widgets.pdf_viewer_widget)=
|
||||
|
||||
# PDF Viewer Widget
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The PDF Viewer Widget is a versatile tool designed for displaying and navigating PDF documents within your BEC applications. Directly integrated with the `BEC` framework, it provides a full-featured PDF viewing experience with zoom controls, page navigation, and customizable display options.
|
||||
|
||||
## Key Features:
|
||||
- **Flexible Integration**: The widget can be integrated into [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`.
|
||||
- **Full PDF Support**: Display any PDF document with full rendering support through Qt's PDF rendering engine.
|
||||
- **Navigation Controls**: Built-in toolbar with page navigation, zoom controls, and document status indicators.
|
||||
- **Customizable Display**: Adjustable page spacing, margins, and zoom levels for optimal viewing experience.
|
||||
- **Document Management**: Load different PDF files dynamically during runtime with proper error handling.
|
||||
|
||||
## User Interface Components:
|
||||
- **Toolbar**: Contains all navigation and zoom controls
|
||||
- Previous/Next page buttons
|
||||
- Page number input field with total page count
|
||||
- First/Last page navigation buttons
|
||||
- Zoom in/out buttons
|
||||
- Fit to width/page buttons
|
||||
- Reset zoom button
|
||||
- **PDF View Area**: Main display area for the PDF content
|
||||
|
||||
````
|
||||
|
||||
````{tab} Examples - CLI
|
||||
|
||||
`PdfViewerWidget` can be embedded in [`BECDockArea`](user.widgets.bec_dock_area), or used as an individual component in your application through `BEC Designer`. The command-line API is the same for all cases.
|
||||
|
||||
## Example 1 - Basic PDF Loading
|
||||
|
||||
In this example, we demonstrate how to add a `PdfViewerWidget` to a [`BECDockArea`](user.widgets.bec_dock_area) and load a PDF document.
|
||||
|
||||
```python
|
||||
# Add a new dock with PDF viewer widget
|
||||
dock_area = gui.new()
|
||||
pdf_viewer = dock_area.new().new(gui.available_widgets.PdfViewerWidget)
|
||||
|
||||
# Load a PDF file
|
||||
pdf_viewer.load_pdf("/path/to/your/document.pdf")
|
||||
```
|
||||
|
||||
## Example 2 - Customizing Display Properties
|
||||
|
||||
This example shows how to customize the display properties of the PDF viewer for better presentation.
|
||||
|
||||
```python
|
||||
# Create PDF viewer
|
||||
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
|
||||
|
||||
# Load PDF document
|
||||
pdf_viewer.load_pdf("/path/to/report.pdf")
|
||||
pdf_viewer.toggle_continuous_scroll(True) # Enable continuous scroll mode
|
||||
|
||||
# Customize display properties
|
||||
pdf_viewer.page_spacing = 20 # Increase spacing between pages
|
||||
pdf_viewer.side_margins = 50 # Add horizontal margins
|
||||
|
||||
# Navigate to specific page
|
||||
pdf_viewer.jump_to_page(5) # Go to page 5
|
||||
```
|
||||
|
||||
## Example 3 - Navigation and Zoom Controls
|
||||
|
||||
The PDF viewer provides programmatic access to all navigation and zoom functionality.
|
||||
|
||||
```python
|
||||
# Create and load PDF
|
||||
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
|
||||
pdf_viewer.load_pdf("/path/to/manual.pdf")
|
||||
|
||||
# Navigation examples
|
||||
pdf_viewer.go_to_first_page() # Go to first page
|
||||
pdf_viewer.go_to_last_page() # Go to last page
|
||||
pdf_viewer.jump_to_page(10) # Jump to specific page
|
||||
|
||||
# Zoom controls
|
||||
pdf_viewer.zoom_in() # Increase zoom
|
||||
pdf_viewer.zoom_out() # Decrease zoom
|
||||
pdf_viewer.fit_to_width() # Fit document to window width
|
||||
pdf_viewer.fit_to_page() # Fit entire page to window
|
||||
pdf_viewer.reset_zoom() # Reset to 100% zoom
|
||||
|
||||
# Check current status
|
||||
current_page = pdf_viewer.current_page
|
||||
print(f"Currently viewing page {current_page}")
|
||||
```
|
||||
|
||||
## Example 4 - Dynamic Document Loading
|
||||
|
||||
This example demonstrates how to switch between different PDF documents dynamically.
|
||||
|
||||
```python
|
||||
# Create PDF viewer
|
||||
pdf_viewer = gui.new().new().new(gui.available_widgets.PdfViewerWidget)
|
||||
|
||||
# Load first document
|
||||
pdf_viewer.load_pdf("/path/to/document1.pdf")
|
||||
|
||||
# Or simply set the current file path
|
||||
pdf_viewer.current_file_path = "/path/to/document2.pdf"
|
||||
# This automatically loads the new document
|
||||
|
||||
# Check which file is currently loaded
|
||||
current_file = pdf_viewer.current_file_path
|
||||
print(f"Currently viewing: {current_file}")
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. autoclass:: bec_widgets.cli.client.PdfViewerWidget
|
||||
:members:
|
||||
:show-inheritance:
|
||||
```
|
||||
````
|
||||
@@ -270,6 +270,14 @@ Select DAP model from a list of DAP processes.
|
||||
|
||||
Show and filter logs from the BEC Redis server.
|
||||
```
|
||||
|
||||
```{grid-item-card} PDF Viewer Widget
|
||||
:link: user.widgets.pdf_viewer_widget
|
||||
:link-type: ref
|
||||
:img-top: /assets/widget_screenshots/pdf_viewer.png
|
||||
|
||||
Display and navigate PDF documents.
|
||||
```
|
||||
````
|
||||
|
||||
```{toctree}
|
||||
@@ -307,6 +315,7 @@ dap_combo_box/dap_combo_box.md
|
||||
games/games.md
|
||||
log_panel/log_panel.md
|
||||
signal_label/signal_label.md
|
||||
pdf_viewer/pdf_viewer_widget.md
|
||||
|
||||
|
||||
```
|
||||
+3
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.41.1"
|
||||
version = "2.45.13"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -13,11 +13,12 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"bec_ipython_client~=3.70", # needed for jupyter console
|
||||
"bec_ipython_client~=3.70", # needed for jupyter console
|
||||
"bec_lib~=3.70",
|
||||
"bec_qthemes~=1.0, >=1.1.2",
|
||||
"black~=25.0", # needed for bw-generate-cli
|
||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||
"ophyd_devices~=1.29, >=1.29.1",
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph==0.13.7",
|
||||
"PySide6==6.9.0",
|
||||
@@ -38,7 +39,6 @@ dependencies = [
|
||||
dev = [
|
||||
"coverage~=7.0",
|
||||
"fakeredis~=2.23, >=2.23.2",
|
||||
"isort~=5.13, >=5.13.2",
|
||||
"pytest-bec-e2e>=2.21.4, <=4.0",
|
||||
"pytest-qt~=4.4",
|
||||
"pytest-random-order~=1.1",
|
||||
|
||||
@@ -15,6 +15,10 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
|
||||
from bec_widgets.utils import error_popups
|
||||
|
||||
# Patch to set default RAISE_ERROR_DEFAULT to True for tests
|
||||
# This means that by default, error popups will raise exceptions during tests
|
||||
# error_popups.RAISE_ERROR_DEFAULT = True
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
|
||||
@@ -2,7 +2,6 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
@@ -129,19 +129,13 @@ def test_update_cycle(update_dialog, qtbot):
|
||||
({"readOnly": True, "description": "test"}, {"readOnly": True, "description": "test"}),
|
||||
(
|
||||
{"deviceConfig": {"param1": "'val1'"}},
|
||||
{
|
||||
"enabled": True,
|
||||
"deviceClass": "TestDevice",
|
||||
"deviceConfig": {"param1": "val1"},
|
||||
"readoutPriority": "monitored",
|
||||
"description": None,
|
||||
"readOnly": False,
|
||||
"softwareTrigger": False,
|
||||
"onFailure": "retry",
|
||||
"deviceTags": set(),
|
||||
"userParameter": {},
|
||||
"name": "test_device",
|
||||
},
|
||||
DeviceConfigModel(
|
||||
enabled=True,
|
||||
deviceClass="TestDevice",
|
||||
deviceConfig={"param1": "val1"},
|
||||
readoutPriority="monitored",
|
||||
name="test_device",
|
||||
).model_dump(),
|
||||
),
|
||||
({"deviceConfig": {}}, {}),
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,220 +5,657 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtWidgets import QFileDialog, QMessageBox
|
||||
from bec_lib.atlas_models import Device as DeviceModel
|
||||
from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import (
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.config_choice_dialog import (
|
||||
ConfigChoiceDialog,
|
||||
)
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.device_form_dialog import (
|
||||
DeviceFormDialog,
|
||||
DeviceManagerOphydValidationDialog,
|
||||
)
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
|
||||
DeviceStatusItem,
|
||||
UploadRedisDialog,
|
||||
ValidationSection,
|
||||
)
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import (
|
||||
DeviceManagerDisplayWidget,
|
||||
)
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import (
|
||||
DeviceManagerView,
|
||||
DeviceManagerWidget,
|
||||
)
|
||||
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
|
||||
from bec_widgets.widgets.control.device_manager.components import (
|
||||
DeviceTableView,
|
||||
DeviceTable,
|
||||
DMConfigView,
|
||||
DMOphydTest,
|
||||
DocstringView,
|
||||
OphydValidation,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import (
|
||||
ConfigStatus,
|
||||
ConnectionStatus,
|
||||
OphydValidation,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dm_view(qtbot):
|
||||
"""Fixture for DeviceManagerView."""
|
||||
widget = DeviceManagerView()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
def device_config() -> dict:
|
||||
"""Fixture for a sample device configuration."""
|
||||
return DeviceModel(
|
||||
name="TestDevice", enabled=True, deviceClass="TestClass", readoutPriority="baseline"
|
||||
).model_dump()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_choice_dialog(qtbot, dm_view):
|
||||
"""Fixture for ConfigChoiceDialog."""
|
||||
dialog = ConfigChoiceDialog(dm_view)
|
||||
qtbot.addWidget(dialog)
|
||||
qtbot.waitExposed(dialog)
|
||||
yield dialog
|
||||
class TestDeviceManagerViewDialogs:
|
||||
"""Test class for DeviceManagerView dialog interactions."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dm_view(self, qtbot):
|
||||
"""Fixture for DeviceManagerView."""
|
||||
widget = DeviceManagerDisplayWidget()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
def test_device_manager_view_config_choice_dialog(qtbot, dm_view, config_choice_dialog):
|
||||
"""Test the configuration choice dialog."""
|
||||
assert config_choice_dialog is not None
|
||||
assert config_choice_dialog.parent() == dm_view
|
||||
@pytest.fixture
|
||||
def config_choice_dialog(self, qtbot, mock_dm_view):
|
||||
"""Fixture for ConfigChoiceDialog."""
|
||||
try:
|
||||
dialog = ConfigChoiceDialog(mock_dm_view)
|
||||
qtbot.addWidget(dialog)
|
||||
qtbot.waitExposed(dialog)
|
||||
yield dialog
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
# Test dialog components
|
||||
with (
|
||||
mock.patch.object(config_choice_dialog, "accept") as mock_accept,
|
||||
mock.patch.object(config_choice_dialog, "reject") as mock_reject,
|
||||
def test_config_choice_dialog(self, mock_dm_view, config_choice_dialog, qtbot):
|
||||
"""Test the configuration choice dialog."""
|
||||
assert config_choice_dialog is not None
|
||||
assert config_choice_dialog.parent() == mock_dm_view
|
||||
|
||||
# Test dialog components
|
||||
with (mock.patch.object(config_choice_dialog, "done") as mock_done,):
|
||||
|
||||
# Replace
|
||||
qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton)
|
||||
mock_done.assert_called_once_with(config_choice_dialog.Result.REPLACE)
|
||||
mock_done.reset_mock()
|
||||
# Add
|
||||
qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton)
|
||||
mock_done.assert_called_once_with(config_choice_dialog.Result.ADD)
|
||||
mock_done.reset_mock()
|
||||
# Cancel
|
||||
qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton)
|
||||
mock_done.assert_called_once_with(config_choice_dialog.Result.CANCEL)
|
||||
|
||||
@pytest.fixture
|
||||
def device_manager_ophyd_test_dialog(self, qtbot):
|
||||
"""Fixture for DeviceManagerOphydValidationDialog."""
|
||||
dialog = DeviceManagerOphydValidationDialog()
|
||||
try:
|
||||
qtbot.addWidget(dialog)
|
||||
qtbot.waitExposed(dialog)
|
||||
yield dialog
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
def test_device_manager_ophyd_test_dialog(
|
||||
self, device_manager_ophyd_test_dialog: DeviceManagerOphydValidationDialog, qtbot
|
||||
):
|
||||
"""Test the DeviceManagerOphydValidationDialog."""
|
||||
dialog = device_manager_ophyd_test_dialog
|
||||
assert dialog.text_box.toPlainText() == ""
|
||||
|
||||
# Replace
|
||||
qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton)
|
||||
mock_accept.assert_called_once()
|
||||
mock_reject.assert_not_called()
|
||||
mock_accept.reset_mock()
|
||||
assert config_choice_dialog.result() == config_choice_dialog.REPLACE
|
||||
# Add
|
||||
qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton)
|
||||
mock_accept.assert_called_once()
|
||||
mock_reject.assert_not_called()
|
||||
mock_accept.reset_mock()
|
||||
assert config_choice_dialog.result() == config_choice_dialog.ADD
|
||||
# Cancel
|
||||
qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton)
|
||||
mock_accept.assert_not_called()
|
||||
mock_reject.assert_called_once()
|
||||
assert config_choice_dialog.result() == config_choice_dialog.CANCEL
|
||||
dialog._on_device_validated(
|
||||
{"name": "TestDevice", "enabled": True},
|
||||
config_status=0,
|
||||
connection_status=0,
|
||||
validation_msg="All good",
|
||||
)
|
||||
assert dialog.validation_result == (
|
||||
{"name": "TestDevice", "enabled": True},
|
||||
0,
|
||||
0,
|
||||
"All good",
|
||||
)
|
||||
assert dialog.text_box.toPlainText() != ""
|
||||
|
||||
@pytest.fixture
|
||||
def device_form_dialog(self, qtbot):
|
||||
"""Fixture for DeviceFormDialog."""
|
||||
dialog = DeviceFormDialog()
|
||||
try:
|
||||
qtbot.addWidget(dialog)
|
||||
qtbot.waitExposed(dialog)
|
||||
yield dialog
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
def test_device_form_dialog(self, device_form_dialog: DeviceFormDialog, qtbot):
|
||||
"""Test the DeviceFormDialog."""
|
||||
# Initial state
|
||||
dialog = device_form_dialog
|
||||
group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"]
|
||||
assert group_combo.count() == len(OPHYD_DEVICE_TEMPLATES)
|
||||
|
||||
# Test select a group from available templates
|
||||
variant_combo = dialog._control_widgets["variant_combo"]
|
||||
assert variant_combo.isEnabled() is False
|
||||
|
||||
with qtbot.waitSignal(group_combo.currentTextChanged):
|
||||
epics_signal_index = group_combo.findText("EpicsSignal")
|
||||
group_combo.setCurrentIndex(epics_signal_index) # Select "EpicsSignal" group
|
||||
|
||||
assert variant_combo.count() == len(OPHYD_DEVICE_TEMPLATES["EpicsSignal"])
|
||||
assert variant_combo.isEnabled() is True
|
||||
|
||||
# Check that numb of widgets in connection settings box is correct
|
||||
fields_in_config = len(
|
||||
OPHYD_DEVICE_TEMPLATES["EpicsSignal"].get(variant_combo.currentText(), {})
|
||||
) # At this point this should be read_pv & write_pv
|
||||
connection_settings_layout: QtWidgets.QGridLayout = (
|
||||
dialog._device_config_template.connection_settings_box.layout()
|
||||
)
|
||||
assert (
|
||||
connection_settings_layout.count() == fields_in_config * 2
|
||||
) # Each field has a label and a widget
|
||||
|
||||
def test_set_device_config(self, device_form_dialog: DeviceFormDialog, qtbot):
|
||||
"""Test setting device configuration in DeviceFormDialog."""
|
||||
dialog = device_form_dialog
|
||||
sample_config = {
|
||||
"name": "TestDevice",
|
||||
"enabled": True,
|
||||
"deviceClass": "ophyd.EpicsSignal",
|
||||
"readoutPriority": "baseline",
|
||||
"deviceConfig": {"read_pv": "X25DA-ES1-MOT:GET"},
|
||||
}
|
||||
DeviceModel.model_validate(sample_config)
|
||||
dialog.set_device_config(sample_config)
|
||||
|
||||
group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"]
|
||||
assert group_combo.currentText() == "EpicsSignal"
|
||||
variant_combo: QtWidgets.QComboBox = dialog._control_widgets["variant_combo"]
|
||||
assert variant_combo.currentText() == "EpicsSignal"
|
||||
config = dialog._device_config_template.get_config_fields()
|
||||
assert config["name"] == "TestDevice"
|
||||
assert config["deviceClass"] == "ophyd.EpicsSignal"
|
||||
assert config["deviceConfig"]["read_pv"] == "X25DA-ES1-MOT:GET"
|
||||
# Set the validation results, assume that test was running
|
||||
dialog.config_validation_result = (
|
||||
dialog._device_config_template.get_config_fields(),
|
||||
ConfigStatus.VALID.value,
|
||||
0,
|
||||
"",
|
||||
)
|
||||
with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box:
|
||||
with qtbot.waitSignal(dialog.accepted_data) as sig_blocker:
|
||||
qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton)
|
||||
config, _, _, _, _ = sig_blocker.args
|
||||
mock_warning_box.assert_not_called()
|
||||
|
||||
# Called with config_status invalid should show warning
|
||||
dialog.config_validation_result = (
|
||||
dialog._device_config_template.get_config_fields(),
|
||||
ConfigStatus.INVALID.value,
|
||||
0,
|
||||
"",
|
||||
)
|
||||
with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box:
|
||||
qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton)
|
||||
mock_warning_box.assert_called_once()
|
||||
|
||||
# Set to random config without name
|
||||
|
||||
random_config = {"deviceClass": "Unknown"}
|
||||
dialog.set_device_config(random_config)
|
||||
dialog.config_validation_result = (
|
||||
dialog._device_config_template.get_config_fields(),
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
)
|
||||
assert group_combo.currentText() == "CustomDevice"
|
||||
assert variant_combo.currentText() == "CustomDevice"
|
||||
with mock.patch.object(dialog, "_create_warning_message_box") as mock_warning_box:
|
||||
qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton)
|
||||
mock_warning_box.assert_called_once_with(
|
||||
"Invalid Device Name",
|
||||
f"Device is invalid, can not be empty with spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r} ",
|
||||
)
|
||||
|
||||
def test_device_status_item(self, device_config: dict, qtbot):
|
||||
"""Test the DeviceStatusItem widget."""
|
||||
item = DeviceStatusItem(device_config=device_config, config_status=0, connection_status=0)
|
||||
qtbot.addWidget(item)
|
||||
qtbot.waitExposed(item)
|
||||
assert item.device_config == device_config
|
||||
assert item.device_name == device_config.get("name", "")
|
||||
assert item.config_status == 0
|
||||
assert item.connection_status == 0
|
||||
assert "config_status" in item.icons
|
||||
assert "connection_status" in item.icons
|
||||
|
||||
# Update status
|
||||
item.update_status(config_status=1, connection_status=2)
|
||||
assert item.config_status == 1
|
||||
assert item.connection_status == 2
|
||||
|
||||
def test_validation_section(self, device_config: dict, qtbot):
|
||||
"""Test the validation section."""
|
||||
device_config_2 = device_config.copy()
|
||||
device_config_2["name"] = "device_2"
|
||||
|
||||
# Create section
|
||||
section = ValidationSection(title="Validation Results")
|
||||
qtbot.addWidget(section)
|
||||
qtbot.waitExposed(section)
|
||||
assert section.title() == "Validation Results"
|
||||
initial_widget_in_container = section.table.rowCount()
|
||||
|
||||
# Add widgets
|
||||
section.add_device(device_config=device_config, config_status=0, connection_status=0)
|
||||
assert initial_widget_in_container + 1 == section.table.rowCount()
|
||||
# Should be the first index, so rowCount - 1
|
||||
assert section._find_row_by_name(device_config["name"]) == section.table.rowCount() - 1
|
||||
|
||||
# Add another device
|
||||
section.add_device(device_config=device_config_2, config_status=1, connection_status=1)
|
||||
assert initial_widget_in_container + 2 == section.table.rowCount()
|
||||
# Should be the first index, so rowCount - 1
|
||||
assert section._find_row_by_name(device_config_2["name"]) == section.table.rowCount() - 1
|
||||
|
||||
# Clear devices
|
||||
section.clear_devices()
|
||||
assert section.table.rowCount() == 0
|
||||
|
||||
# Update test summary label
|
||||
section.update_summary("2 devices validated, 1 failed.")
|
||||
assert section.summary_label.text() == "2 devices validated, 1 failed."
|
||||
|
||||
@pytest.fixture
|
||||
def device_configs_valid(self, device_config: dict):
|
||||
"""Fixture for multiple device configurations."""
|
||||
return_dict = {}
|
||||
for i in range(4):
|
||||
name = f"Device_{i}"
|
||||
dev_config_copy = device_config.copy()
|
||||
dev_config_copy["name"] = name
|
||||
return_dict[name] = (dev_config_copy, ConfigStatus.VALID.value, i)
|
||||
return return_dict
|
||||
|
||||
@pytest.fixture
|
||||
def device_configs_invalid(self, device_config: dict):
|
||||
return_dict = {}
|
||||
for i in range(4):
|
||||
name = f"Device_{i}"
|
||||
dev_config_copy = device_config.copy()
|
||||
dev_config_copy["name"] = name
|
||||
return_dict[name] = (dev_config_copy, ConfigStatus.INVALID.value, i)
|
||||
return return_dict
|
||||
|
||||
@pytest.fixture
|
||||
def device_configs_unknown(self, device_config: dict):
|
||||
return_dict = {}
|
||||
for i in range(4):
|
||||
name = f"Device_{i}"
|
||||
dev_config_copy = device_config.copy()
|
||||
dev_config_copy["name"] = name
|
||||
return_dict[name] = (dev_config_copy, ConfigStatus.UNKNOWN.value, i)
|
||||
return return_dict
|
||||
|
||||
@pytest.fixture
|
||||
def upload_redis_dialog(self, qtbot):
|
||||
"""Fixture for UploadRedisDialog."""
|
||||
dialog = UploadRedisDialog(
|
||||
parent=None, ophyd_test_widget=mock.MagicMock(spec=OphydValidation), device_configs={}
|
||||
)
|
||||
try:
|
||||
qtbot.addWidget(dialog)
|
||||
qtbot.waitExposed(dialog)
|
||||
yield dialog
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
def test_upload_redis_valid_config(
|
||||
self, upload_redis_dialog: UploadRedisDialog, device_configs_valid, qtbot
|
||||
):
|
||||
"""
|
||||
Test the UploadRedisDialog with a valid device configuration.
|
||||
"""
|
||||
dialog = upload_redis_dialog
|
||||
configs = device_configs_valid
|
||||
dialog.set_device_config(configs)
|
||||
|
||||
n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value])
|
||||
n_untested = len(
|
||||
[True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value]
|
||||
)
|
||||
n_has_cannot_connect = len(
|
||||
[
|
||||
True
|
||||
for _, cs, conn in configs.values()
|
||||
if conn == ConnectionStatus.CANNOT_CONNECT.value
|
||||
]
|
||||
)
|
||||
|
||||
# Check the initial states
|
||||
assert dialog.has_invalid_configs == n_invalid
|
||||
assert dialog.has_untested_connections == n_untested
|
||||
assert dialog.has_cannot_connect == n_has_cannot_connect
|
||||
|
||||
num_devices = len(configs)
|
||||
expected_text = ""
|
||||
if n_invalid > 0:
|
||||
expected_text = f"{n_invalid} of {num_devices} device configurations are invalid."
|
||||
else:
|
||||
expected_text = f"All {num_devices} device configurations are valid."
|
||||
if n_untested > 0:
|
||||
expected_text += f"{n_untested} device connections are not tested."
|
||||
if n_has_cannot_connect > 0:
|
||||
expected_text += f"{n_has_cannot_connect} device connections cannot be established."
|
||||
|
||||
assert dialog.config_section.summary_label.text() == expected_text
|
||||
|
||||
def test_upload_redis_unknown_config(
|
||||
self, upload_redis_dialog: UploadRedisDialog, device_configs_unknown, qtbot
|
||||
):
|
||||
"""
|
||||
Test the UploadRedisDialog with a valid device configuration.
|
||||
"""
|
||||
dialog = upload_redis_dialog
|
||||
configs = device_configs_unknown
|
||||
dialog.set_device_config(configs)
|
||||
|
||||
n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value])
|
||||
n_untested = len(
|
||||
[True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value]
|
||||
)
|
||||
n_has_cannot_connect = len(
|
||||
[
|
||||
True
|
||||
for _, cs, conn in configs.values()
|
||||
if conn == ConnectionStatus.CANNOT_CONNECT.value
|
||||
]
|
||||
)
|
||||
|
||||
# Check the initial states
|
||||
assert dialog.has_invalid_configs == n_invalid
|
||||
assert dialog.has_untested_connections == n_untested
|
||||
assert dialog.has_cannot_connect == n_has_cannot_connect
|
||||
|
||||
num_devices = len(configs)
|
||||
expected_text = ""
|
||||
if n_invalid > 0:
|
||||
expected_text = f"{n_invalid} of {num_devices} device configurations are invalid."
|
||||
else:
|
||||
expected_text = f"All {num_devices} device configurations are valid."
|
||||
if n_untested > 0:
|
||||
expected_text += f"{n_untested} device connections are not tested."
|
||||
if n_has_cannot_connect > 0:
|
||||
expected_text += f"{n_has_cannot_connect} device connections cannot be established."
|
||||
|
||||
assert dialog.config_section.summary_label.text() == expected_text
|
||||
|
||||
def test_upload_redis_invalid_config(
|
||||
self, upload_redis_dialog: UploadRedisDialog, device_configs_invalid, qtbot
|
||||
):
|
||||
"""
|
||||
Test the UploadRedisDialog with a valid device configuration.
|
||||
"""
|
||||
dialog = upload_redis_dialog
|
||||
configs = device_configs_invalid
|
||||
dialog.set_device_config(configs)
|
||||
|
||||
n_invalid = len([True for _, cs, _ in configs.values() if cs == ConfigStatus.INVALID.value])
|
||||
n_untested = len(
|
||||
[True for _, cs, conn in configs.values() if conn == ConnectionStatus.UNKNOWN.value]
|
||||
)
|
||||
n_has_cannot_connect = len(
|
||||
[
|
||||
True
|
||||
for _, cs, conn in configs.values()
|
||||
if conn == ConnectionStatus.CANNOT_CONNECT.value
|
||||
]
|
||||
)
|
||||
|
||||
# Check the initial states
|
||||
assert dialog.has_invalid_configs == n_invalid
|
||||
assert dialog.has_untested_connections == n_untested
|
||||
assert dialog.has_cannot_connect == n_has_cannot_connect
|
||||
|
||||
num_devices = len(configs)
|
||||
expected_text = ""
|
||||
if n_invalid > 0:
|
||||
expected_text = f"{n_invalid} of {num_devices} device configurations are invalid."
|
||||
else:
|
||||
expected_text = f"All {num_devices} device configurations are valid."
|
||||
if n_untested > 0:
|
||||
expected_text += f"{n_untested} device connections are not tested."
|
||||
if n_has_cannot_connect > 0:
|
||||
expected_text += f"{n_has_cannot_connect} device connections cannot be established."
|
||||
|
||||
assert dialog.config_section.summary_label.text() == expected_text
|
||||
|
||||
def test_upload_redis_validate_connections(self, device_configs_invalid, qtbot):
|
||||
"""Test the validate connections method in UploadRedisDialog."""
|
||||
configs = device_configs_invalid
|
||||
ophyd_test_mock = mock.MagicMock(spec=OphydValidation)
|
||||
try:
|
||||
dialog = UploadRedisDialog(
|
||||
parent=None, ophyd_test_widget=ophyd_test_mock, device_configs=configs
|
||||
)
|
||||
qtbot.addWidget(dialog)
|
||||
qtbot.waitExposed(dialog)
|
||||
|
||||
with mock.patch.object(
|
||||
dialog.ophyd_test_widget, "change_device_configs"
|
||||
) as mock_change:
|
||||
dialog._validate_connections()
|
||||
mock_change.assert_called_once_with(
|
||||
[cfg for k, (cfg, _, _) in configs.items() if k in ["Device_0", "Device_3"]],
|
||||
added=True,
|
||||
connect=True,
|
||||
)
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
class TestDeviceManagerViewInitialization:
|
||||
"""Test class for DeviceManagerView initialization and basic components."""
|
||||
class TestDeviceManagerView:
|
||||
"""Test class for DeviceManagerView functionality."""
|
||||
|
||||
def test_dock_manager_initialization(self, dm_view):
|
||||
"""Test that the QtAds DockManager is properly initialized."""
|
||||
assert dm_view.dock_manager is not None
|
||||
assert dm_view.dock_manager.centralWidget() is not None
|
||||
@pytest.fixture
|
||||
def dm_view(self, qtbot):
|
||||
"""Fixture for DeviceManagerView."""
|
||||
widget = DeviceManagerView()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
def test_central_widget_is_device_table_view(self, dm_view):
|
||||
"""Test that the central widget is DeviceTableView."""
|
||||
central_widget = dm_view.dock_manager.centralWidget().widget()
|
||||
assert isinstance(central_widget, DeviceTableView)
|
||||
assert central_widget is dm_view.device_table_view
|
||||
def test_dm_view_initialization(self, dm_view, qtbot):
|
||||
"""Test DeviceManagerView initialization."""
|
||||
assert isinstance(dm_view.device_manager_widget, DeviceManagerWidget)
|
||||
# If on_enter is called, overlay should be shown initially
|
||||
dm_widget = dm_view.device_manager_widget
|
||||
dm_view.on_enter()
|
||||
assert dm_widget.stacked_layout.currentWidget() == dm_widget._overlay_widget
|
||||
|
||||
def test_dock_widgets_exist(self, dm_view):
|
||||
with mock.patch.object(dm_widget.device_manager_display, "_load_file_action") as mock_load:
|
||||
# Simulate clicking "Load Config From File" button
|
||||
with qtbot.waitSignal(dm_widget.button_load_config_from_file.clicked):
|
||||
qtbot.mouseClick(dm_widget.button_load_config_from_file, QtCore.Qt.LeftButton)
|
||||
assert dm_widget._initialized is True
|
||||
assert dm_widget.stacked_layout.currentWidget() == dm_widget.device_manager_display
|
||||
|
||||
# Reset for test loading current config
|
||||
dm_widget._initialized = False
|
||||
dm_widget.stacked_layout.setCurrentWidget(dm_widget._overlay_widget)
|
||||
dm_widget.client.device_manager = mock.MagicMock()
|
||||
|
||||
with mock.patch.object(
|
||||
dm_widget.client.device_manager, "_get_redis_device_config"
|
||||
) as mock_get:
|
||||
mock_get.return_value = []
|
||||
# Simulate clicking "Load Current Config" button
|
||||
with mock.patch.object(
|
||||
dm_widget.device_manager_display.device_table_view, "set_device_config"
|
||||
) as mock_set:
|
||||
with qtbot.waitSignal(dm_widget.button_load_current_config.clicked):
|
||||
qtbot.mouseClick(dm_widget.button_load_current_config, QtCore.Qt.LeftButton)
|
||||
assert dm_widget._initialized is True
|
||||
assert (
|
||||
dm_widget.stacked_layout.currentWidget() == dm_widget.device_manager_display
|
||||
)
|
||||
mock_set.assert_called_once_with([])
|
||||
|
||||
@pytest.fixture
|
||||
def device_manager_display_widget(self, qtbot):
|
||||
"""Fixture for DeviceManagerDisplayWidget within DeviceManagerView."""
|
||||
widget = DeviceManagerDisplayWidget()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
@pytest.fixture
|
||||
def device_configs(self, device_config: dict):
|
||||
"""Fixture for multiple device configurations."""
|
||||
cfg_iter = []
|
||||
for i in range(4):
|
||||
name = f"Device_{i}"
|
||||
dev_config_copy = device_config.copy()
|
||||
dev_config_copy["name"] = name
|
||||
cfg_iter.append(dev_config_copy)
|
||||
return cfg_iter
|
||||
|
||||
def test_device_manager_view_add_remove_device(
|
||||
self, device_manager_display_widget: DeviceManagerDisplayWidget, device_config
|
||||
):
|
||||
"""Test adding a device via the DeviceManagerView."""
|
||||
dm_view = device_manager_display_widget
|
||||
dm_view._add_to_table_from_dialog(
|
||||
device_config, config_status=0, connection_status=0, msg=""
|
||||
)
|
||||
table_config_list = dm_view.device_table_view.get_device_config()
|
||||
assert table_config_list == [device_config]
|
||||
|
||||
# Remove the device
|
||||
dm_view.device_table_view.table.selectRow(0)
|
||||
dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit()
|
||||
table_config_list = dm_view.device_table_view.get_device_config()
|
||||
assert table_config_list == []
|
||||
|
||||
def test_dock_widgets_exist(self, device_manager_display_widget: DeviceManagerDisplayWidget):
|
||||
"""Test that all required dock widgets are created."""
|
||||
dm_view = device_manager_display_widget
|
||||
dock_widgets = dm_view.dock_manager.dockWidgets()
|
||||
|
||||
# Check that we have the expected number of dock widgets
|
||||
assert len(dock_widgets) >= 4
|
||||
assert len(dock_widgets) == 4
|
||||
|
||||
# Check for specific widget types
|
||||
widget_types = [dock.widget().__class__ for dock in dock_widgets]
|
||||
|
||||
# OphydValidation is used in a layout with a QWidget
|
||||
assert DMConfigView in widget_types
|
||||
assert DMOphydTest in widget_types
|
||||
assert DocstringView in widget_types
|
||||
assert DeviceTable in widget_types
|
||||
|
||||
def test_toolbar_initialization(self, dm_view):
|
||||
def test_toolbar_initialization(
|
||||
self, device_manager_display_widget: DeviceManagerDisplayWidget
|
||||
):
|
||||
"""Test that the toolbar is properly initialized with expected bundles."""
|
||||
dm_view = device_manager_display_widget
|
||||
assert dm_view.toolbar is not None
|
||||
assert "IO" in dm_view.toolbar.bundles
|
||||
assert "Table" in dm_view.toolbar.bundles
|
||||
|
||||
def test_toolbar_components_exist(self, dm_view):
|
||||
"""Test that all expected toolbar components exist."""
|
||||
expected_components = [
|
||||
"load",
|
||||
"save_to_disk",
|
||||
"load_redis",
|
||||
"update_config_redis",
|
||||
"reset_composed",
|
||||
"add_device",
|
||||
"remove_device",
|
||||
"rerun_validation",
|
||||
]
|
||||
|
||||
for component in expected_components:
|
||||
assert dm_view.toolbar.components.exists(component)
|
||||
|
||||
def test_signal_connections(self, dm_view):
|
||||
"""Test that signals are properly connected between components."""
|
||||
# Test that device_table_view signals are connected
|
||||
assert dm_view.device_table_view.selected_devices is not None
|
||||
assert dm_view.device_table_view.device_configs_changed is not None
|
||||
|
||||
# Test that ophyd_test_view signals are connected
|
||||
assert dm_view.ophyd_test_view.device_validated is not None
|
||||
|
||||
|
||||
class TestDeviceManagerViewIOBundle:
|
||||
"""Test class for DeviceManagerView IO bundle actions."""
|
||||
|
||||
def test_io_bundle_exists(self, dm_view):
|
||||
def test_io_bundle_exists(self, device_manager_display_widget: DeviceManagerDisplayWidget):
|
||||
"""Test that IO bundle exists and contains expected actions."""
|
||||
dm_view = device_manager_display_widget
|
||||
assert "IO" in dm_view.toolbar.bundles
|
||||
io_actions = ["load", "save_to_disk", "load_redis", "update_config_redis"]
|
||||
io_actions = ["load", "save_to_disk", "flush_redis", "load_redis", "update_config_redis"]
|
||||
for action in io_actions:
|
||||
assert dm_view.toolbar.components.exists(action)
|
||||
|
||||
def test_load_file_action_triggered(self, tmp_path, dm_view):
|
||||
def test_load_file_action_triggered(
|
||||
self, tmp_path, device_manager_display_widget: DeviceManagerDisplayWidget
|
||||
):
|
||||
"""Test load file action trigger mechanism."""
|
||||
|
||||
dm_view = device_manager_display_widget
|
||||
with (
|
||||
mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path),
|
||||
mock.patch(
|
||||
"bec_widgets.applications.views.device_manager_view.device_manager_view.yaml_load"
|
||||
) as mock_yaml_load,
|
||||
mock.patch.object(dm_view, "_open_config_choice_dialog") as mock_open_dialog,
|
||||
mock.patch.object(dm_view, "_get_config_base_path", return_value=tmp_path),
|
||||
mock.patch.object(
|
||||
dm_view, "_get_file_path", return_value=str(tmp_path)
|
||||
) as mock_get_file,
|
||||
mock.patch.object(dm_view, "_load_config_from_file") as mock_load_config,
|
||||
):
|
||||
mock_yaml_data = {"device1": {"param1": "value1"}}
|
||||
mock_yaml_load.return_value = mock_yaml_data
|
||||
|
||||
# Setup dialog mock
|
||||
dm_view.toolbar.components._components["load"].action.action.triggered.emit()
|
||||
mock_yaml_load.assert_called_once_with(tmp_path)
|
||||
mock_open_dialog.assert_called_once_with([{"name": "device1", "param1": "value1"}])
|
||||
mock_get_file.assert_called_once_with(str(tmp_path), "open_file")
|
||||
mock_load_config.assert_called_once_with(str(tmp_path))
|
||||
|
||||
def test_save_config_to_file(self, tmp_path, dm_view):
|
||||
"""Test saving config to file."""
|
||||
yaml_path = tmp_path / "test_save.yaml"
|
||||
mock_config = [{"name": "device1", "param1": "value1"}]
|
||||
with (
|
||||
mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path),
|
||||
mock.patch.object(dm_view, "_get_recovery_config_path", return_value=tmp_path),
|
||||
mock.patch.object(dm_view, "_get_file_path", return_value=yaml_path),
|
||||
mock.patch.object(
|
||||
dm_view.device_table_view, "get_device_config", return_value=mock_config
|
||||
),
|
||||
):
|
||||
dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit()
|
||||
assert yaml_path.exists()
|
||||
|
||||
|
||||
class TestDeviceManagerViewTableBundle:
|
||||
"""Test class for DeviceManagerView Table bundle actions."""
|
||||
|
||||
def test_table_bundle_exists(self, dm_view):
|
||||
def test_table_bundle_exists(self, device_manager_display_widget: DeviceManagerDisplayWidget):
|
||||
"""Test that Table bundle exists and contains expected actions."""
|
||||
dm_view = device_manager_display_widget
|
||||
assert "Table" in dm_view.toolbar.bundles
|
||||
table_actions = ["reset_composed", "add_device", "remove_device", "rerun_validation"]
|
||||
for action in table_actions:
|
||||
assert dm_view.toolbar.components.exists(action)
|
||||
|
||||
@mock.patch(
|
||||
"bec_widgets.applications.views.device_manager_view.device_manager_view._yes_no_question"
|
||||
"bec_widgets.applications.views.device_manager_view.device_manager_display_widget._yes_no_question"
|
||||
)
|
||||
def test_reset_composed_view(self, mock_question, dm_view):
|
||||
def test_reset_composed_view(
|
||||
self, mock_question, device_manager_display_widget: DeviceManagerDisplayWidget
|
||||
):
|
||||
"""Test reset composed view when user confirms."""
|
||||
dm_view = device_manager_display_widget
|
||||
with mock.patch.object(dm_view.device_table_view, "clear_device_configs") as mock_clear:
|
||||
mock_question.return_value = QMessageBox.StandardButton.Yes
|
||||
mock_question.return_value = QtWidgets.QMessageBox.StandardButton.Yes
|
||||
dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit()
|
||||
mock_clear.assert_called_once()
|
||||
mock_clear.reset_mock()
|
||||
mock_question.return_value = QMessageBox.StandardButton.No
|
||||
mock_question.return_value = QtWidgets.QMessageBox.StandardButton.No
|
||||
dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit()
|
||||
mock_clear.assert_not_called()
|
||||
|
||||
def test_add_device_action_connected(self, dm_view):
|
||||
def test_add_device_action_connected(
|
||||
self, device_manager_display_widget: DeviceManagerDisplayWidget
|
||||
):
|
||||
"""Test add device action opens dialog correctly."""
|
||||
dm_view = device_manager_display_widget
|
||||
with mock.patch.object(dm_view, "_add_device_action") as mock_add:
|
||||
dm_view.toolbar.components._components["add_device"].action.action.triggered.emit()
|
||||
mock_add.assert_called_once()
|
||||
|
||||
def test_remove_device_action(self, dm_view):
|
||||
"""Test remove device action."""
|
||||
with mock.patch.object(dm_view.device_table_view, "remove_selected_rows") as mock_remove:
|
||||
dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit()
|
||||
mock_remove.assert_called_once()
|
||||
def test_run_validate_connection_action_connected(
|
||||
self, device_manager_display_widget: DeviceManagerDisplayWidget, device_configs: dict
|
||||
):
|
||||
"""Test run validate connection action is connected."""
|
||||
dm_view = device_manager_display_widget
|
||||
|
||||
def test_rerun_device_validation(self, dm_view):
|
||||
"""Test rerun device validation action."""
|
||||
cfgs = [{"name": "device1", "param1": "value1"}]
|
||||
with (
|
||||
mock.patch.object(dm_view.ophyd_test_view, "change_device_configs") as mock_change,
|
||||
mock.patch.object(
|
||||
dm_view.device_table_view.table, "selected_configs", return_value=cfgs
|
||||
),
|
||||
):
|
||||
with mock.patch.object(
|
||||
dm_view.ophyd_test_view, "change_device_configs"
|
||||
) as mock_change_configs:
|
||||
# First, add device configs to the table
|
||||
dm_view.device_table_view.add_device_configs(device_configs)
|
||||
assert mock_change_configs.call_args[0][1] is True # Configs were added
|
||||
mock_change_configs.reset_mock()
|
||||
|
||||
# Trigger the validate connection action without selection, should validate all
|
||||
dm_view.toolbar.components._components[
|
||||
"rerun_validation"
|
||||
].action.action.triggered.emit()
|
||||
mock_change.assert_called_once_with(cfgs, True, True)
|
||||
assert len(mock_change_configs.call_args[0][0]) == len(device_configs)
|
||||
assert mock_change_configs.call_args[0][1:] == (True, True) # Configs were not added
|
||||
mock_change_configs.reset_mock()
|
||||
|
||||
# Select a single row and trigger again, should only validate that one
|
||||
dm_view.device_table_view.table.selectRow(0)
|
||||
dm_view.toolbar.components._components[
|
||||
"rerun_validation"
|
||||
].action.action.triggered.emit()
|
||||
assert len(mock_change_configs.call_args[0][0]) == 1
|
||||
|
||||
@@ -4,8 +4,16 @@ import numpy as np
|
||||
import pytest
|
||||
from bec_lib import messages
|
||||
from bec_lib.scan_history import ScanHistory
|
||||
from qtpy.QtCore import QPointF
|
||||
from qtpy.QtGui import QTransform
|
||||
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap, HeatmapConfig, HeatmapDeviceSignal
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import (
|
||||
Heatmap,
|
||||
HeatmapConfig,
|
||||
HeatmapDeviceSignal,
|
||||
_InterpolationRequest,
|
||||
_StepInterpolationWorker,
|
||||
)
|
||||
|
||||
# pytest: disable=unused-import
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
@@ -125,12 +133,16 @@ def test_heatmap_get_image_data_unsupported_scan(heatmap_widget):
|
||||
|
||||
|
||||
def test_heatmap_get_grid_scan_image(heatmap_widget):
|
||||
x_levels = np.linspace(-5, 5, 10).tolist()
|
||||
y_levels = np.linspace(-5, 5, 10).tolist()
|
||||
scan_msg = messages.ScanStatusMessage(
|
||||
scan_id="123",
|
||||
status="open",
|
||||
scan_name="grid_scan",
|
||||
metadata={},
|
||||
info={"positions": np.random.rand(100, 2).tolist()},
|
||||
info={
|
||||
"positions": _grid_positions(slow_levels=x_levels, fast_levels=y_levels, snaked=True)
|
||||
},
|
||||
request_inputs={"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10], "kwargs": {}},
|
||||
)
|
||||
heatmap_widget._image_config = HeatmapConfig(
|
||||
@@ -145,6 +157,111 @@ def test_heatmap_get_grid_scan_image(heatmap_widget):
|
||||
assert sorted(np.asarray(img, dtype=int).flatten().tolist()) == list(range(100))
|
||||
|
||||
|
||||
def _grid_positions(
|
||||
*, slow_levels: list[float], fast_levels: list[float], snaked: bool, slow_is_col0: bool = True
|
||||
) -> list[list[float]]:
|
||||
positions: list[list[float]] = []
|
||||
for slow_i, slow_val in enumerate(slow_levels):
|
||||
row_fast = fast_levels if (not snaked or slow_i % 2 == 0) else list(reversed(fast_levels))
|
||||
for fast_val in row_fast:
|
||||
if slow_is_col0:
|
||||
positions.append([slow_val, fast_val])
|
||||
else:
|
||||
positions.append([fast_val, slow_val])
|
||||
return positions
|
||||
|
||||
|
||||
def test_heatmap_grid_scan_direction_and_snaking_x_fast(heatmap_widget):
|
||||
heatmap_widget._image_config = HeatmapConfig(
|
||||
parent_id="parent_id",
|
||||
x_device=HeatmapDeviceSignal(name="samx", entry="samx"),
|
||||
y_device=HeatmapDeviceSignal(name="samy", entry="samy"),
|
||||
z_device=HeatmapDeviceSignal(name="bpm4i", entry="bpm4i"),
|
||||
color_map="viridis",
|
||||
)
|
||||
|
||||
# x decreases (relative), y increases (relative), x is fast axis
|
||||
x0 = 10.0
|
||||
y0 = -3.0
|
||||
x_levels = (x0 + np.linspace(1.0, -1.0, 3)).tolist()
|
||||
y_levels = (y0 + np.linspace(-2.0, 2.0, 2)).tolist()
|
||||
snaked = True
|
||||
|
||||
scan_msg = messages.ScanStatusMessage(
|
||||
scan_id="123",
|
||||
status="open",
|
||||
scan_name="grid_scan",
|
||||
metadata={},
|
||||
info={
|
||||
"positions": _grid_positions(slow_levels=y_levels, fast_levels=x_levels, snaked=snaked)
|
||||
},
|
||||
request_inputs={
|
||||
"arg_bundle": ["samy", -2.0, 2.0, 2, "samx", 1.0, -1.0, 3],
|
||||
"kwargs": {"snaked": snaked, "relative": True},
|
||||
},
|
||||
)
|
||||
|
||||
img, transform = heatmap_widget.get_grid_scan_image(list(range(6)), msg=scan_msg)
|
||||
|
||||
assert img.shape == (3, 2)
|
||||
assert img[0, 0] == 0 # first point: (x0,y0) in scan order
|
||||
assert img[2, 1] == 3 # second row first point due to snaking
|
||||
assert img[0, 1] == 5 # last point in second row
|
||||
|
||||
p0 = transform.map(QPointF(0.5, 0.5))
|
||||
p1 = transform.map(QPointF(2.5, 1.5))
|
||||
assert p0.x() == pytest.approx(x_levels[0])
|
||||
assert p0.y() == pytest.approx(y_levels[0])
|
||||
assert p1.x() == pytest.approx(x_levels[-1])
|
||||
assert p1.y() == pytest.approx(y_levels[-1])
|
||||
|
||||
|
||||
def test_heatmap_grid_scan_direction_and_snaking_y_fast(heatmap_widget):
|
||||
heatmap_widget._image_config = HeatmapConfig(
|
||||
parent_id="parent_id",
|
||||
x_device=HeatmapDeviceSignal(name="samx", entry="samx"),
|
||||
y_device=HeatmapDeviceSignal(name="samy", entry="samy"),
|
||||
z_device=HeatmapDeviceSignal(name="bpm4i", entry="bpm4i"),
|
||||
color_map="viridis",
|
||||
)
|
||||
|
||||
# x decreases (relative), y increases (relative), y is fast axis
|
||||
x0 = 1.5
|
||||
y0 = 22.0
|
||||
x_levels = (x0 + np.linspace(1.0, -1.0, 3)).tolist()
|
||||
y_levels = (y0 + np.linspace(-2.0, 2.0, 2)).tolist()
|
||||
snaked = True
|
||||
|
||||
scan_msg = messages.ScanStatusMessage(
|
||||
scan_id="123",
|
||||
status="open",
|
||||
scan_name="grid_scan",
|
||||
metadata={},
|
||||
info={
|
||||
"positions": _grid_positions(slow_levels=x_levels, fast_levels=y_levels, snaked=snaked)
|
||||
},
|
||||
request_inputs={
|
||||
"arg_bundle": ["samx", 1.0, -1.0, 3, "samy", -2.0, 2.0, 2],
|
||||
"kwargs": {"snaked": snaked, "relative": True},
|
||||
},
|
||||
)
|
||||
|
||||
img, transform = heatmap_widget.get_grid_scan_image(list(range(6)), msg=scan_msg)
|
||||
|
||||
assert img.shape == (3, 2)
|
||||
assert img[0, 0] == 0
|
||||
# For y-fast scans, snaking reverses the y index on every odd x row.
|
||||
assert img[1, 1] == 2
|
||||
assert img[1, 0] == 3
|
||||
|
||||
p0 = transform.map(QPointF(0.5, 0.5))
|
||||
p1 = transform.map(QPointF(2.5, 1.5))
|
||||
assert p0.x() == pytest.approx(x_levels[0])
|
||||
assert p0.y() == pytest.approx(y_levels[0])
|
||||
assert p1.x() == pytest.approx(x_levels[-1])
|
||||
assert p1.y() == pytest.approx(y_levels[-1])
|
||||
|
||||
|
||||
def test_heatmap_get_step_scan_image(heatmap_widget):
|
||||
|
||||
scan_msg = messages.ScanStatusMessage(
|
||||
@@ -193,12 +310,16 @@ def test_heatmap_update_plot(heatmap_widget):
|
||||
color_map="viridis",
|
||||
)
|
||||
heatmap_widget.scan_item = create_dummy_scan_item()
|
||||
x_levels = np.linspace(-5, 5, 10).tolist()
|
||||
y_levels = np.linspace(-5, 5, 10).tolist()
|
||||
heatmap_widget.scan_item.status_message = messages.ScanStatusMessage(
|
||||
scan_id="123",
|
||||
status="open",
|
||||
scan_name="grid_scan",
|
||||
metadata={},
|
||||
info={"positions": np.random.rand(100, 2).tolist()},
|
||||
info={
|
||||
"positions": _grid_positions(slow_levels=x_levels, fast_levels=y_levels, snaked=True)
|
||||
},
|
||||
request_inputs={"arg_bundle": ["samx", -5, 5, 10, "samy", -5, 5, 10], "kwargs": {}},
|
||||
)
|
||||
with mock.patch.object(heatmap_widget.main_image, "setImage") as mock_set_image:
|
||||
@@ -334,12 +455,16 @@ def test_heatmap_widget_reset(heatmap_widget):
|
||||
"""
|
||||
Test that the reset method clears the plot.
|
||||
"""
|
||||
heatmap_widget._pending_interpolation_request = object()
|
||||
heatmap_widget._latest_interpolation_version = 5
|
||||
heatmap_widget.scan_item = create_dummy_scan_item()
|
||||
heatmap_widget.plot(x_name="samx", y_name="samy", z_name="bpm4i")
|
||||
|
||||
heatmap_widget.reset()
|
||||
assert heatmap_widget._grid_index is None
|
||||
assert heatmap_widget.main_image.raw_data is None
|
||||
assert heatmap_widget._pending_interpolation_request is None
|
||||
assert heatmap_widget._latest_interpolation_version == 5
|
||||
|
||||
|
||||
def test_heatmap_widget_update_plot_with_scan_history(heatmap_widget, grid_scan_history_msg, qtbot):
|
||||
@@ -364,3 +489,111 @@ def test_heatmap_widget_update_plot_with_scan_history(heatmap_widget, grid_scan_
|
||||
heatmap_widget.enforce_interpolation = True
|
||||
heatmap_widget.oversampling_factor = 2.0
|
||||
qtbot.waitUntil(lambda: heatmap_widget.main_image.raw_data.shape == (20, 20))
|
||||
|
||||
|
||||
def test_step_interpolation_worker_emits_finished(qtbot):
|
||||
worker = _StepInterpolationWorker()
|
||||
request = _InterpolationRequest(
|
||||
x_data=[0.0, 1.0, 0.5, 0.2],
|
||||
y_data=[0.0, 0.0, 1.0, 1.0],
|
||||
z_data=[1.0, 2.0, 3.0, 4.0],
|
||||
data_version=4,
|
||||
scan_id="scan-1",
|
||||
interpolation="linear",
|
||||
oversampling_factor=1.0,
|
||||
)
|
||||
with qtbot.waitSignal(worker.finished, timeout=1000) as blocker:
|
||||
worker.process(request, request.data_version)
|
||||
img, transform, data_version, scan_id = blocker.args
|
||||
assert img.shape[0] > 0
|
||||
assert isinstance(transform, QTransform)
|
||||
assert data_version == request.data_version
|
||||
assert scan_id == request.scan_id
|
||||
|
||||
|
||||
def test_step_interpolation_worker_emits_failed(qtbot, monkeypatch):
|
||||
def _scan_goes_boom(**kwargs):
|
||||
raise RuntimeError("crash")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"bec_widgets.widgets.plots.heatmap.heatmap.Heatmap.compute_step_scan_image", _scan_goes_boom
|
||||
)
|
||||
worker = _StepInterpolationWorker()
|
||||
request = _InterpolationRequest(
|
||||
x_data=[0.0, 1.0, 0.5, 0.2],
|
||||
y_data=[0.0, 0.0, 1.0, 1.0],
|
||||
z_data=[1.0, 2.0, 3.0, 4.0],
|
||||
data_version=99,
|
||||
scan_id="scan-err",
|
||||
interpolation="linear",
|
||||
oversampling_factor=1.0,
|
||||
)
|
||||
with qtbot.waitSignal(worker.failed, timeout=1000) as blocker:
|
||||
worker.process(request, request.data_version)
|
||||
error, data_version, scan_id = blocker.args
|
||||
assert "crash" in error
|
||||
assert data_version == request.data_version
|
||||
assert scan_id == request.scan_id
|
||||
|
||||
|
||||
def test_interpolation_generation_invalidation(heatmap_widget):
|
||||
heatmap_widget.scan_id = "scan-1"
|
||||
heatmap_widget._latest_interpolation_version = 2
|
||||
with (
|
||||
mock.patch.object(heatmap_widget, "_apply_image_update") as apply_mock,
|
||||
mock.patch.object(heatmap_widget, "_maybe_start_pending_interpolation") as maybe_mock,
|
||||
):
|
||||
heatmap_widget._on_interpolation_finished(
|
||||
np.zeros((2, 2)), QTransform(), data_version=1, scan_id="scan-1"
|
||||
)
|
||||
apply_mock.assert_not_called()
|
||||
maybe_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_pending_request_queueing_and_start(heatmap_widget):
|
||||
heatmap_widget.scan_id = "scan-queue"
|
||||
heatmap_widget.status_message = messages.ScanStatusMessage(
|
||||
scan_id="scan-queue",
|
||||
status="open",
|
||||
scan_name="step_scan",
|
||||
scan_type="step",
|
||||
metadata={},
|
||||
info={"positions": [[0, 0], [1, 1], [2, 2], [3, 3]]},
|
||||
)
|
||||
# Simulate an active worker processing a job so new requests are queued.
|
||||
heatmap_widget._interpolation_worker = mock.MagicMock()
|
||||
heatmap_widget._interpolation_worker.is_processing = True
|
||||
|
||||
with mock.patch.object(heatmap_widget, "_start_step_scan_interpolation") as start_mock:
|
||||
heatmap_widget._request_step_scan_interpolation(
|
||||
x_data=[0, 1, 2, 3],
|
||||
y_data=[0, 1, 2, 3],
|
||||
z_data=[0, 1, 2, 3],
|
||||
msg=heatmap_widget.status_message,
|
||||
)
|
||||
assert heatmap_widget._pending_interpolation_request is not None
|
||||
|
||||
# Now simulate worker finished and thread cleaned up
|
||||
heatmap_widget._interpolation_worker.is_processing = False
|
||||
pending = heatmap_widget._pending_interpolation_request
|
||||
heatmap_widget._pending_interpolation_request = pending
|
||||
heatmap_widget._maybe_start_pending_interpolation()
|
||||
|
||||
start_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_finish_interpolation_thread_cleans_references(heatmap_widget):
|
||||
worker_mock = mock.Mock()
|
||||
thread_mock = mock.Mock()
|
||||
thread_mock.isRunning.return_value = True
|
||||
heatmap_widget._interpolation_worker = worker_mock
|
||||
heatmap_widget._interpolation_thread = thread_mock
|
||||
|
||||
heatmap_widget._finish_interpolation_thread()
|
||||
|
||||
worker_mock.deleteLater.assert_called_once()
|
||||
thread_mock.quit.assert_called_once()
|
||||
thread_mock.wait.assert_called_once()
|
||||
thread_mock.deleteLater.assert_called_once()
|
||||
assert heatmap_widget._interpolation_worker is None
|
||||
assert heatmap_widget._interpolation_thread is None
|
||||
|
||||
@@ -81,7 +81,7 @@ def test_data_extraction_matches_coordinates(bec_image_widget_with_roi):
|
||||
|
||||
# For rectangular ROI: pixel bounding box equals coordinate bbox
|
||||
if isinstance(roi, RectangularROI):
|
||||
(x0, y0), (_, _), (_, _), (x1, y1) = roi.get_coordinates(typed=False)
|
||||
(x0, y0), (_, _), (_, _), (x1, y1), *_ = roi.get_coordinates(typed=False)
|
||||
# ensure ints inside image shape
|
||||
x0, y0, x1, y1 = map(int, (x0, y0, x1, y1))
|
||||
expected = widget.main_image.image[y0:y1, x0:x1]
|
||||
|
||||
@@ -191,51 +191,10 @@ def test_bec_weblinks(monkeypatch):
|
||||
assert opened_urls == [
|
||||
"https://beamline-experiment-control.readthedocs.io/en/latest/",
|
||||
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
|
||||
"https://gitlab.psi.ch/groups/bec/-/issues/",
|
||||
"https://github.com/bec-project/bec_widgets/issues",
|
||||
]
|
||||
|
||||
|
||||
#################################################################
|
||||
# Tests for scan‑progress bar animations
|
||||
|
||||
|
||||
def test_scan_progress_bar_show_animation(qtbot, bec_main_window):
|
||||
"""
|
||||
_show_scan_progress_bar should animate the container's maximumWidth
|
||||
from 0 to the configured target width.
|
||||
"""
|
||||
container = bec_main_window._scan_progress_bar_with_separator
|
||||
|
||||
# Pre‑condition: collapsed
|
||||
assert container.maximumWidth() == 0
|
||||
|
||||
bec_main_window._show_scan_progress_bar()
|
||||
|
||||
target = bec_main_window._scan_progress_bar_target_width
|
||||
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
|
||||
|
||||
assert container.maximumWidth() == target
|
||||
|
||||
|
||||
def test_scan_progress_bar_hide_animation(qtbot, bec_main_window):
|
||||
"""
|
||||
_animate_hide_scan_progress_bar should collapse the container back to 0 width.
|
||||
"""
|
||||
container = bec_main_window._scan_progress_bar_with_separator
|
||||
|
||||
# First expand it
|
||||
bec_main_window._show_scan_progress_bar()
|
||||
target = bec_main_window._scan_progress_bar_target_width
|
||||
qtbot.waitUntil(lambda: container.maximumWidth() == target, timeout=2000)
|
||||
|
||||
# Trigger hide animation
|
||||
bec_main_window._animate_hide_scan_progress_bar()
|
||||
|
||||
qtbot.waitUntil(lambda: container.maximumWidth() == 0, timeout=2000)
|
||||
|
||||
assert container.maximumWidth() == 0
|
||||
|
||||
|
||||
#################################################################
|
||||
# Tests for hover widget and tooltip behaviour
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtTest import QSignalSpy
|
||||
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from tests.unit_tests.client_mocks import mocked_client
|
||||
@@ -274,18 +273,74 @@ def test_motor_map_toolbar_selection(qtbot, mocked_client):
|
||||
# Verify toolbar bundle was created during initialization
|
||||
motor_selection = mm.toolbar.components.get_action("motor_selection")
|
||||
|
||||
motor_selection.motor_x.setCurrentText("samx")
|
||||
motor_selection.motor_y.setCurrentText("samy")
|
||||
motor_selection.widget.motor_x.setCurrentText("samx")
|
||||
motor_selection.widget.motor_y.setCurrentText("samy")
|
||||
|
||||
assert mm.config.x_motor.name == "samx"
|
||||
assert mm.config.y_motor.name == "samy"
|
||||
|
||||
motor_selection.motor_y.setCurrentText("samz")
|
||||
motor_selection.widget.motor_y.setCurrentText("samz")
|
||||
|
||||
assert mm.config.x_motor.name == "samx"
|
||||
assert mm.config.y_motor.name == "samz"
|
||||
|
||||
|
||||
def test_motor_selection_set_motors_blocks_signals(qtbot, mocked_client):
|
||||
"""Ensure set_motors updates both comboboxes without emitting change signals."""
|
||||
mm = create_widget(qtbot, MotorMap, client=mocked_client)
|
||||
motor_selection = mm.toolbar.components.get_action("motor_selection").widget
|
||||
|
||||
spy_x = QSignalSpy(motor_selection.motor_x.currentTextChanged)
|
||||
spy_y = QSignalSpy(motor_selection.motor_y.currentTextChanged)
|
||||
|
||||
motor_selection.set_motors("samx", "samy")
|
||||
|
||||
assert motor_selection.motor_x.currentText() == "samx"
|
||||
assert motor_selection.motor_y.currentText() == "samy"
|
||||
assert spy_x.count() == 0
|
||||
assert spy_y.count() == 0
|
||||
|
||||
|
||||
def test_motor_properties_partial_then_complete_map(qtbot, mocked_client):
|
||||
"""Setting x then y via properties should map once both are valid."""
|
||||
mm = create_widget(qtbot, MotorMap, client=mocked_client)
|
||||
|
||||
spy = QSignalSpy(mm.property_changed)
|
||||
mm.x_motor = "samx"
|
||||
|
||||
assert mm.config.x_motor.name == "samx"
|
||||
assert mm.config.y_motor.name is None
|
||||
assert mm._trace is None # map not triggered yet
|
||||
assert spy.at(0) == ["x_motor", "samx"]
|
||||
|
||||
mm.y_motor = "samy"
|
||||
|
||||
assert mm.config.x_motor.name == "samx"
|
||||
assert mm.config.y_motor.name == "samy"
|
||||
assert mm._trace is not None # map called once both valid
|
||||
assert spy.at(1) == ["y_motor", "samy"]
|
||||
assert len(mm._buffer["x"]) == 1
|
||||
assert len(mm._buffer["y"]) == 1
|
||||
|
||||
|
||||
def test_set_motor_name_emits_and_syncs_toolbar(qtbot, mocked_client):
|
||||
"""_set_motor_name should emit property changes and sync toolbar widgets."""
|
||||
mm = create_widget(qtbot, MotorMap, client=mocked_client)
|
||||
motor_selection = mm.toolbar.components.get_action("motor_selection").widget
|
||||
|
||||
spy = QSignalSpy(mm.property_changed)
|
||||
mm._set_motor_name("x", "samx")
|
||||
|
||||
assert mm.config.x_motor.name == "samx"
|
||||
assert motor_selection.motor_x.currentText() == "samx"
|
||||
assert spy.at(0) == ["x_motor", "samx"]
|
||||
|
||||
# Calling with same name should be a no-op
|
||||
initial_count = spy.count()
|
||||
mm._set_motor_name("x", "samx")
|
||||
assert spy.count() == initial_count
|
||||
|
||||
|
||||
def test_motor_map_settings_dialog(qtbot, mocked_client):
|
||||
"""Test the settings dialog for the motor map."""
|
||||
mm = create_widget(qtbot, MotorMap, client=mocked_client, popups=True)
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import pytest
|
||||
from qtpy.QtPdf import QPdfDocument
|
||||
from qtpy.QtPdfWidgets import QPdfView
|
||||
|
||||
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf_viewer_widget(qtbot, mocked_client):
|
||||
"""Create a PDF viewer widget for testing."""
|
||||
widget = PdfViewerWidget(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
widget.cleanup()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_pdf_file(tmpdir):
|
||||
"""Create a minimal 3-page PDF file for testing."""
|
||||
pdf_content = b"""%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>
|
||||
endobj
|
||||
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 44 >>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (Page 1) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
|
||||
5 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R >>
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Length 44 >>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (Page 2) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
|
||||
7 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 8 0 R >>
|
||||
endobj
|
||||
8 0 obj
|
||||
<< /Length 44 >>
|
||||
stream
|
||||
BT /F1 12 Tf 100 700 Td (Page 3) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
|
||||
9 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
|
||||
xref
|
||||
0 10
|
||||
0000000000 65535 f
|
||||
0000000010 00000 n
|
||||
0000000060 00000 n
|
||||
0000000125 00000 n
|
||||
0000000205 00000 n
|
||||
0000000282 00000 n
|
||||
0000000362 00000 n
|
||||
0000000439 00000 n
|
||||
0000000519 00000 n
|
||||
0000000596 00000 n
|
||||
trailer
|
||||
<< /Size 10 /Root 1 0 R >>
|
||||
startxref
|
||||
675
|
||||
%%EOF
|
||||
"""
|
||||
|
||||
pdf_path = tmpdir.join("test_3page.pdf")
|
||||
pdf_path.write_binary(pdf_content)
|
||||
return str(pdf_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_pdf_file_2(tmpdir):
|
||||
"""Create a second minimal temporary PDF file for testing."""
|
||||
# Create a minimal PDF content for testing
|
||||
pdf_content = b"""%PDF-1.4
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [3 0 R]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Parent 2 0 R
|
||||
/MediaBox [0 0 612 792]
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
/Contents 4 0 R
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Length 44
|
||||
>>stream
|
||||
BT
|
||||
/F1 12 Tf
|
||||
100 700 Td
|
||||
(Second Test PDF) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000307 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 5
|
||||
/Root 1 0 R
|
||||
>>
|
||||
startxref
|
||||
398
|
||||
%%EOF"""
|
||||
# Create temporary PDF file using tmpdir
|
||||
pdf_file = tmpdir.join("test2.pdf")
|
||||
pdf_file.write_binary(pdf_content)
|
||||
return str(pdf_file)
|
||||
|
||||
|
||||
def test_initialization(pdf_viewer_widget: PdfViewerWidget):
|
||||
"""Test that the widget initializes correctly."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Check basic widget setup
|
||||
assert widget is not None
|
||||
assert hasattr(widget, "pdf_view")
|
||||
assert hasattr(widget, "toolbar")
|
||||
assert hasattr(widget, "_pdf_document")
|
||||
|
||||
# Check initial state
|
||||
assert widget._current_file_path is None
|
||||
assert widget._page_spacing == 5
|
||||
assert widget._side_margins == 10
|
||||
|
||||
# Check PDF view setup
|
||||
assert isinstance(widget.pdf_view, QPdfView)
|
||||
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
|
||||
|
||||
# Check PDF document setup
|
||||
assert isinstance(widget._pdf_document, QPdfDocument)
|
||||
|
||||
|
||||
def test_toolbar_setup(pdf_viewer_widget: PdfViewerWidget):
|
||||
"""Test that toolbar is set up with all expected actions."""
|
||||
widget = pdf_viewer_widget
|
||||
toolbar = widget.toolbar
|
||||
|
||||
# Check that all expected actions exist
|
||||
expected_actions = [
|
||||
"open_file",
|
||||
"zoom_in",
|
||||
"zoom_out",
|
||||
"fit_width",
|
||||
"fit_page",
|
||||
"reset_zoom",
|
||||
"continuous_scroll",
|
||||
"prev_page",
|
||||
"next_page",
|
||||
"page_jump",
|
||||
]
|
||||
|
||||
for action_name in expected_actions:
|
||||
assert toolbar.components.exists(action_name), f"Action {action_name} not found"
|
||||
|
||||
|
||||
def test_load_pdf_file(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file, temp_pdf_file_2):
|
||||
"""Test loading a PDF file into the viewer."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Load the temporary PDF file
|
||||
widget.load_pdf(temp_pdf_file)
|
||||
qtbot.wait(100) # Wait for loading
|
||||
|
||||
# Check that the document is loaded
|
||||
assert widget._pdf_document.status() == QPdfDocument.Status.Ready
|
||||
assert widget._pdf_document.pageCount() > 0
|
||||
assert widget._current_file_path == temp_pdf_file
|
||||
|
||||
# Load a second PDF file to test reloading
|
||||
widget.load_pdf(temp_pdf_file_2)
|
||||
qtbot.wait(100) # Wait for loading
|
||||
|
||||
# Check that the new document is loaded
|
||||
assert widget._pdf_document.status() == QPdfDocument.Status.Ready
|
||||
assert widget._pdf_document.pageCount() > 0
|
||||
assert widget._current_file_path == temp_pdf_file_2
|
||||
|
||||
assert widget.current_file_path == temp_pdf_file_2
|
||||
|
||||
widget.current_file_path = temp_pdf_file
|
||||
qtbot.wait(100) # Wait for loading
|
||||
assert widget.current_file_path == temp_pdf_file
|
||||
|
||||
|
||||
def test_load_invalid_pdf_file(qtbot, pdf_viewer_widget: PdfViewerWidget, tmpdir):
|
||||
"""Test loading an invalid PDF file into the viewer."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Try to open a non-existent file
|
||||
invalid_pdf_file = tmpdir.join("non_existent.pdf")
|
||||
|
||||
# Attempt to load the invalid PDF file
|
||||
with pytest.raises(FileNotFoundError):
|
||||
widget.load_pdf(str(invalid_pdf_file), _override_slot_params={"raise_error": True})
|
||||
|
||||
|
||||
def test_page_navigation(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
|
||||
"""Test page navigation functionality."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Load the temporary PDF file
|
||||
with qtbot.waitSignal(widget.document_ready, timeout=2000):
|
||||
widget.load_pdf(temp_pdf_file)
|
||||
|
||||
# Check initial page
|
||||
assert widget.current_page == 1
|
||||
total_pages = widget._pdf_document.pageCount()
|
||||
assert total_pages >= 1
|
||||
|
||||
# Navigate to next page
|
||||
widget.next_page()
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == 2
|
||||
|
||||
# Navigate to previous page
|
||||
widget.previous_page()
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == 1
|
||||
|
||||
# Jump to last page
|
||||
widget.jump_to_page(total_pages)
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == total_pages
|
||||
|
||||
widget.jump_to_page(1)
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == 1
|
||||
|
||||
widget.jump_to_page(2)
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == 2
|
||||
|
||||
widget.go_to_last_page()
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == total_pages
|
||||
|
||||
widget.go_to_first_page()
|
||||
qtbot.wait(300)
|
||||
assert widget.current_page == 1
|
||||
|
||||
widget.page_input.setText(str(total_pages + 10))
|
||||
widget.page_input.returnPressed.emit()
|
||||
qtbot.wait(100)
|
||||
assert widget.current_page == total_pages
|
||||
|
||||
|
||||
def test_zoom_controls(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
|
||||
"""Test zoom in, zoom out, fit width, fit page, and reset zoom functionality."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Load the temporary PDF file
|
||||
with qtbot.waitSignal(widget.document_ready, timeout=2000):
|
||||
widget.load_pdf(temp_pdf_file)
|
||||
|
||||
# Initial zoom mode should be FitToWidth
|
||||
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
|
||||
|
||||
# Zoom in
|
||||
initial_zoom = widget.pdf_view.zoomFactor()
|
||||
widget.zoom_in()
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.zoomFactor() > initial_zoom
|
||||
|
||||
# Zoom out
|
||||
zoom_after_in = widget.pdf_view.zoomFactor()
|
||||
widget.zoom_out()
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.zoomFactor() < zoom_after_in
|
||||
|
||||
# Fit to page
|
||||
widget.fit_to_page()
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitInView
|
||||
|
||||
# Fit to width
|
||||
widget.fit_to_width()
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.FitToWidth
|
||||
|
||||
# Reset zoom
|
||||
widget.reset_zoom()
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.zoomMode() == QPdfView.ZoomMode.Custom
|
||||
|
||||
|
||||
def test_page_spacing_and_margins(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
|
||||
"""Test setting page spacing and side margins."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Load the temporary PDF file
|
||||
with qtbot.waitSignal(widget.document_ready, timeout=2000):
|
||||
widget.load_pdf(temp_pdf_file)
|
||||
|
||||
# Set and verify page spacing
|
||||
widget.page_spacing = 20
|
||||
assert widget.page_spacing == 20
|
||||
|
||||
# Set and verify side margins
|
||||
widget.side_margins = 30
|
||||
assert widget.side_margins == 30
|
||||
|
||||
|
||||
def test_toggle_continuous_scroll(qtbot, pdf_viewer_widget: PdfViewerWidget, temp_pdf_file):
|
||||
"""Test toggling continuous scroll mode."""
|
||||
widget = pdf_viewer_widget
|
||||
|
||||
# Load the temporary PDF file
|
||||
with qtbot.waitSignal(widget.document_ready, timeout=2000):
|
||||
widget.load_pdf(temp_pdf_file)
|
||||
|
||||
# Initial mode should be single page
|
||||
assert widget.pdf_view.pageMode() == QPdfView.PageMode.SinglePage
|
||||
|
||||
# Toggle to continuous scroll
|
||||
widget.toggle_continuous_scroll(True)
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.pageMode() == QPdfView.PageMode.MultiPage
|
||||
|
||||
# Toggle back to single page
|
||||
widget.toggle_continuous_scroll(False)
|
||||
qtbot.wait(100)
|
||||
assert widget.pdf_view.pageMode() == QPdfView.PageMode.SinglePage
|
||||
|
||||
widget.jump_to_page(2)
|
||||
qtbot.wait(100)
|
||||
assert widget.current_page == 2
|
||||
@@ -80,3 +80,60 @@ def test_positioner_box_setpoint_changes(positioner_box_2d: PositionerBox2D):
|
||||
positioner_box_2d.ui.setpoint_ver.setText("100")
|
||||
positioner_box_2d.on_setpoint_change_ver()
|
||||
mock_move.assert_called_once_with(100, relative=False)
|
||||
|
||||
|
||||
def _hor_buttons(widget: PositionerBox2D):
|
||||
return [
|
||||
widget.ui.tweak_increase_hor,
|
||||
widget.ui.tweak_decrease_hor,
|
||||
widget.ui.step_increase_hor,
|
||||
widget.ui.step_decrease_hor,
|
||||
]
|
||||
|
||||
|
||||
def _ver_buttons(widget: PositionerBox2D):
|
||||
return [
|
||||
widget.ui.tweak_increase_ver,
|
||||
widget.ui.tweak_decrease_ver,
|
||||
widget.ui.step_increase_ver,
|
||||
widget.ui.step_decrease_ver,
|
||||
]
|
||||
|
||||
|
||||
def test_controls_default_enabled(positioner_box_2d: PositionerBox2D):
|
||||
"""By default both axes controls are enabled and UI reflects it."""
|
||||
assert positioner_box_2d.enable_controls_hor is True
|
||||
assert positioner_box_2d.enable_controls_ver is True
|
||||
assert all(w.isEnabled() for w in _hor_buttons(positioner_box_2d))
|
||||
assert all(w.isEnabled() for w in _ver_buttons(positioner_box_2d))
|
||||
|
||||
|
||||
def test_disable_enable_controls_and_persist_across_device_change(
|
||||
positioner_box_2d: PositionerBox2D, qtbot
|
||||
):
|
||||
"""Disabling an axis should disable its buttons and remain disabled after device (re)binding."""
|
||||
# Disable horizontal and verify UI
|
||||
positioner_box_2d.enable_controls_hor = False
|
||||
assert positioner_box_2d.enable_controls_hor is False
|
||||
assert all(not w.isEnabled() for w in _hor_buttons(positioner_box_2d))
|
||||
|
||||
# Simulate a horizontal device change; state must persist after queued re-apply
|
||||
positioner_box_2d.on_device_change_hor("samx", "samx")
|
||||
qtbot.waitUntil(lambda: all(not w.isEnabled() for w in _hor_buttons(positioner_box_2d)))
|
||||
|
||||
# Re-enable and verify UI
|
||||
positioner_box_2d.enable_controls_hor = True
|
||||
qtbot.waitUntil(lambda: all(w.isEnabled() for w in _hor_buttons(positioner_box_2d)))
|
||||
|
||||
# Disable vertical and verify UI
|
||||
positioner_box_2d.enable_controls_ver = False
|
||||
assert positioner_box_2d.enable_controls_ver is False
|
||||
assert all(not w.isEnabled() for w in _ver_buttons(positioner_box_2d))
|
||||
|
||||
# Simulate a vertical device change; state must persist after queued re-apply
|
||||
positioner_box_2d.on_device_change_ver("samy", "samy")
|
||||
qtbot.waitUntil(lambda: all(not w.isEnabled() for w in _ver_buttons(positioner_box_2d)))
|
||||
|
||||
# Re-enable and verify UI
|
||||
positioner_box_2d.enable_controls_ver = True
|
||||
qtbot.waitUntil(lambda: all(w.isEnabled() for w in _ver_buttons(positioner_box_2d)))
|
||||
|
||||
@@ -25,6 +25,30 @@ def scan_progressbar(qtbot, mocked_client):
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_message():
|
||||
return messages.ScanQueueMessage(
|
||||
metadata={
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {"sample_name": ""},
|
||||
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
|
||||
},
|
||||
scan_type="line_scan",
|
||||
parameter={
|
||||
"args": {"samx": [-10.0, 10.0]},
|
||||
"kwargs": {
|
||||
"steps": 20,
|
||||
"relative": False,
|
||||
"exp_time": 0.1,
|
||||
"burst_at_each_point": 1,
|
||||
"system_config": {"file_suffix": None, "file_directory": None},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
)
|
||||
|
||||
|
||||
def test_progress_task_basic():
|
||||
"""percentage, remaining, and formatted time helpers behave as expected."""
|
||||
task = ProgressTask(parent=None, value=50, max_value=100, done=False)
|
||||
@@ -167,7 +191,9 @@ def test_progressbar_queue_update(scan_progressbar):
|
||||
"""
|
||||
Test that an empty queue update does not change the progress source.
|
||||
"""
|
||||
msg = messages.ScanQueueStatusMessage(queue={"primary": {"info": [], "status": "RUNNING"}})
|
||||
msg = messages.ScanQueueStatusMessage(
|
||||
queue={"primary": messages.ScanQueueStatus(info=[], status="RUNNING")}
|
||||
)
|
||||
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
|
||||
scan_progressbar.on_queue_update(
|
||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
||||
@@ -175,50 +201,37 @@ def test_progressbar_queue_update(scan_progressbar):
|
||||
mock_set_source.assert_not_called()
|
||||
|
||||
|
||||
def test_progressbar_queue_update_with_scan(scan_progressbar):
|
||||
def test_progressbar_queue_update_with_scan(scan_progressbar, scan_message):
|
||||
"""
|
||||
Test that a queue update with a scan changes the progress source to SCAN_PROGRESS.
|
||||
"""
|
||||
request_block = messages.RequestBlock(
|
||||
msg=scan_message,
|
||||
RID="some-rid",
|
||||
scan_motors=["samx"],
|
||||
readout_priority={"monitored": ["samx"]},
|
||||
is_scan=True,
|
||||
scan_number=1,
|
||||
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
|
||||
report_instructions=[{"scan_progress": 20}],
|
||||
)
|
||||
msg = messages.ScanQueueStatusMessage(
|
||||
metadata={},
|
||||
queue={
|
||||
"primary": {
|
||||
"info": [
|
||||
{
|
||||
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
"status": "RUNNING",
|
||||
"active_request_block": {
|
||||
"msg": messages.ScanQueueMessage(
|
||||
metadata={
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {"sample_name": ""},
|
||||
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
|
||||
},
|
||||
scan_type="line_scan",
|
||||
parameter={
|
||||
"args": {"samx": [-10.0, 10.0]},
|
||||
"kwargs": {
|
||||
"steps": 20,
|
||||
"relative": False,
|
||||
"exp_time": 0.1,
|
||||
"burst_at_each_point": 1,
|
||||
"system_config": {
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
),
|
||||
"scan_number": 1,
|
||||
"report_instructions": [{"scan_progress": 20}],
|
||||
},
|
||||
}
|
||||
"primary": messages.ScanQueueStatus(
|
||||
info=[
|
||||
messages.QueueInfoEntry(
|
||||
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
status="RUNNING",
|
||||
active_request_block=request_block,
|
||||
is_scan=[True],
|
||||
request_blocks=[request_block],
|
||||
scan_number=[1],
|
||||
)
|
||||
],
|
||||
"status": "RUNNING",
|
||||
}
|
||||
status="RUNNING",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -229,50 +242,37 @@ def test_progressbar_queue_update_with_scan(scan_progressbar):
|
||||
mock_set_source.assert_called_once_with(ProgressSource.SCAN_PROGRESS)
|
||||
|
||||
|
||||
def test_progressbar_queue_update_with_device(scan_progressbar):
|
||||
def test_progressbar_queue_update_with_device(scan_progressbar, scan_message):
|
||||
"""
|
||||
Test that a queue update with a device changes the progress source to DEVICE_PROGRESS.
|
||||
"""
|
||||
request_block = messages.RequestBlock(
|
||||
msg=scan_message,
|
||||
RID="some-rid",
|
||||
scan_motors=["samx"],
|
||||
readout_priority={"monitored": ["samx"]},
|
||||
is_scan=True,
|
||||
scan_number=1,
|
||||
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
|
||||
report_instructions=[{"device_progress": ["samx"]}],
|
||||
)
|
||||
msg = messages.ScanQueueStatusMessage(
|
||||
metadata={},
|
||||
queue={
|
||||
"primary": {
|
||||
"info": [
|
||||
{
|
||||
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
"status": "RUNNING",
|
||||
"active_request_block": {
|
||||
"msg": messages.ScanQueueMessage(
|
||||
metadata={
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {"sample_name": ""},
|
||||
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
|
||||
},
|
||||
scan_type="line_scan",
|
||||
parameter={
|
||||
"args": {"samx": [-10.0, 10.0]},
|
||||
"kwargs": {
|
||||
"steps": 20,
|
||||
"relative": False,
|
||||
"exp_time": 0.1,
|
||||
"burst_at_each_point": 1,
|
||||
"system_config": {
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
),
|
||||
"scan_number": 1,
|
||||
"report_instructions": [{"device_progress": ["samx"]}],
|
||||
},
|
||||
}
|
||||
"primary": messages.ScanQueueStatus(
|
||||
info=[
|
||||
messages.QueueInfoEntry(
|
||||
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
status="RUNNING",
|
||||
active_request_block=request_block,
|
||||
is_scan=[True],
|
||||
request_blocks=[request_block],
|
||||
scan_number=[1],
|
||||
)
|
||||
],
|
||||
"status": "RUNNING",
|
||||
}
|
||||
status="RUNNING",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -283,49 +283,36 @@ def test_progressbar_queue_update_with_device(scan_progressbar):
|
||||
mock_set_source.assert_called_once_with(ProgressSource.DEVICE_PROGRESS, device="samx")
|
||||
|
||||
|
||||
def test_progressbar_queue_update_with_no_scan_or_device(scan_progressbar):
|
||||
def test_progressbar_queue_update_with_no_scan_or_device(scan_progressbar, scan_message):
|
||||
"""
|
||||
Test that a queue update with neither scan nor device does not change the progress source.
|
||||
"""
|
||||
request_block = messages.RequestBlock(
|
||||
msg=scan_message,
|
||||
RID="some-rid",
|
||||
scan_motors=["samx"],
|
||||
readout_priority={"monitored": ["samx"]},
|
||||
is_scan=True,
|
||||
scan_number=1,
|
||||
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
|
||||
)
|
||||
msg = messages.ScanQueueStatusMessage(
|
||||
metadata={},
|
||||
queue={
|
||||
"primary": {
|
||||
"info": [
|
||||
{
|
||||
"queue_id": "40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
"scan_id": ["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
"status": "RUNNING",
|
||||
"active_request_block": {
|
||||
"msg": messages.ScanQueueMessage(
|
||||
metadata={
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
"user_metadata": {"sample_name": ""},
|
||||
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
|
||||
},
|
||||
scan_type="line_scan",
|
||||
parameter={
|
||||
"args": {"samx": [-10.0, 10.0]},
|
||||
"kwargs": {
|
||||
"steps": 20,
|
||||
"relative": False,
|
||||
"exp_time": 0.1,
|
||||
"burst_at_each_point": 1,
|
||||
"system_config": {
|
||||
"file_suffix": None,
|
||||
"file_directory": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
queue="primary",
|
||||
),
|
||||
"scan_number": 1,
|
||||
},
|
||||
}
|
||||
"primary": messages.ScanQueueStatus(
|
||||
info=[
|
||||
messages.QueueInfoEntry(
|
||||
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
|
||||
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
|
||||
status="RUNNING",
|
||||
active_request_block=request_block,
|
||||
is_scan=[True],
|
||||
request_blocks=[request_block],
|
||||
scan_number=[1],
|
||||
)
|
||||
],
|
||||
"status": "RUNNING",
|
||||
}
|
||||
status="RUNNING",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for the BECList widget."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from qtpy import QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_list import BECList
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bec_list(qtbot):
|
||||
widget = BECList()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_widget(qtbot):
|
||||
widget = QtWidgets.QLabel("sample")
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
return widget
|
||||
|
||||
|
||||
class TestBECList:
|
||||
def test_add_widget_item(self, bec_list, sample_widget):
|
||||
bec_list.add_widget_item("key1", sample_widget)
|
||||
|
||||
assert "key1" in bec_list
|
||||
assert bec_list.count() == 1
|
||||
retrieved_widget = bec_list.get_widget("key1")
|
||||
assert retrieved_widget == sample_widget
|
||||
retrieved_item = bec_list.get_item("key1")
|
||||
assert retrieved_item is not None
|
||||
assert bec_list.itemWidget(retrieved_item) == sample_widget
|
||||
|
||||
def test_add_widget_item_replaces_existing(self, bec_list, sample_widget, qtbot):
|
||||
bec_list.add_widget_item("key", sample_widget)
|
||||
replacement = QtWidgets.QLabel("replacement")
|
||||
qtbot.addWidget(replacement)
|
||||
qtbot.waitExposed(replacement)
|
||||
|
||||
bec_list.add_widget_item("key", replacement)
|
||||
|
||||
assert bec_list.count() == 1
|
||||
assert bec_list.get_widget("key") == replacement
|
||||
# ensure first widget no longer tracked
|
||||
assert sample_widget not in bec_list.get_widgets()
|
||||
|
||||
def test_remove_widget_item(self, bec_list, sample_widget, monkeypatch):
|
||||
bec_list.add_widget_item("key", sample_widget)
|
||||
|
||||
close_mock = MagicMock()
|
||||
delete_mock = MagicMock()
|
||||
monkeypatch.setattr(sample_widget, "close", close_mock)
|
||||
monkeypatch.setattr(sample_widget, "deleteLater", delete_mock)
|
||||
|
||||
bec_list.remove_widget_item("key")
|
||||
|
||||
assert bec_list.count() == 0
|
||||
assert "key" not in bec_list
|
||||
close_mock.assert_called_once()
|
||||
delete_mock.assert_called_once()
|
||||
|
||||
def test_remove_widget_item_missing_key(self, bec_list):
|
||||
bec_list.remove_widget_item("missing")
|
||||
assert bec_list.count() == 0
|
||||
|
||||
def test_clear_widgets(self, bec_list, qtbot):
|
||||
for key in ["a", "b", "c"]:
|
||||
label = QtWidgets.QLabel(key)
|
||||
qtbot.addWidget(label)
|
||||
qtbot.waitExposed(label)
|
||||
bec_list.add_widget_item(key, label)
|
||||
|
||||
bec_list.clear_widgets()
|
||||
|
||||
assert bec_list.count() == 0
|
||||
assert bec_list.get_widgets() == []
|
||||
assert bec_list.get_all_keys() == []
|
||||
|
||||
def test_get_widget_and_item(self, bec_list, sample_widget):
|
||||
bec_list.add_widget_item("key", sample_widget)
|
||||
|
||||
item = bec_list.get_item("key")
|
||||
assert item is not None
|
||||
assert bec_list.get_widget_for_item(item) == sample_widget
|
||||
assert bec_list.get_widget("key") == sample_widget
|
||||
|
||||
def test_get_item_for_widget(self, bec_list, sample_widget):
|
||||
bec_list.add_widget_item("key", sample_widget)
|
||||
|
||||
item = bec_list.get_item_for_widget(sample_widget)
|
||||
assert item is not None
|
||||
assert bec_list.itemWidget(item) == sample_widget
|
||||
|
||||
def test_get_all_keys(self, bec_list, qtbot):
|
||||
labels = []
|
||||
for key in ["k1", "k2", "k3"]:
|
||||
label = QtWidgets.QLabel(key)
|
||||
labels.append(label)
|
||||
qtbot.addWidget(label)
|
||||
qtbot.waitExposed(label)
|
||||
bec_list.add_widget_item(key, label)
|
||||
|
||||
assert sorted(bec_list.get_all_keys()) == ["k1", "k2", "k3"]
|
||||
assert set(bec_list.get_widgets()) == set(labels)
|
||||
|
||||
def test_get_widget_for_item_unknown(self, bec_list, sample_widget):
|
||||
unrelated_item = QtWidgets.QListWidgetItem()
|
||||
assert bec_list.get_widget_for_item(unrelated_item) is None
|
||||
|
||||
bec_list.add_widget_item("key", sample_widget)
|
||||
other_item = QtWidgets.QListWidgetItem()
|
||||
assert bec_list.get_widget_for_item(other_item) is None
|
||||
|
||||
def test_get_item_for_widget_unknown(self, bec_list, qtbot):
|
||||
label = QtWidgets.QLabel("orphan")
|
||||
qtbot.addWidget(label)
|
||||
qtbot.waitExposed(label)
|
||||
assert bec_list.get_item_for_widget(label) is None
|
||||
|
||||
def test_contains(self, bec_list, sample_widget):
|
||||
assert "key" not in bec_list
|
||||
bec_list.add_widget_item("key", sample_widget)
|
||||
assert "key" in bec_list
|
||||
@@ -479,6 +479,43 @@ def test_add_dap_curve(qtbot, mocked_client_with_dap, monkeypatch):
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
|
||||
"""
|
||||
Ensure that custom curves can also serve as parents for DAP fits.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
x = np.linspace(-1, 1, 50)
|
||||
y = np.sin(x)
|
||||
custom_curve = wf.plot(x=x, y=y, label="custom-curve")
|
||||
|
||||
dap_curve = wf.add_dap_curve(device_label=custom_curve.name(), dap_name="GaussianModel")
|
||||
assert dap_curve.config.source == "dap"
|
||||
assert dap_curve.config.parent_label == custom_curve.name()
|
||||
assert dap_curve.config.signal.name == custom_curve.name()
|
||||
assert dap_curve.config.signal.entry == "custom"
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_curve_set_data_emits_dap_update(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
|
||||
with qtbot.waitSignal(wf.request_dap_update):
|
||||
c.set_data([7, 8, 9], [10, 11, 12])
|
||||
|
||||
|
||||
def test_plot_custom_curve_with_inline_dap(qtbot, mocked_client_with_dap):
|
||||
"""
|
||||
Supplying the `dap` kwarg when plotting custom data should auto-create the fit curve.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
curve = wf.plot(x=[0, 1, 2], y=[1, 2, 3], label="custom-inline", dap="GaussianModel")
|
||||
|
||||
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel")
|
||||
assert dap_curve is not None
|
||||
assert dap_curve.config.parent_label == curve.name()
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Test the _fetch_scan_data_and_access method returns live_data/val if in a live scan,
|
||||
|
||||
@@ -34,7 +34,7 @@ def test_web_console_write(console_widget):
|
||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
||||
console_widget.write("Hello, World!")
|
||||
|
||||
assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls
|
||||
assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls
|
||||
|
||||
|
||||
def test_web_console_write_no_return(console_widget):
|
||||
@@ -42,7 +42,7 @@ def test_web_console_write_no_return(console_widget):
|
||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
||||
console_widget.write("Hello, World!", send_return=False)
|
||||
|
||||
assert mock.call("window.term.paste('Hello, World!');") in mock_run_js.mock_calls
|
||||
assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls
|
||||
assert mock_run_js.call_count == 1
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user