From 7eb2ce5c1d2ff64ec4e80d3dce77915e730746f8 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 18 Aug 2025 14:55:18 +0200 Subject: [PATCH] feat: available resources basic structure --- .../available_device_resources/__init__.py | 3 + .../available_device_resources.py | 61 +++++++ .../available_device_resources_ui.py | 27 +++ .../device_resource_backend.py | 143 +++++++++++++++ .../device_tag_group.py | 163 ++++++++++++++++++ .../device_tag_group_item_ui.py | 135 +++++++++++++++ .../test_available_device_resources.py | 78 +++++++++ 7 files changed, 610 insertions(+) create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group_item_ui.py create mode 100644 tests/unit_tests/test_available_device_resources.py diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py new file mode 100644 index 00000000..83d4d4d0 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py @@ -0,0 +1,3 @@ +from .available_device_resources import AvailableDeviceResources + +__all__ = ["AvailableDeviceResources"] diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py new file mode 100644 index 00000000..6c650ac6 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -0,0 +1,61 @@ +from random import randint +from typing import Iterable + +from qtpy.QtCore import QSize +from qtpy.QtWidgets import QListWidgetItem, QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources_ui import ( + Ui_availableDeviceResources, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, + get_backend, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_tag_group import ( + DeviceTagGroup, +) + + +class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources): + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + self._backend = get_backend() + self._items: dict[str, tuple[QListWidgetItem, DeviceTagGroup]] = {} + self.refresh_full_list() + + def refresh_full_list(self): + self.tag_groups_list.clear() + self._items = {} + for tag_group, devices in self._backend.tag_groups.items(): + item = QListWidgetItem(self.tag_groups_list) + tag_group_widget = DeviceTagGroup(self.tag_groups_list, tag_group, devices) + self.tag_groups_list.setItemWidget(item, tag_group_widget) + self.tag_groups_list.addItem(item) + self._items[tag_group] = (item, tag_group_widget) + item.setSizeHint(QSize(tag_group_widget.width(), tag_group_widget.height())) + + def set_devices_state(self, devices: Iterable[HashableDevice], included: bool): + for _, tag_group in self._items.values(): + for device in devices: + tag_group.set_item_state(hash(device), included) + + def resizeEvent(self, event): + super().resizeEvent(event) + for list_item, tag_group_widget in self._items.values(): + list_item.setSizeHint(tag_group_widget.sizeHint()) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = AvailableDeviceResources() + widget.set_devices_state( + list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True + ) + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py new file mode 100644 index 00000000..6b14659f --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -0,0 +1,27 @@ +from qtpy.QtCore import QMetaObject, Qt +from qtpy.QtWidgets import QAbstractItemView, QListView, QListWidget, QVBoxLayout + + +class Ui_availableDeviceResources(object): + def setupUi(self, availableDeviceResources): + if not availableDeviceResources.objectName(): + availableDeviceResources.setObjectName("availableDeviceResources") + self.verticalLayout = QVBoxLayout(availableDeviceResources) + self.verticalLayout.setObjectName("verticalLayout") + self.tag_groups_list = QListWidget(availableDeviceResources) + self.tag_groups_list.setObjectName("tag_groups_list") + self.tag_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.tag_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.tag_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.tag_groups_list.setMovement(QListView.Movement.Static) + self.tag_groups_list.setSpacing(2) + self.tag_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly) + self.tag_groups_list.setDragEnabled(True) + self.tag_groups_list.setAcceptDrops(False) + self.tag_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + availableDeviceResources.setMinimumWidth(250) + availableDeviceResources.resize(250, availableDeviceResources.height()) + + self.verticalLayout.addWidget(self.tag_groups_list) + + QMetaObject.connectSlotsByName(availableDeviceResources) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py new file mode 100644 index 00000000..1e8854c8 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import operator +from functools import reduce +from glob import glob +from pathlib import Path +from textwrap import dedent +from typing import AbstractSet, Protocol + +from bec_lib.atlas_models import Device +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path +from pydantic import model_validator + + +class HashableDevice(Device): + source_files: set[str] = set() + names: set[str] = set() + + @model_validator(mode="after") + def add_name(self) -> HashableDevice: + self.names.add(self.name) + return self + + def as_normal_device(self): + return Device.model_validate(self) + + def __hash__(self) -> int: + config_values = sorted( + (str(kv) for kv in self.deviceConfig.items()) if self.deviceConfig else [] + ) + return (reduce(operator.add, (self.name, self.deviceClass, *config_values))).__hash__() + + def __eq__(self, value: object) -> bool: + if not isinstance(value, self.__class__): + return False + if hash(self) == hash(value): + return True + return False + + def rich_text(self) -> str: + return dedent( + f""" +

{self.name}:

+ + + + + +
description: {self.description}
config: {self.deviceConfig}
enabled: {self.enabled}
read only: {self.readOnly}
+ """ + ) + + def add_sources(self, other: HashableDevice): + self.source_files.update(other.source_files) + + def add_tags(self, other: HashableDevice): + self.deviceTags.update(other.deviceTags) + + def add_names(self, other: HashableDevice): + self.names.update(other.names) + + +class _HashableDeviceSet(set): + def __or__(self, value: AbstractSet) -> _HashableDeviceSet: + for item in self: + if item in value: + for other_item in value: + if other_item == item: + item.add_sources(other_item) + item.add_tags(other_item) + item.add_names(other_item) + for other_item in value: + if other_item not in self: + self.add(other_item) + return self + + +class DeviceResourceBackend(Protocol): + @property + def tag_groups(self) -> dict[str, set[HashableDevice]]: + """A dictionary of all availble devices separated by tag groups. The same device may + appear more than once (in different groups).""" + ... + + @property + def all_devices(self) -> set[HashableDevice]: + """A set of all availble devices. The same device may not appear more than once.""" + ... + + def tags(self) -> set[str]: + """Returns a set of all the tags in all available devices.""" + ... + + def tag_group(self, tag: str) -> set[HashableDevice]: + """Returns a set of the devices in the tag group with the given key.""" + ... + + +class _ConfigFileBackend(DeviceResourceBackend): + def __init__(self) -> None: + self._raw_device_set: set[HashableDevice] = self._get_config_from_files( + Path(plugin_repo_path()) / plugin_package_name() / "device_configs/" + ) + self._tag_groups = self._get_tag_groups() + + def _get_config_from_files(self, dir: Path): + files = glob("*.yaml", root_dir=dir, recursive=True) + + def devices_from_file(file: str): + data = yaml_load(str(dir / file)) + return set( + HashableDevice.model_validate( + dev | {"name": name, "source_files": {str(dir / file)}} + ) + for name, dev in data.items() + ) + + return reduce(operator.or_, map(devices_from_file, files)) + + def _get_tag_groups(self) -> dict[str, set[HashableDevice]]: + return { + tag: set(filter(lambda dev: tag in dev.deviceTags, self._raw_device_set)) + for tag in self.tags() + } + + @property + def tag_groups(self): + return self._tag_groups + + @property + def all_devices(self): + return self._raw_device_set + + def tags(self) -> set[str]: + return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set)) + + def tag_group(self, tag: str) -> set[HashableDevice]: + return self.tag_groups[tag] + + +def get_backend() -> DeviceResourceBackend: + return _ConfigFileBackend() diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group.py new file mode 100644 index 00000000..389f13e9 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group.py @@ -0,0 +1,163 @@ +from textwrap import dedent +from typing import Callable, NamedTuple + +from bec_qthemes import material_icon +from qtpy.QtCore import QSize +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget + +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_tag_group_item_ui import ( + Ui_DeviceTagGroup, +) + +DEVICE_HASH_ROLE = 101 + + +class _DeviceEntryWidget(QFrame): + _grid_size = QSize(120, 80) + + def __init__(self, device_spec: HashableDevice, parent=None, **kwargs): + super().__init__(parent, **kwargs) + self._device_spec = device_spec + self.included: bool = False + + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + + self._layout = QVBoxLayout() + self._layout.setContentsMargins(5, 5, 5, 5) + self.setLayout(self._layout) + self.setMinimumSize(self._grid_size) + + self.setup_title_layout(device_spec) + + self.setToolTip(device_spec.rich_text()) + + self.details = QLabel(f"Tags:\n{', '.join(device_spec.deviceTags)}") + self.details.setStyleSheet("QLabel { font-size: 8pt; }") + self.details.setWordWrap(True) + self._layout.addWidget(self.details) + + def setup_title_layout(self, device_spec: HashableDevice): + self._title_layout = QHBoxLayout() + self._title_container = QWidget(parent=self) + self._title_container.setLayout(self._title_layout) + + self.title = QLabel(device_spec.name) + self.title.setToolTip(device_spec.name) + self.title.setStyleSheet(self.title_style("#FF0000")) + self._title_layout.addWidget(self.title) + + self._title_layout.addStretch(1) + + self._warning_label = QLabel() + self._title_layout.addWidget(self._warning_label) + + self._layout.addWidget(self._title_container) + + def check_and_display_warning(self): + if len(self._device_spec.names) == 1 and len(self._device_spec.source_files) == 1: + self._warning_label.setText("") + self._warning_label.setToolTip("") + else: + self._warning_label.setPixmap(material_icon("warning", color="#FFAA00")) + self._warning_label.setToolTip( + dedent( + f""" + {f"Device has multiple names! Please check! \n names: {self._device_spec.names}" if len(self._device_spec.names)>1 else ""} + {f"Device found in multiple source files! Please check! \n files: {self._device_spec.names}" if len(self._device_spec.names)>1 else ""} + """ + ) + ) + + @property + def device_hash(self): + return hash(self._device_spec) + + def title_style(self, color: str) -> str: + return f"QLabel {{ color: {color}; font-weight: bold; font-size: 10pt; }}" + + def setTitle(self, text: str): + self.title.setText(text) + + def set_included(self, included: bool): + self.included = included + self.title.setStyleSheet(self.title_style("#00FF00" if included else "#FF0000")) + + +class _DeviceEntry(NamedTuple): + list_item: QListWidgetItem + widget: _DeviceEntryWidget + + +class DeviceTagGroup(QWidget, Ui_DeviceTagGroup): + def __init__( + self, parent=None, name: str = "TagGroupTitle", data: set[HashableDevice] = set(), **kwargs + ): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + self.device_list.setGridSize(_DeviceEntryWidget._grid_size) + self.title.setText(name) + self._devices: dict[str, _DeviceEntry] = {} + for device in data: + self._add_item(device) + self.device_list.sortItems() + + def _add_item(self, device: HashableDevice): + item = QListWidgetItem(self.device_list) + widget = _DeviceEntryWidget(device, self) + item.setSizeHint(QSize(widget.width(), widget.height())) + self.device_list.setItemWidget(item, widget) + self.device_list.addItem(item) + self._devices[device.name] = _DeviceEntry(item, widget) + + def set_item_state(self, /, device_hash: int, included: bool): + for dev in self._devices.values(): + if dev.widget.device_hash == device_hash: + dev.widget.set_included(included) + self._update_num_included() + + def _update_num_included(self): + n_included = sum(int(dev.widget.included) for dev in self._devices.values()) + if n_included == 0: + color = "#FF0000" + elif n_included == len(self._devices): + color = "#00FF00" + else: + color = "#FFAA00" + self.n_included.setText(f"{n_included} / {len(self._devices)}") + self.n_included.setStyleSheet(f"QLabel {{ color: {color}; }}") + + def resizeEvent(self, event): + super().resizeEvent(event) + self.setMinimumHeight(self.sizeHint().height()) + self.setMaximumHeight(self.sizeHint().height()) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}: {self.title.text()}" + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = DeviceTagGroup(name="Tag group 1") + for item in [ + HashableDevice( + **{ + "name": f"test_device_{i}", + "deviceClass": "TestDeviceClass", + "readoutPriority": "baseline", + "enabled": True, + } + ) + for i in range(5) + ]: + widget._add_item(item) + widget._update_num_included() + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group_item_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group_item_ui.py new file mode 100644 index 00000000..0ffa1cba --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_tag_group_item_ui.py @@ -0,0 +1,135 @@ +import math +from functools import partial + +from bec_qthemes import material_icon +from qtpy.QtCore import QMetaObject, QSize, Qt +from qtpy.QtWidgets import ( + QAbstractItemView, + QFrame, + QHBoxLayout, + QLabel, + QListView, + QListWidget, + QSizePolicy, + QSpacerItem, + QToolButton, + QVBoxLayout, +) + + +class AutoHeightListWidget(QListWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setViewMode(QListView.ViewMode.IconMode) + self.setResizeMode(QListView.ResizeMode.Adjust) + self.setWrapping(True) + self.setUniformItemSizes(True) + self.setMovement(QListView.Movement.Static) + self.setAcceptDrops(False) + self.setDragEnabled(True) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setSpacing(5) + + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.setMinimumHeight(self._calcSize().height()) + self.setMaximumHeight(self._calcSize().height()) + + def sizeHint(self): + return self._calcSize() + + def minimumSizeHint(self): + return self._calcSize() + + def _calcSize(self): + if self.count() == 0: + return super().sizeHint() + + grid = self.gridSize() + if not grid.isValid(): + grid = QSize(100, 100) # fallback + + items_per_row = max(1, self.viewport().width() // grid.width()) + rows = math.ceil(self.count() / items_per_row) + + height = rows * grid.height() + 2 * self.frameWidth() + return QSize(self.viewport().width(), height) + + +class Ui_DeviceTagGroup(object): + def setupUi(self, DeviceTagGroup): + if not DeviceTagGroup.objectName(): + DeviceTagGroup.setObjectName("DeviceTagGroup") + DeviceTagGroup.setMinimumWidth(150) + self.verticalLayout = QVBoxLayout(DeviceTagGroup) + self.verticalLayout.setObjectName("verticalLayout") + self.frame = QFrame(DeviceTagGroup) + self.frame.setObjectName("frame") + self.frame.setFrameShape(QFrame.Shape.StyledPanel) + self.frame.setFrameShadow(QFrame.Shadow.Raised) + self.verticalLayout_2 = QVBoxLayout(self.frame) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + + self.title = QLabel(self.frame) + self.title.setObjectName("title") + self.horizontalLayout.addWidget(self.title) + + self.n_included = QLabel(self.frame, text="...") + self.n_included.setObjectName("n_included") + self.horizontalLayout.addWidget(self.n_included) + + self.horizontalSpacer = QSpacerItem( + 40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum + ) + self.horizontalLayout.addItem(self.horizontalSpacer) + + self.delete_tag_button = QToolButton(self.frame) + self.delete_tag_button.setObjectName("delete_tag_button") + self.horizontalLayout.addWidget(self.delete_tag_button) + + self.remove_from_composition_button = QToolButton(self.frame) + self.remove_from_composition_button.setObjectName("remove_from_composition_button") + self.horizontalLayout.addWidget(self.remove_from_composition_button) + + self.add_to_composition_button = QToolButton(self.frame) + self.add_to_composition_button.setObjectName("add_to_composition_button") + self.horizontalLayout.addWidget(self.add_to_composition_button) + + self.remove_all_button = QToolButton(self.frame) + self.remove_all_button.setObjectName("remove_all_from_composition_button") + self.horizontalLayout.addWidget(self.remove_all_button) + + self.add_all_button = QToolButton(self.frame) + self.add_all_button.setObjectName("add_all_to_composition_button") + self.horizontalLayout.addWidget(self.add_all_button) + + self.verticalLayout_2.addLayout(self.horizontalLayout) + + self.device_list = AutoHeightListWidget(self.frame) + self.device_list.setObjectName("device_list") + + self.verticalLayout_2.addWidget(self.device_list) + + self.verticalLayout.addWidget(self.frame) + + self.set_icons() + + QMetaObject.connectSlotsByName(DeviceTagGroup) + + def set_icons(self): + icon = partial(material_icon, size=(15, 15), convert_to_pixmap=False) + self.delete_tag_button.setIcon(icon("delete")) + self.delete_tag_button.setToolTip("Delete tag group") + self.remove_from_composition_button.setIcon(icon("remove")) + self.remove_from_composition_button.setToolTip("Remove selected from composition") + self.add_to_composition_button.setIcon(icon("add")) + self.add_to_composition_button.setToolTip("Add selected to composition") + self.remove_all_button.setIcon(icon("chips")) + self.remove_all_button.setToolTip("Remove all with this tag from composition") + self.add_all_button.setIcon(icon("add_box")) + self.add_all_button.setToolTip("Add all with this tag to composition") diff --git a/tests/unit_tests/test_available_device_resources.py b/tests/unit_tests/test_available_device_resources.py new file mode 100644 index 00000000..23b644f7 --- /dev/null +++ b/tests/unit_tests/test_available_device_resources.py @@ -0,0 +1,78 @@ +from copy import copy + +import pytest + +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, + _HashableDeviceSet, +) + +TEST_DEVICE_DICT = { + "name": "test_device", + "deviceClass": "TestDeviceClass", + "readoutPriority": "baseline", + "enabled": True, +} + + +def _test_device_dict(**kwargs): + new = copy(TEST_DEVICE_DICT) + new.update(kwargs) + return new + + +@pytest.mark.parametrize( + "kwargs_1, kwargs_2, kwargs_3, kwargs_4, n", + [ + ({}, {}, {}, {}, 1), + ({}, {}, {}, {"deviceConfig": {"a": 1}}, 1), + ({}, {}, {}, {"name": "test_device_2"}, 2), + ({}, {}, {"name": "test_device_2"}, {"deviceClass": "OtherDeviceClass"}, 3), + ], +) +def test_hashable_device_set_merges_equal(kwargs_1, kwargs_2, kwargs_3, kwargs_4, n): + item_1 = HashableDevice(**_test_device_dict(**kwargs_1)) + item_2 = HashableDevice(**_test_device_dict(**kwargs_2)) + item_3 = HashableDevice(**_test_device_dict(**kwargs_3)) + item_4 = HashableDevice(**_test_device_dict(**kwargs_4)) + + test_set = _HashableDeviceSet((item_1, item_2, item_3, item_4)) + assert len(test_set) == n + + +def test_hashable_device_set_or_adds_sources(): + item_1 = HashableDevice(**_test_device_dict(), source_files={"a", "b"}) + item_2 = HashableDevice(**_test_device_dict(), source_files={"c", "d"}) + + set_1 = _HashableDeviceSet((item_1,)) + set_2 = _HashableDeviceSet((item_2,)) + + combined = set_1 | set_2 + assert len(combined) == 1 + assert combined.pop().source_files == {"a", "b", "c", "d"} + + +def test_hashable_device_set_or_adds_tags(): + item_1 = HashableDevice( + **_test_device_dict(deviceTags={"tag1"}, deviceConfig={"param": "value"}), + source_files={"a", "b"}, + ) + item_2 = HashableDevice( + **_test_device_dict(deviceTags={"tag2"}, deviceConfig={"param": "value"}), + source_files={"c", "d"}, + ) + item_3 = HashableDevice( + **_test_device_dict(deviceTags={"tag3"}, deviceConfig={"param": "other_value"}), + source_files={"q"}, + ) + + set_1 = _HashableDeviceSet((item_1,)) + set_2 = _HashableDeviceSet((item_2,)) + set_3 = _HashableDeviceSet((item_3,)) + + combined = sorted(set_1 | set_2 | set_3, key=lambda hd: hd.deviceConfig["param"]) + assert len(combined) == 2 + assert combined[0].source_files == {"q"} + assert combined[0].deviceTags == {"tag3"} + assert combined[1].source_files == {"a", "b", "c", "d"} + assert combined[1].deviceTags == {"tag1", "tag2"}