mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-11 03:00:54 +02:00
Compare commits
6 Commits
fix/rpc-se
...
v3.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14a6b04b11 | ||
| 4c9d7fddce | |||
|
|
39ecb89196 | ||
| 974f25997d | |||
| e061fa31a9 | |||
| 718f99527c |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,6 +1,28 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.2.2 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image**: Disconnecting of 2d monitor
|
||||
([`4c9d7fd`](https://github.com/bec-project/bec_widgets/commit/4c9d7fddce7aa5b7f13a00ac332bd54b301e3c28))
|
||||
|
||||
|
||||
## v3.2.1 (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **e2e**: Bec dock rpc fixed synchronization
|
||||
([`e061fa3`](https://github.com/bec-project/bec_widgets/commit/e061fa31a9a5e5c00e44337d7cc52c51d8e259b5))
|
||||
|
||||
- **e2e**: Bec shell excluded from e2e testing
|
||||
([`974f259`](https://github.com/bec-project/bec_widgets/commit/974f25997d68d13ff1063026f9e5c4c8dd4d49f3))
|
||||
|
||||
- **e2e**: Timeout for maybe_remove_dock_area
|
||||
([`718f995`](https://github.com/bec-project/bec_widgets/commit/718f99527c3bebb96845d3305aba69434eb83f77))
|
||||
|
||||
|
||||
## v3.2.0 (2026-03-11)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -270,6 +270,16 @@ class Image(ImageBase):
|
||||
return
|
||||
|
||||
old_device = self._config.device
|
||||
old_signal = self._config.signal
|
||||
old_config = self.subscriptions["main"]
|
||||
if old_device and old_signal and old_device != value:
|
||||
self._disconnect_monitor_subscription(
|
||||
device=old_device,
|
||||
signal=old_signal,
|
||||
source=old_config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=old_config.async_signal_name,
|
||||
)
|
||||
self._config.device = value
|
||||
|
||||
# If we have a signal, reconnect with the new device
|
||||
@@ -325,6 +335,16 @@ class Image(ImageBase):
|
||||
self._set_connection_status("disconnected")
|
||||
return
|
||||
|
||||
old_signal = self._config.signal
|
||||
old_config = self.subscriptions["main"]
|
||||
if self._config.device and old_signal and old_signal != value:
|
||||
self._disconnect_monitor_subscription(
|
||||
device=self._config.device,
|
||||
signal=old_signal,
|
||||
source=old_config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=old_config.async_signal_name,
|
||||
)
|
||||
self._config.signal = value
|
||||
|
||||
# If we have a device, try to connect
|
||||
@@ -447,6 +467,61 @@ class Image(ImageBase):
|
||||
)
|
||||
self._autorange_on_next_update = True
|
||||
|
||||
def _disconnect_monitor_subscription(
|
||||
self,
|
||||
*,
|
||||
device: str,
|
||||
signal: str,
|
||||
source: Literal["device_monitor_1d", "device_monitor_2d"] | None,
|
||||
async_update: bool,
|
||||
async_signal_name: str | None,
|
||||
) -> None:
|
||||
if not device or not signal:
|
||||
return
|
||||
|
||||
if async_update:
|
||||
async_signal_name = async_signal_name or signal
|
||||
ids_to_check = [self.scan_id, self.old_scan_id]
|
||||
|
||||
if source == "device_monitor_1d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_async_signal(scan_id, device, async_signal_name),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}"
|
||||
)
|
||||
elif source == "device_monitor_2d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_async_signal(scan_id, device, async_signal_name),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}"
|
||||
)
|
||||
return
|
||||
|
||||
if source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d, MessageEndpoints.device_preview(device, signal)
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 1d update Device Name:{device}, Device Entry:{signal}"
|
||||
)
|
||||
elif source == "device_monitor_2d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d, MessageEndpoints.device_preview(device, signal)
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 2d update Device Name:{device}, Device Entry:{signal}"
|
||||
)
|
||||
|
||||
def _disconnect_current_monitor(self):
|
||||
"""
|
||||
Internal method to disconnect the current monitor subscriptions.
|
||||
@@ -455,55 +530,13 @@ class Image(ImageBase):
|
||||
return
|
||||
|
||||
config = self.subscriptions["main"]
|
||||
|
||||
if self.async_update:
|
||||
async_signal_name = config.async_signal_name or self._config.signal
|
||||
ids_to_check = [self.scan_id, self.old_scan_id]
|
||||
|
||||
if config.source == "device_monitor_1d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, self._config.device, async_signal_name
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device},Device Entry:{async_signal_name}"
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, self._config.device, async_signal_name
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device},Device Entry:{async_signal_name}"
|
||||
)
|
||||
|
||||
else:
|
||||
if config.source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_preview(self._config.device, self._config.signal),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 1d update Device Name:{self._config.device}, Device Entry:{self._config.signal}"
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_preview(self._config.device, self._config.signal),
|
||||
)
|
||||
logger.info(
|
||||
f"Disconnecting preview 2d update Device Name:{self._config.device}, Device Entry:{self._config.signal}"
|
||||
)
|
||||
self._disconnect_monitor_subscription(
|
||||
device=self._config.device,
|
||||
signal=self._config.signal,
|
||||
source=config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=config.async_signal_name,
|
||||
)
|
||||
|
||||
# Reset async state
|
||||
self.async_update = False
|
||||
@@ -860,45 +893,19 @@ class Image(ImageBase):
|
||||
logger.warning("Cannot disconnect monitor without both device and signal")
|
||||
return
|
||||
|
||||
if self.async_update:
|
||||
async_signal_name = config.async_signal_name or target_entry
|
||||
ids_to_check = [self.scan_id, self.old_scan_id]
|
||||
if config.source == "device_monitor_1d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, target_device, async_signal_name
|
||||
),
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
for scan_id in ids_to_check:
|
||||
if scan_id is None:
|
||||
continue
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_async_signal(
|
||||
scan_id, target_device, async_signal_name
|
||||
),
|
||||
)
|
||||
else:
|
||||
if config.source == "device_monitor_1d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_1d,
|
||||
MessageEndpoints.device_preview(target_device, target_entry),
|
||||
)
|
||||
elif config.source == "device_monitor_2d":
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_image_update_2d,
|
||||
MessageEndpoints.device_preview(target_device, target_entry),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}"
|
||||
)
|
||||
return
|
||||
if config.source not in {"device_monitor_1d", "device_monitor_2d"}:
|
||||
logger.warning(
|
||||
f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}"
|
||||
)
|
||||
return
|
||||
|
||||
self._disconnect_monitor_subscription(
|
||||
device=target_device,
|
||||
signal=target_entry,
|
||||
source=config.source,
|
||||
async_update=self.async_update,
|
||||
async_signal_name=config.async_signal_name,
|
||||
)
|
||||
|
||||
self.subscriptions["main"].async_signal_name = None
|
||||
self.async_update = False
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "3.2.0"
|
||||
version = "3.2.2"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
|
||||
@@ -75,6 +75,13 @@ def test_dock_manipulations_e2e(qtbot, connected_client_gui_obj):
|
||||
w1 = dock_area.new("Waveform")
|
||||
w2 = dock_area.new("Waveform")
|
||||
|
||||
qtbot.waitUntil(
|
||||
lambda: all(
|
||||
gui_id in gui._server_registry for gui_id in [w0._gui_id, w1._gui_id, w2._gui_id]
|
||||
),
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
assert hasattr(gui.bec, "Waveform")
|
||||
assert hasattr(gui.bec, "Waveform_0")
|
||||
assert hasattr(gui.bec, "Waveform_1")
|
||||
@@ -126,6 +133,7 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
|
||||
|
||||
xw = gui.new("X")
|
||||
xw.delete_all()
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
|
||||
assert xw.__class__.__name__ == "RPCReference"
|
||||
assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea"
|
||||
assert len(gui.windows) == 2
|
||||
@@ -145,12 +153,15 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
|
||||
|
||||
qtbot.waitUntil(wait_for_gui_started, timeout=3000)
|
||||
# gui.windows should have bec with gui_id 'bec'
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
|
||||
assert len(gui.windows) == 1
|
||||
|
||||
# communication should work, main dock area should have same id and be visible
|
||||
|
||||
yw = gui.new("Y")
|
||||
yw.delete_all()
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
|
||||
assert len(gui.windows) == 2
|
||||
yw.remove()
|
||||
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
|
||||
assert len(gui.windows) == 1 # only bec is left
|
||||
|
||||
@@ -89,8 +89,8 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
|
||||
# Skip private attributes
|
||||
if object_name.startswith("_"):
|
||||
continue
|
||||
# Skip VSCode widget as Code server is not available in the Docker image
|
||||
if object_name == "VSCodeEditor":
|
||||
# Skip BECShell as ttyd is not installed
|
||||
if object_name == "BECShell":
|
||||
continue
|
||||
|
||||
# Skip WebConsole as ttyd is not installed
|
||||
|
||||
@@ -128,13 +128,9 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand
|
||||
random_int = random_int_gen.randint(0, 100)
|
||||
if random_int >= 50:
|
||||
# Needed, reference gets deleted in the gui
|
||||
name = gui.dock_area.object_name
|
||||
gui_id = gui.dock_area._gui_id
|
||||
gui.dock_area.delete_all() # start fresh
|
||||
gui.delete("dock_area")
|
||||
wait_for_namespace_change(
|
||||
qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False
|
||||
)
|
||||
qtbot.waitUntil(lambda: hasattr(gui, "dock_area") is False, timeout=5000)
|
||||
|
||||
|
||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||
|
||||
@@ -197,6 +197,163 @@ def test_image_setup_preview_signal_2d(qtbot, mocked_client):
|
||||
np.testing.assert_array_equal(view.main_image.image, test_data)
|
||||
|
||||
|
||||
def test_switching_device_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
|
||||
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=2)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
view.image(device="eiger", signal="img")
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
|
||||
view.device = "waveform1d"
|
||||
|
||||
assert MessageEndpoints.device_preview("eiger", "img") in disconnected
|
||||
assert MessageEndpoints.device_preview("waveform1d", "img") in connected
|
||||
|
||||
|
||||
def test_switching_device_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Verify that switching device while async_update=True disconnects device_async_signal
|
||||
endpoints for both scan_id and old_scan_id on the old device before reconnecting to
|
||||
the new device.
|
||||
"""
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
|
||||
)
|
||||
_set_signal_config(
|
||||
mocked_client, "waveform1d", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
|
||||
)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
view.image(device="eiger", signal="img")
|
||||
assert view.async_update is True
|
||||
assert view.subscriptions["main"].async_signal_name == "async_obj"
|
||||
|
||||
view.scan_id = "scan_current"
|
||||
view.old_scan_id = "scan_previous"
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
|
||||
view.device = "waveform1d"
|
||||
|
||||
# Both scan_id and old_scan_id endpoints for the old device must be disconnected
|
||||
assert (
|
||||
MessageEndpoints.device_async_signal("scan_current", "eiger", "async_obj") in disconnected
|
||||
)
|
||||
assert (
|
||||
MessageEndpoints.device_async_signal("scan_previous", "eiger", "async_obj") in disconnected
|
||||
)
|
||||
# The new device's async endpoint for the current scan must be connected
|
||||
assert (
|
||||
MessageEndpoints.device_async_signal("scan_current", "waveform1d", "async_obj") in connected
|
||||
)
|
||||
|
||||
|
||||
def test_switching_signal_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "eiger", "img_a", signal_class="PreviewSignal", ndim=2)
|
||||
_set_signal_config(mocked_client, "eiger", "img_b", signal_class="PreviewSignal", ndim=2)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
view.image(device="eiger", signal="img_a")
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
|
||||
view.signal = "img_b"
|
||||
|
||||
assert MessageEndpoints.device_preview("eiger", "img_a") in disconnected
|
||||
assert MessageEndpoints.device_preview("eiger", "img_b") in connected
|
||||
|
||||
|
||||
def test_switching_signal_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
When the current monitor is an async signal, switching to a different signal must
|
||||
disconnect the previous async endpoint (based on scan_id/async_signal_name) before
|
||||
reconnecting with the new signal's async endpoint.
|
||||
"""
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img_a", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_a"
|
||||
)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img_b", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_b"
|
||||
)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
# Connect to img_a as an async signal; scan_id is None so no actual subscription is made
|
||||
view.image(device="eiger", signal="img_a")
|
||||
assert view.async_update is True
|
||||
assert view.subscriptions["main"].async_signal_name == "async_obj_a"
|
||||
assert view.subscriptions["main"].source == "device_monitor_2d"
|
||||
|
||||
# Simulate an active scan so that the async endpoint is real
|
||||
view.scan_id = "scan_123"
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
|
||||
# Switch to a different signal
|
||||
view.signal = "img_b"
|
||||
|
||||
# The previous async endpoint for img_a must have been disconnected
|
||||
expected_disconnect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_a")
|
||||
assert expected_disconnect in disconnected
|
||||
|
||||
# The new async endpoint for img_b must have been connected
|
||||
expected_connect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_b")
|
||||
assert expected_connect in connected
|
||||
|
||||
|
||||
def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Preview/async combobox should omit 0‑D signals.
|
||||
|
||||
Reference in New Issue
Block a user