Compare commits

..

102 Commits

Author SHA1 Message Date
wakonig_k d91a61ae45 f - wip 2025-12-23 11:37:16 +01:00
wakonig_k 0419b63e3f feat(web console): add support for shared web console sessions 2025-12-23 11:07:34 +01:00
wyzula_j b4ad75aade fix(client): client API regenerated 2025-12-19 14:12:48 +01:00
wyzula_j 82ce27a700 feat(device-manager): Add DeviceManager Widget for BEC Widget main applications 2025-12-19 14:12:08 +01:00
wyzula_j fe8e6d9427 fix(general_app): old general app example removed 2025-12-19 14:12:08 +01:00
wyzula_j f8cd8d0d06 fix(heatmap): interpolation thread is killed only on exit, logger for dandling thread 2025-12-19 14:12:08 +01:00
wyzula_j 821b61bcc0 perf(heatmap): thread worker optimization 2025-12-19 14:12:08 +01:00
wyzula_j d9afe31d61 fix(heatmap): interpolation of the image moved to separate thread 2025-12-19 14:12:08 +01:00
wyzula_j 8df2576390 fix(motor_map): x/y motor are saved in properties 2025-12-19 14:12:08 +01:00
perl_d 240c6dd439 fix: don't wait forever 2025-12-19 14:08:47 +01:00
wyzula_j 6039d070b7 fix(widget_state_manager): PROPERTIES_TO_SKIP are not restored even if in ini file 2025-12-19 14:08:47 +01:00
wyzula_j 04fc10213d feat(advanced_dock_area): floating docks restore with relative geometry 2025-12-19 14:08:47 +01:00
wakonig_k c3d6eb009f refactor: improvements to enum access 2025-12-19 14:08:47 +01:00
wyzula_j 669a84cb21 feat(advanced_dock_area): instance lock for multiple ads in same session 2025-12-19 14:08:47 +01:00
wyzula_j 1211a66577 fix(widgets): removed isVisible from all SafeProperties 2025-12-19 14:08:47 +01:00
wyzula_j a2374f00b0 fix(bec_widget): improved qt enums; grab safeguard 2025-12-19 14:08:47 +01:00
wyzula_j 92dc947e68 fix(qt_ads): pythons stubs match structure of PySide6QtAds 2025-12-19 14:08:47 +01:00
wyzula_j 574fd051c1 fix(widget_state_manager): filtering of not wanted properties 2025-12-19 14:08:47 +01:00
wyzula_j 8070d60370 refactor(main_app): adapted for DockAreaWidget changes 2025-12-19 14:08:47 +01:00
wyzula_j 23a232da9c refactor(developer_view): changed to use DockAreaWidget 2025-12-19 14:08:47 +01:00
wyzula_j c7d40ca82c refactor(monaco_dock): changed to use DockAreaWidget 2025-12-19 14:08:47 +01:00
wyzula_j 1c38d7a6ff feat(advanced_dock_area): created DockAreaWidget base class; profile management through namespaces; dock area variants 2025-12-19 14:08:47 +01:00
wyzula_j 75afac2fc7 fix(main_window): removed general forced cleanup 2025-12-19 14:08:47 +01:00
wyzula_j 4ad8b7cb22 feat(advanced_dock_area): UI/UX for profile management improved, saving directories logic adjusted 2025-12-19 14:08:47 +01:00
wyzula_j 8c9d06e9d6 fix(main_window): cleanup adjusted with shiboken6 2025-12-19 14:08:47 +01:00
wyzula_j 781f7cc055 fix(dark_mode_button): skip settings added 2025-12-19 14:08:47 +01:00
wyzula_j 2dac1c38c1 fix(widget_state_manager): added shiboken check 2025-12-19 14:08:47 +01:00
wyzula_j a00bb0fe58 feat(bec_widget): save screenshot to bytes 2025-12-19 14:08:47 +01:00
wyzula_j 435873b539 fix(becconnector): ophyd thread killer on exit + in conftest 2025-12-19 14:08:47 +01:00
wakonig_k cb253e5998 feat(guided_tour): add guided tour 2025-12-19 14:08:47 +01:00
wakonig_k 17fa18d9d2 fix: add metadata to scan control export 2025-12-19 14:08:47 +01:00
wyzula_j f7210b88ea feat(developer_view): add developer view 2025-12-19 14:08:47 +01:00
wyzula_j cb2ccb02ff feat(jupyter_console_window): adjustment for general usage 2025-12-19 14:08:47 +01:00
wakonig_k cacf98cb9a feat(ads): add pyi stub file to provide type hints for ads 2025-12-19 14:08:47 +01:00
appel_c 5d0ec2186b feat(dm-view): initial device manager view added 2025-12-19 14:08:47 +01:00
appel_c fc4ad051f8 feat(help-inspector): add help inspector widget 2025-12-19 14:08:47 +01:00
wyzula_j d5aaba1adb fix(signal_label): dispatcher unsubscribed in the cleanup 2025-12-19 14:08:47 +01:00
wyzula_j 5b9fdc7d30 fix(client): abort, reset, stop button removed from RPC access 2025-12-19 14:08:47 +01:00
wyzula_j 943a911a17 feat(main_app): main app with interactive app switcher 2025-12-19 14:08:47 +01:00
wyzula_j 80f7829adc feat(actions): actions can be created with label text with beside or under alignment 2025-12-19 14:08:47 +01:00
wyzula_j f6f0ad445f feat(busy_loader): busy loader added to bec widget base class 2025-12-19 14:08:47 +01:00
wakonig_k 554704a63a feat: add SafeConnect 2025-12-19 14:08:47 +01:00
wyzula_j a3e044bf50 fix(bec_widgets): adapt to bec_qthemes 1.0; themes can be only applied on living Qt objects 2025-12-19 14:08:47 +01:00
wyzula_j 66ae2b25fd feat(advanced_dock_area): added ads based dock area with profiles 2025-12-19 13:33:53 +01:00
wyzula_j 532b7422b8 fix(web_console): added startup kwarg 2025-12-19 13:33:53 +01:00
wyzula_j 80d1c29ab1 refactor(bec_main_window): main app theme renamed to View 2025-12-19 13:33:53 +01:00
wyzula_j 18b0cd4142 fix(widget_state_manager): state manager can save all properties recursively to already existing settings 2025-12-19 13:33:53 +01:00
wyzula_j a26e1f4811 feat(bec_widget): attach/detach method for all widgets + client regenerated 2025-12-19 13:33:51 +01:00
wyzula_j 3b7bc2b25a feat(widget_io): widget hierarchy can grap all bec connectors from the widget recursively 2025-12-19 11:38:43 +01:00
wyzula_j a7cf98cb58 fix(bec_connector): widget_removed and name_established signals added 2025-12-19 11:38:43 +01:00
wakonig_k 01b317367a ci: install ttyd 2025-12-19 11:38:43 +01:00
wakonig_k a9a4d3aa6e ci: add artifact upload 2025-12-19 11:38:43 +01:00
wyzula_j 605c13a6ea build: PySide6-QtAds; bec_qtheme V1; dependencies updated and adjusted 2025-12-19 11:38:43 +01:00
wakonig_k de8fe3b5f5 test(device config): validate against pydantic 2025-12-18 14:02:10 +01:00
semantic-release 2b75d5600a 2.45.13
Automatically generated by python-semantic-release
2025-12-17 13:52:45 +00:00
wakonig_k 01c6e092b9 fix(scan queue): adjustments for changes to the pydantic model of the scan queue 2025-12-17 14:51:54 +01:00
semantic-release ca6f355aac 2.45.12
Automatically generated by python-semantic-release
2025-12-16 12:37:06 +00:00
wyzula_j d876ca72bc fix(heatmap): more robust logic for fast and slow axis in grid scan 2025-12-16 13:36:17 +01:00
wyzula_j e0fd97616d fix(heatmap): flush image if config changes during scan 2025-12-16 13:36:17 +01:00
wyzula_j 6af8a5cbfe fix(heatmap): grid scan image correctly map to scan positions 2025-12-16 13:36:17 +01:00
semantic-release 944e2cedf8 2.45.11
Automatically generated by python-semantic-release
2025-12-15 14:48:20 +00:00
wyzula_j cd11a6cce3 fix(waveform): support for AsyncMultiSignal 2025-12-15 15:47:26 +01:00
semantic-release c98106e594 2.45.10
Automatically generated by python-semantic-release
2025-12-10 11:21:40 +00:00
wakonig_k 04f1ff4fe7 fix(devices): minor fix to comply with new config helper in bec_lib 2025-12-10 12:20:48 +01:00
semantic-release 45ed92494c 2.45.9
Automatically generated by python-semantic-release
2025-12-09 14:21:27 +00:00
wakonig_k 5fc96bd299 fix(rpc): add expiration to GUI registry state updates 2025-12-09 15:20:42 +01:00
semantic-release 1ad5df57fe 2.45.8
Automatically generated by python-semantic-release
2025-12-08 14:36:13 +00:00
wyzula_j 440e778162 fix(notification_banner): backwards compatibility to push messages from Broker to Centre as dict 2025-12-08 15:35:23 +01:00
wyzula_j fdeb8fcb0f fix(notification_banner): formatted error messages fetched directly from BECMessage; do not repreat notifications ids 2025-12-08 15:35:23 +01:00
wyzula_j 5c90983dd4 fix(notification_banner): better contrast in light mode 2025-12-08 15:35:23 +01:00
wyzula_j 4171de1e45 fix(notification_banner): expired messages are hidden in notification center but still accessible 2025-12-08 15:35:23 +01:00
semantic-release f12339e6f9 2.45.7
Automatically generated by python-semantic-release
2025-12-08 14:04:02 +00:00
perl_d ce8e5f0bec fix: handle none in literal combobox 2025-12-08 15:03:17 +01:00
semantic-release 7ea9ab5175 2.45.6
Automatically generated by python-semantic-release
2025-11-27 09:43:46 +00:00
wyzula_j b72f0dc6e8 fix(curve): update dap curves if data are set manually 2025-11-27 10:42:58 +01:00
semantic-release cb9d429884 2.45.5
Automatically generated by python-semantic-release
2025-11-26 13:25:28 +00:00
perl_d 0a80bd0a92 fix: remove ghost widgets in scan metadata 2025-11-26 14:24:36 +01:00
semantic-release 9bc9d355e2 2.45.4
Automatically generated by python-semantic-release
2025-11-24 13:30:46 +00:00
wyzula_j 7d5e702a11 fix(web_links): fixed link to bec widget issues from gitlab to github 2025-11-24 14:29:56 +01:00
wyzula_j 40cbf7fe4f fix(main_window): removed hiding scan progressbar animation 2025-11-24 14:29:56 +01:00
semantic-release 7b287c45f2 2.45.3
Automatically generated by python-semantic-release
2025-11-17 19:27:38 +00:00
wakonig_k c9455672b5 fix(fakeredis): add support for additional args 2025-11-17 20:24:44 +01:00
semantic-release 7f06375f9d 2.45.2
Automatically generated by python-semantic-release
2025-11-17 12:30:21 +00:00
wyzula_j d00d786399 fix(test): removed duplicate test in crosshair 2025-11-17 13:29:35 +01:00
wyzula_j a4c465dcaf build: pyqtgraph pin to 0.13.7 2025-11-17 13:29:35 +01:00
semantic-release d0e94d0da4 2.45.1
Automatically generated by python-semantic-release
2025-11-14 14:13:05 +00:00
wyzula_j bb3cea7fe8 fix(waveform): async_readback can accept 0D data 2025-11-14 15:12:14 +01:00
semantic-release 3c6aa8e138 2.45.0
Automatically generated by python-semantic-release
2025-11-10 19:28:18 +00:00
wyzula_j 198684c65d feat(waveform): dap curve can be attached to custom and history curves 2025-11-10 20:27:31 +01:00
wakonig_k 617f2df2af chore: add third-party license notice 2025-11-10 13:52:22 +01:00
semantic-release ef83287126 2.44.0
Automatically generated by python-semantic-release
2025-11-05 21:43:46 +00:00
wyzula_j d5e6f095fe refactor(plot_base): consolidated user access for the PlotBase 2025-11-05 22:42:57 +01:00
wyzula_j b10efc0f40 feat(plot_base): invert x/y axis 2025-11-05 22:42:57 +01:00
wyzula_j 44b1dbf911 docs: README rewritten 2025-11-03 14:59:57 +01:00
Klaus Wakonig e9d381a18a chore: Update stale issue and PR settings to 120 days 2025-11-03 14:46:03 +01:00
semantic-release b005542df3 2.43.0
Automatically generated by python-semantic-release
2025-10-30 07:58:54 +00:00
wakonig_k 13a9175ba5 feat: add pdf viewer widget 2025-10-30 08:58:11 +01:00
semantic-release 3f8e60a14f 2.42.1
Automatically generated by python-semantic-release
2025-10-28 14:48:23 +00:00
wyzula_j 6bc1c3c5f1 fix(rpc_server): raise window, even if minimized 2025-10-28 15:47:37 +01:00
semantic-release 9f91eb2e08 2.42.0
Automatically generated by python-semantic-release
2025-10-21 13:17:23 +00:00
wyzula_j 1e19092319 feat(positioner_box_2d): added properties to enable/disable vertical and horizontal controls 2025-10-21 15:16:24 +02:00
wyzula_j 96664c3923 feat(image_roi): enhance get_coordinates to include rectangle center and dimensions 2025-10-21 15:16:01 +02:00
89 changed files with 11659 additions and 4673 deletions
+4 -4
View File
@@ -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
View File
@@ -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
+165 -46
View File
@@ -1,5 +1,6 @@
# BEC Widgets
![banner_opti](https://github.com/user-attachments/assets/44e483be-3f0d-4eb0-bd98-613157456b81)
# BEC Widgets
[![CI](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml/badge.svg)](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
@@ -10,72 +11,190 @@
[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/bec-project/bec_widgets/graph/badge.svg?token=0Z9IQRJKMY)](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`: dragdock, 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 zeroglue 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.
![dock_area_example](https://github.com/user-attachments/assets/219a2806-19a8-4a07-9734-b7b554850833)
### 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 autoconnect to BEC/Redis on startup, so your UI is operational immediately.
![designer_opti](https://github.com/user-attachments/assets/fed4843c-1cce-438a-b41f-6636fa5e1545)
### 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.
![rpc_opti](https://github.com/user-attachments/assets/666be7fb-9a0d-44c2-8d44-2f9d1dae4497)
### 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 daytoday 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/)
+28
View File
@@ -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)
+2 -4
View File
@@ -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
@@ -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()
@@ -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())
@@ -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
View File
@@ -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 ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan 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.
+2 -1
View File
@@ -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/")
+93
View File
@@ -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())
+3 -1
View File
@@ -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):
+14 -15
View File
@@ -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
+52 -8
View File
@@ -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)
+39 -10
View File
@@ -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:
+13 -11
View File
@@ -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
@@ -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 humanreadable 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
@@ -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.",
},
}
@@ -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_())
@@ -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),
}
@@ -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
@@ -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_())
@@ -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
@@ -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_())
+391 -102
View File
@@ -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
+2 -37
View File
@@ -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",
+117 -68
View File
@@ -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",
+78
View File
@@ -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:
"""
+19 -4
View File
@@ -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):
+55 -64
View File
@@ -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 ydata (and optional xdata) are fetched from that historical scan. Such curves are
never cleared by livescan 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 1100000
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:
```
````
+9
View File
@@ -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
View File
@@ -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",
+4
View File
@@ -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
+579 -142
View File
@@ -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
+236 -3
View File
@@ -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
+1 -1
View File
@@ -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]
+1 -42
View File
@@ -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 scanprogress 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
# Precondition: 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
+60 -5
View File
@@ -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)
+372
View File
@@ -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)))
+98 -111
View File
@@ -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",
)
},
)
+128
View File
@@ -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
+37
View File
@@ -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,
+2 -2
View File
@@ -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