From 5afc4c618813c96e99f7cc3251ad1d592fd4293b Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 15:49:12 +0200 Subject: [PATCH 01/19] add: dmx default value support to scene model --- src/controller/file/read.py | 6 +++ .../file/serializing/scene_serialization.py | 6 +++ src/model/scene.py | 37 +++++++++++++++++++ submodules/docs | 2 +- 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/controller/file/read.py b/src/controller/file/read.py index 6737c371..84639da8 100644 --- a/src/controller/file/read.py +++ b/src/controller/file/read.py @@ -264,6 +264,10 @@ def _parse_filter_page(element: ET.Element, parent_scene: Scene, instantiated_pa return True +def _parse_dmx_default_value(scene: Scene, child: ET.Element) -> None: + scene.insert_dmx_default_value(int(child.attrib["universe"]), int(child.attrib["channel"]), int(child.attrib["value"])) + + def _parse_scene( scene_element: ET.Element, board_configuration: BoardConfiguration, loaded_banksets: dict[str, BankSet] ) -> None: @@ -300,6 +304,8 @@ def _parse_scene( filter_pages.append(child) case "uipage": ui_page_elements.append(child) + case "dmxdefaultvalue": + _parse_dmx_default_value(scene, child) case _: logger.warning("Scene %s contains unknown element: %s", human_readable_name, child.tag) diff --git a/src/controller/file/serializing/scene_serialization.py b/src/controller/file/serializing/scene_serialization.py index 10c2ab21..cf6ae7ed 100644 --- a/src/controller/file/serializing/scene_serialization.py +++ b/src/controller/file/serializing/scene_serialization.py @@ -107,6 +107,12 @@ def generate_scene_xml_description( for ui_page in scene.ui_pages: _add_ui_page_to_element(scene_element, ui_page) + for default_value in scene.dmx_default_values: + ET.SubElement(scene_element, "dmxdefaultvalue", attrib={ + "universe": default_value.universe_id, + "channel": str(default_value.channel), + "value": str(default_value.value), + }) def _create_scene_element(scene: Scene, parent: ET.Element) -> ET.Element: diff --git a/src/model/scene.py b/src/model/scene.py index b49ce750..a583eb72 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -2,8 +2,11 @@ from __future__ import annotations +from collections import namedtuple from typing import TYPE_CHECKING, override +from .universe import Universe + if TYPE_CHECKING: from .board_configuration import BoardConfiguration from .control_desk import BankSet @@ -62,6 +65,9 @@ def copy(self, new_scene: Scene = None) -> FilterPage: return new_fp +DmxDefaultValue = namedtuple("DmxDefaultValue", ['universe_id', 'channel', 'value']) + + class Scene: """Scene for a show file.""" @@ -75,6 +81,7 @@ def __init__(self, scene_id: int, human_readable_name: str, board_configuration: self._filter_pages: list[FilterPage] = [] self._associated_bankset: BankSet | None = None self._ui_pages: list[UIPage] = [] + self._dmx_default_values: list[DmxDefaultValue] = [] @property def scene_id(self) -> int: @@ -116,6 +123,34 @@ def pages(self) -> list[FilterPage]: self._filter_pages.append(default_page) return self._filter_pages + @property + def dmx_default_values(self) -> list[DmxDefaultValue]: + """Get the list of default values to be applied on scene switch.""" + return self._dmx_default_values.copy() + + def insert_dmx_default_value(self, universe: Universe | int, channel: int, value: int) -> None: + """Add a new default value to the scene. + + Existing values will be updated. + + Args: + universe: target universe or its ID. + channel: target channel. + value: value to set on scene entry. + + """ + if isinstance(universe, Universe): + universe_id = universe.id + else: + universe_id = universe + value_to_remove = None + for existing_value in self._dmx_default_values: + if existing_value.universe_id == universe_id and existing_value.channel == channel: + value_to_remove = existing_value + if value_to_remove is not None: + self._dmx_default_values.remove(value_to_remove) + self._dmx_default_values.append(DmxDefaultValue(universe_id, channel, value)) + def insert_filterpage(self, fp: FilterPage) -> None: """Add a filterpage to the scene.""" self._filter_pages.append(fp) @@ -184,6 +219,8 @@ def copy(self, existing_scenes: list[Scene]) -> Scene: scene.linked_bankset = self._associated_bankset.copy() for page in self._ui_pages: scene._ui_pages.append(page.copy(scene)) + for ddv in self._dmx_default_values: + scene._dmx_default_values.append(ddv) return scene def get_filter_by_id(self, fid: str) -> Filter | None: diff --git a/submodules/docs b/submodules/docs index 9726b4c9..9fe92bdf 160000 --- a/submodules/docs +++ b/submodules/docs @@ -1 +1 @@ -Subproject commit 9726b4c9196c1ebca838622dc8ec8eb4709674bd +Subproject commit 9fe92bdfcd76a45545b70d11c1aef9733d5e8e7a From 427051526bbbdb98680f5e0a7cca05b43a83f390 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 16:08:22 +0200 Subject: [PATCH 02/19] fix: ruff issues --- src/controller/file/read.py | 6 +++++- src/model/scene.py | 14 ++++++++------ src/view/show_mode/editor/nodes/impl/adapters.py | 1 - src/view/utility_widgets/asset_selection_widget.py | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/controller/file/read.py b/src/controller/file/read.py index c8428a27..cf45a06d 100644 --- a/src/controller/file/read.py +++ b/src/controller/file/read.py @@ -266,7 +266,11 @@ def _parse_filter_page(element: ET.Element, parent_scene: Scene, instantiated_pa def _parse_dmx_default_value(scene: Scene, child: ET.Element) -> None: - scene.insert_dmx_default_value(int(child.attrib["universe"]), int(child.attrib["channel"]), int(child.attrib["value"])) + scene.insert_dmx_default_value( + int(child.attrib["universe"]), + int(child.attrib["channel"]), + int(child.attrib["value"]) + ) def _parse_scene( diff --git a/src/model/scene.py b/src/model/scene.py index a583eb72..5f5c357f 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import namedtuple -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, NamedTuple, override from .universe import Universe @@ -65,7 +65,12 @@ def copy(self, new_scene: Scene = None) -> FilterPage: return new_fp -DmxDefaultValue = namedtuple("DmxDefaultValue", ['universe_id', 'channel', 'value']) +class DmxDefaultValue(NamedTuple): + """Contains a single default entry to be applied on scene activation by fish.""" + + universe_id: int + channel: int + value: int class Scene: @@ -139,10 +144,7 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, value value: value to set on scene entry. """ - if isinstance(universe, Universe): - universe_id = universe.id - else: - universe_id = universe + universe_id = universe.id if isinstance(universe, Universe) else universe value_to_remove = None for existing_value in self._dmx_default_values: if existing_value.universe_id == universe_id and existing_value.channel == channel: diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index a861801d..ff2eeda0 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -3,7 +3,6 @@ from model import DataType, Scene from model.filter import Filter, FilterTypeEnumeration, VirtualFilter -from model.filter import Filter, FilterTypeEnumeration from view.show_mode.editor.filter_settings_item import FilterSettingsItem from view.show_mode.editor.nodes.base.filternode import FilterNode diff --git a/src/view/utility_widgets/asset_selection_widget.py b/src/view/utility_widgets/asset_selection_widget.py index 2d267e11..8296d9b0 100644 --- a/src/view/utility_widgets/asset_selection_widget.py +++ b/src/view/utility_widgets/asset_selection_widget.py @@ -201,7 +201,7 @@ def __init__(self, parent: QWidget | None = None, allowed_types: list[MediaType] self.setLayout(layout) self._update_filter() - self._asset_view.selectionModel().selectionChanged.connect(lambda: self.asset_selection_changed.emit()) + self._asset_view.selectionModel().selectionChanged.connect(self.asset_selection_changed.emit) def _update_filter(self, force: bool = False) -> None: selected_types: set[MediaType] = set() From 91ff1d5c3938ff1c985d72ce99812031629171bc Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 17:29:35 +0200 Subject: [PATCH 03/19] add: skeleton --- src/model/broadcaster.py | 4 ++++ .../dmx_default_value_editor.py | 19 +++++++++++++++++++ .../editor/show_browser/show_browser.py | 14 ++++++++++++-- src/view/show_mode/editor/showmanager.py | 8 ++++++++ submodules/resources | 2 +- 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py diff --git a/src/model/broadcaster.py b/src/model/broadcaster.py index 0f8fd07c..ef4bf470 100644 --- a/src/model/broadcaster.py +++ b/src/model/broadcaster.py @@ -57,6 +57,7 @@ class Broadcaster(QtCore.QObject, metaclass=QObjectSingletonMeta): scene_open_in_editor_requested: QtCore.Signal = QtCore.Signal(object) # FilterPage bankset_open_in_editor_requested: QtCore.Signal = QtCore.Signal(dict) uipage_opened_in_editor_requested: QtCore.Signal = QtCore.Signal(dict) + default_dmx_value_editor_opening_requested: QtCore.Signal = QtCore.Signal(object) delete_scene: QtCore.Signal = QtCore.Signal(object) delete_universe: QtCore.Signal = QtCore.Signal(object) device_created: QtCore.Signal = QtCore.Signal(object) # device @@ -120,6 +121,9 @@ class Broadcaster(QtCore.QObject, metaclass=QObjectSingletonMeta): dmx_from_fish: QtCore.Signal = QtCore.Signal(proto.DirectMode_pb2.dmx_output) event_sender_update: QtCore.Signal = QtCore.Signal(proto.Events_pb2.event_sender) + def __init__(self, /, parent: PySide6.QtCore.QObject | None = ..., *, objectName: str | None = ...): + super().__init__(null, parent, null, objectName) + def __new__(cls) -> Self: """Override __new__ to implement singleton behavior.""" if not hasattr(cls, "instance") or cls.instance is None: diff --git a/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py new file mode 100644 index 00000000..254c28b2 --- /dev/null +++ b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py @@ -0,0 +1,19 @@ +"""Contains DMX default value editor tab widget.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import QWidget + +if TYPE_CHECKING: + from model.scene import Scene + +class DMXDefaultValueEditorWidget(QWidget): + def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._scene: Scene = scene + + @property + def scene(self) -> Scene: + return self._scene diff --git a/src/view/show_mode/editor/show_browser/show_browser.py b/src/view/show_mode/editor/show_browser/show_browser.py index 3462f304..6368e5ce 100644 --- a/src/view/show_mode/editor/show_browser/show_browser.py +++ b/src/view/show_mode/editor/show_browser/show_browser.py @@ -41,6 +41,7 @@ class ShowBrowser: _filter_browser_tab_icon = QIcon(resource_path(os.path.join("resources", "icons", "showbrowser-filterpages.svg"))) _fader_icon = QIcon(resource_path(os.path.join("resources", "icons", "faders.svg"))) _uipage_icon = QIcon(resource_path(os.path.join("resources", "icons", "uipage.svg"))) + _dmx_default_value_tab_icon = QIcon(resource_path(os.path.join("resources", "icons", "dmx-values-default.svg"))) def __init__(self, parent: QWidget, show: BoardConfiguration, editor_tab_browser: QTabWidget) -> None: """Initialize a ShowBrowser. @@ -178,6 +179,12 @@ def add_filter_page(parent_item: AnnotatedTreeWidgetItem, fp: FilterPage) -> Non bankset_item.setIcon(0, ShowBrowser._fader_icon) bankset_item.setText(1, s.linked_bankset.description) bankset_item.annotated_data = s.linked_bankset + default_value_item = AnnotatedTreeWidgetItem(item) + default_value_item.setText(0, "Default DMX values") + default_value_item.setIcon(0, ShowBrowser._dmx_default_value_tab_icon) + default_value_item.setData(1, Qt.ItemDataRole.WhatsThisRole, "DMXDEFAULTDATA") + default_value_item.annotated_data = s + if len(s.ui_pages) < 1: s.ui_pages.append(UIPage(s)) @@ -282,7 +289,8 @@ def rename(c: ShowBrowser, scene: Scene | FilterPage, text: str) -> None: for si in items: if isinstance(si, AnnotatedTreeWidgetItem): - if isinstance(si.annotated_data, Scene): + if (isinstance(si.annotated_data, Scene) and + not si.data(1, Qt.ItemDataRole.WhatsThisRole) == "DMXDEFAULTDATA"): scene_to_rename = si.annotated_data self._input_dialog = QInputDialog(self.widget) self._input_dialog.setInputMode(QInputDialog.TextInput) @@ -316,11 +324,13 @@ def rename(c: ShowBrowser, scene: Scene | FilterPage, text: str) -> None: def _scene_item_double_clicked(self, item: AnnotatedTreeWidgetItem) -> None: if isinstance(item, AnnotatedTreeWidgetItem): data = item.annotated_data - if isinstance(data, Scene): + if isinstance(data, Scene) and not item.data(1, Qt.ItemDataRole.WhatsThisRole) == "DMXDEFAULTDATA": self._show.broadcaster.scene_open_in_editor_requested.emit(data.pages[0]) if self._selected_scene != data: self._selected_scene = data self._refresh_filter_browser() + elif isinstance(data, Scene) and item.data(1, Qt.ItemDataRole.WhatsThisRole) == "DMXDEFAULTDATA": + self._show.broadcaster.default_dmx_value_editor_opening_requested.emit(data) elif isinstance(data, FilterPage): # TODO exchange for correct loading of page self._show.broadcaster.scene_open_in_editor_requested.emit(data) diff --git a/src/view/show_mode/editor/showmanager.py b/src/view/show_mode/editor/showmanager.py index 30732810..66e3c2c2 100644 --- a/src/view/show_mode/editor/showmanager.py +++ b/src/view/show_mode/editor/showmanager.py @@ -67,6 +67,9 @@ def __init__(self, board_configuration: BoardConfiguration, bcaster: Broadcaster board_configuration.broadcaster.bankset_open_in_editor_requested.connect(self._add_bankset_tab) board_configuration.broadcaster.uipage_opened_in_editor_requested.connect(self._add_uipage_tab) board_configuration.broadcaster.delete_scene.connect(self._remove_tab) + board_configuration.broadcaster.default_dmx_value_editor_opening_requested.connect( + self._open_dmx_default_value_editor + ) def _select_scene_to_be_removed(self) -> None: scene_index, ok_button_pressed = QInputDialog.getInt(self, "Remove a scene", "Scene index (0-index)") @@ -189,3 +192,8 @@ def _remove_tab(self, scene_or_index: Scene | int) -> None: def _send_show_file(self) -> None: """Send the current board configuration as a xml file to fish""" transmit_to_fish(self._board_configuration) + + def _open_dmx_default_value_editor(self, s: Scene) -> None: + # TODO iterate over all open tabs and jump to open tab if correct was found and exit + # TODO create editor tab and add it to list + pass diff --git a/submodules/resources b/submodules/resources index 8567afa0..637a2861 160000 --- a/submodules/resources +++ b/submodules/resources @@ -1 +1 @@ -Subproject commit 8567afa061ae0a926c53b36081bf4908f91b9bb1 +Subproject commit 637a2861a89cf43d2677d62e7672054c742fba4b From 81e7154e630612a76b659bf7e0183851a0295146 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 17:57:26 +0200 Subject: [PATCH 04/19] add: tab loading and closing mechanics --- src/model/broadcaster.py | 3 --- .../dmx_default_value_editor.py | 8 ++++++- .../editor/show_browser/show_browser.py | 4 ++-- src/view/show_mode/editor/showmanager.py | 21 ++++++++++++++++--- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/model/broadcaster.py b/src/model/broadcaster.py index ef4bf470..7d8d54e7 100644 --- a/src/model/broadcaster.py +++ b/src/model/broadcaster.py @@ -121,9 +121,6 @@ class Broadcaster(QtCore.QObject, metaclass=QObjectSingletonMeta): dmx_from_fish: QtCore.Signal = QtCore.Signal(proto.DirectMode_pb2.dmx_output) event_sender_update: QtCore.Signal = QtCore.Signal(proto.Events_pb2.event_sender) - def __init__(self, /, parent: PySide6.QtCore.QObject | None = ..., *, objectName: str | None = ...): - super().__init__(null, parent, null, objectName) - def __new__(cls) -> Self: """Override __new__ to implement singleton behavior.""" if not hasattr(cls, "instance") or cls.instance is None: diff --git a/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py index 254c28b2..204fd56d 100644 --- a/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py +++ b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget if TYPE_CHECKING: from model.scene import Scene @@ -14,6 +14,12 @@ def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: super().__init__(parent) self._scene: Scene = scene + layout = QVBoxLayout() + + layout.addWidget(QLabel("DMX Default Value Tab: " + str(scene.scene_id))) + + self.setLayout(layout) + @property def scene(self) -> Scene: return self._scene diff --git a/src/view/show_mode/editor/show_browser/show_browser.py b/src/view/show_mode/editor/show_browser/show_browser.py index 6368e5ce..e0d473ca 100644 --- a/src/view/show_mode/editor/show_browser/show_browser.py +++ b/src/view/show_mode/editor/show_browser/show_browser.py @@ -290,7 +290,7 @@ def rename(c: ShowBrowser, scene: Scene | FilterPage, text: str) -> None: for si in items: if isinstance(si, AnnotatedTreeWidgetItem): if (isinstance(si.annotated_data, Scene) and - not si.data(1, Qt.ItemDataRole.WhatsThisRole) == "DMXDEFAULTDATA"): + si.data(1, Qt.ItemDataRole.WhatsThisRole) != "DMXDEFAULTDATA"): scene_to_rename = si.annotated_data self._input_dialog = QInputDialog(self.widget) self._input_dialog.setInputMode(QInputDialog.TextInput) @@ -324,7 +324,7 @@ def rename(c: ShowBrowser, scene: Scene | FilterPage, text: str) -> None: def _scene_item_double_clicked(self, item: AnnotatedTreeWidgetItem) -> None: if isinstance(item, AnnotatedTreeWidgetItem): data = item.annotated_data - if isinstance(data, Scene) and not item.data(1, Qt.ItemDataRole.WhatsThisRole) == "DMXDEFAULTDATA": + if isinstance(data, Scene) and item.data(1, Qt.ItemDataRole.WhatsThisRole) != "DMXDEFAULTDATA": self._show.broadcaster.scene_open_in_editor_requested.emit(data.pages[0]) if self._selected_scene != data: self._selected_scene = data diff --git a/src/view/show_mode/editor/showmanager.py b/src/view/show_mode/editor/showmanager.py index 66e3c2c2..98e2cdab 100644 --- a/src/view/show_mode/editor/showmanager.py +++ b/src/view/show_mode/editor/showmanager.py @@ -17,6 +17,7 @@ from .editing_utils import add_scene_to_show from .editor_tab_widgets.bankset_tab import BankSetTabWidget +from .editor_tab_widgets.dmx_default_value_editor import DMXDefaultValueEditorWidget from .show_browser.show_browser import ShowBrowser @@ -30,6 +31,7 @@ def __init__(self, board_configuration: BoardConfiguration, bcaster: Broadcaster self._opened_pages = set() self._opened_banksets = set() self._opened_uieditors = set() + self._open_dmx_value_editors = set() # Buttons to add or remove scenes from show self._open_page_tab_widget = QTabWidget(self) @@ -187,6 +189,8 @@ def _remove_tab(self, scene_or_index: Scene | int) -> None: self._opened_banksets.remove(widget.bankset) elif isinstance(widget, SceneUIPageEditorWidget): self._opened_uieditors.remove(widget.ui_page) + elif isinstance(widget, DMXDefaultValueEditorWidget): + self._open_dmx_value_editors.remove(widget.scene) self._open_page_tab_widget.removeTab(scene_or_index) def _send_show_file(self) -> None: @@ -194,6 +198,17 @@ def _send_show_file(self) -> None: transmit_to_fish(self._board_configuration) def _open_dmx_default_value_editor(self, s: Scene) -> None: - # TODO iterate over all open tabs and jump to open tab if correct was found and exit - # TODO create editor tab and add it to list - pass + if s in self._open_dmx_value_editors: + for tab_index in range(self._open_page_tab_widget.count()): + tab = self._open_page_tab_widget.widget(tab_index) + if isinstance(tab, DMXDefaultValueEditorWidget) and tab.scene == s: + self._open_page_tab_widget.setCurrentIndex(tab_index) + return + self._open_dmx_value_editors.add(s) + tab = DMXDefaultValueEditorWidget(s, self._open_page_tab_widget) + self._open_page_tab_widget.insertTab( + self._open_page_tab_widget.tabBar().count() - 1, + tab, + s.human_readable_name + "/Defaults", + ) + self._open_page_tab_widget.setCurrentWidget(tab) From 1722447dd34550c72b21822703ee684bb03dd9cc Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 23:09:55 +0200 Subject: [PATCH 05/19] add: crude editing UI --- src/model/board_configuration.py | 5 +- src/model/scene.py | 6 +- .../dmx_default_value_editor.py | 107 +++++++++++++++++- 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py index be46c962..435aad07 100644 --- a/src/model/board_configuration.py +++ b/src/model/board_configuration.py @@ -330,13 +330,14 @@ def get_fixture_by_address(self, fixture_univ: int, fixture_chan: int) -> UsedFi Args: fixture_univ: The universe of the fixture. - fixture_chan: The first channel of the fixture. + fixture_chan: A channel of the fixture. Returns: The fixture or None if no fixture was found. """ for fixture in self._fixtures.values(): - if fixture.universe_id == fixture_univ and fixture.start_index == fixture_chan: + if fixture.universe_id == fixture_univ and \ + fixture.start_index <= fixture_chan <= fixture.start_index + fixture.channel_length: return fixture return None diff --git a/src/model/scene.py b/src/model/scene.py index 5f5c357f..75ce0309 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -133,7 +133,7 @@ def dmx_default_values(self) -> list[DmxDefaultValue]: """Get the list of default values to be applied on scene switch.""" return self._dmx_default_values.copy() - def insert_dmx_default_value(self, universe: Universe | int, channel: int, value: int) -> None: + def insert_dmx_default_value(self, universe: Universe | int, channel: int, value: int) -> bool: """Add a new default value to the scene. Existing values will be updated. @@ -143,6 +143,9 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, value channel: target channel. value: value to set on scene entry. + Returns: + True if a value was updated and false if it was added. + """ universe_id = universe.id if isinstance(universe, Universe) else universe value_to_remove = None @@ -152,6 +155,7 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, value if value_to_remove is not None: self._dmx_default_values.remove(value_to_remove) self._dmx_default_values.append(DmxDefaultValue(universe_id, channel, value)) + return value_to_remove is None def insert_filterpage(self, fp: FilterPage) -> None: """Add a filterpage to the scene.""" diff --git a/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py index 204fd56d..c3919340 100644 --- a/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py +++ b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py @@ -4,22 +4,125 @@ from typing import TYPE_CHECKING -from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QPushButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, +) if TYPE_CHECKING: from model.scene import Scene + +class _ValueQueryDialog(QDialog): + def __init__(self, parent: DMXDefaultValueEditorWidget) -> None: + super().__init__(parent) + self._editor: DMXDefaultValueEditorWidget = parent + + self._universe_id_tb = QComboBox() + for univ in parent.scene.board_configuration.universes: + self._universe_id_tb.addItem(str(univ.name), univ.id) + self._channel_tb = QSpinBox() + self._channel_tb.setRange(0, 511) + self._value_tb = QSpinBox() + self._value_tb.setRange(0, 255) + + layout = QFormLayout() + layout.addRow("Universe ID", self._universe_id_tb) + layout.addRow("Channel", self._channel_tb) + layout.addRow("Value", self._value_tb) + + layout_exit = QHBoxLayout() + self._ok = QPushButton() + self._ok.setText("Enter") + _cancel = QPushButton() + _cancel.setText("Cancel") + layout_exit.addWidget(_cancel) + layout_exit.addWidget(self._ok) + _cancel.setAutoDefault(False) + self._ok.setAutoDefault(True) + self._ok.clicked.connect(self._accept) + _cancel.clicked.connect(self.close) + layout.addRow(layout_exit) + + self.setLayout(layout) + self.setWindowTitle("Add Entry") + self.setModal(True) + + def _accept(self) -> None: + univ_id = self._universe_id_tb.currentData() + channel = self._channel_tb.value() + value = self._value_tb.value() + refresh_required = self._editor.scene.insert_dmx_default_value(univ_id, channel, value) + if refresh_required: + self._editor.refresh() + else: + self._editor.add_value_entry(univ_id, channel, value) + self.close() + + class DMXDefaultValueEditorWidget(QWidget): + """Widget to edit default DMX value mappings.""" + def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: + """Initializes the widget. + + Args: + scene: The scene which default DMX values should be edited. + parent: The parent widget. + + """ super().__init__(parent) self._scene: Scene = scene layout = QVBoxLayout() - layout.addWidget(QLabel("DMX Default Value Tab: " + str(scene.scene_id))) + self._value_list_widget = QListWidget() + self._value_list_widget.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) + self.refresh() + + self._add_value_button = QPushButton("Add Default Value") + self._add_value_button.clicked.connect(self._add_value_clicked) + + layout.addWidget(QLabel(f"DMX Default Values of Scene '{scene.human_readable_name}' [{scene.scene_id}]")) + layout.addWidget(self._value_list_widget) + buttons_layout = QHBoxLayout() + buttons_layout.addStretch() + buttons_layout.addWidget(self._add_value_button) + layout.addLayout(buttons_layout) self.setLayout(layout) + def refresh(self) -> None: + """Refreshes the list view.""" + self._value_list_widget.clear() + for univ_id, channel, value in self._scene.dmx_default_values: + self.add_value_entry(univ_id, channel, value) + @property def scene(self) -> Scene: + """Get the scene in use.""" return self._scene + + def _add_value_clicked(self) -> None: + self._dialog = _ValueQueryDialog(self) + self._dialog.show() + + def add_value_entry(self, universe_id: int, channel: int, value: int) -> None: + """Add an entry to the list view.""" + item = QListWidgetItem(self._value_list_widget) + capability = "" + associated_fixture = self._scene.board_configuration.get_fixture_by_address(universe_id, channel) + if associated_fixture is not None: + capability = associated_fixture.fixture_channels[channel - associated_fixture.start_index].name + item.setText(f"{universe_id}:{channel} ({associated_fixture.name if + associated_fixture is not None else ""}.{capability}) -> {value}") From fadad978c08a03dcabee679a22ce811022ddfc2b Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 23:13:24 +0200 Subject: [PATCH 06/19] fix: inverted update bug --- src/model/scene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/model/scene.py b/src/model/scene.py index 75ce0309..444cafa6 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -155,7 +155,7 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, value if value_to_remove is not None: self._dmx_default_values.remove(value_to_remove) self._dmx_default_values.append(DmxDefaultValue(universe_id, channel, value)) - return value_to_remove is None + return value_to_remove is not None def insert_filterpage(self, fp: FilterPage) -> None: """Add a filterpage to the scene.""" From 4ce9b85b88c7b6868574bdc673a111f9ea7f6d12 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 23:18:34 +0200 Subject: [PATCH 07/19] fix: docs in showmanager.py --- src/view/show_mode/editor/showmanager.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/view/show_mode/editor/showmanager.py b/src/view/show_mode/editor/showmanager.py index 98e2cdab..edf85740 100644 --- a/src/view/show_mode/editor/showmanager.py +++ b/src/view/show_mode/editor/showmanager.py @@ -3,6 +3,7 @@ Usage (where self is a QWidget and board_configuration is a BoardConfiguration): node_editor = NodeEditor(self, board_configuration) self.addWidget(node_editor) + """ from PySide6.QtGui import QAction from PySide6.QtWidgets import QInputDialog, QSplitter, QTabBar, QTabWidget, QWidget @@ -25,6 +26,7 @@ class ShowEditorWidget(QSplitter): """Node Editor to create and manage filters.""" def __init__(self, board_configuration: BoardConfiguration, bcaster: Broadcaster, parent: QWidget) -> None: + """Initialize the widget.""" super().__init__(parent) self._broadcaster = bcaster self._board_configuration = board_configuration @@ -80,13 +82,15 @@ def _select_scene_to_be_removed(self) -> None: @property def toolbar(self) -> list[QAction]: - """toolbar for node_mode""" + """toolbar for node_mode.""" return self._toolbar def _tab_bar_clicked(self, index: int) -> None: """Handles adding/deleting button action. + Args: index: Index of the clicked tab + """ # Left to right, first "+" button, second "-" button if index == self._open_page_tab_widget.tabBar().count() - 1: @@ -96,9 +100,11 @@ def _add_button_clicked(self) -> None: add_scene_to_show(self, self._board_configuration) def _add_scene_tab(self, page: Scene | FilterPage) -> SceneTabWidget | None: - """Creates a tab for a scene + """Creates a tab for a scene. + Args: page: The scene to be added + """ if isinstance(page, Scene): page = page.pages[0] @@ -168,6 +174,7 @@ def _remove_tab(self, scene_or_index: Scene | int) -> None: Args: scene_or_index: The that is being removed. + """ if isinstance(scene_or_index, Scene): for index in range(self._open_page_tab_widget.count() - 1): @@ -194,7 +201,7 @@ def _remove_tab(self, scene_or_index: Scene | int) -> None: self._open_page_tab_widget.removeTab(scene_or_index) def _send_show_file(self) -> None: - """Send the current board configuration as a xml file to fish""" + """Send the current board configuration as a xml file to fish.""" transmit_to_fish(self._board_configuration) def _open_dmx_default_value_editor(self, s: Scene) -> None: From 650b463241394edd17ce960b27a31c55e1f3373a Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Wed, 15 Apr 2026 23:19:44 +0200 Subject: [PATCH 08/19] fix: typo --- src/view/show_mode/editor/showmanager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/show_mode/editor/showmanager.py b/src/view/show_mode/editor/showmanager.py index edf85740..4fc6c13f 100644 --- a/src/view/show_mode/editor/showmanager.py +++ b/src/view/show_mode/editor/showmanager.py @@ -82,7 +82,7 @@ def _select_scene_to_be_removed(self) -> None: @property def toolbar(self) -> list[QAction]: - """toolbar for node_mode.""" + """Toolbar for node_mode.""" return self._toolbar def _tab_bar_clicked(self, index: int) -> None: From 4521faf9e98c5029b2be9fe7b0f25793fd429cab Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 16 Apr 2026 11:21:22 +0200 Subject: [PATCH 09/19] add: entry removal option --- src/model/scene.py | 33 +++++++++++++++++-- .../dmx_default_value_editor.py | 28 ++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/model/scene.py b/src/model/scene.py index 444cafa6..545a7a51 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -2,9 +2,10 @@ from __future__ import annotations -from collections import namedtuple from typing import TYPE_CHECKING, NamedTuple, override +from PySide6.QtCore import QObject, Signal + from .universe import Universe if TYPE_CHECKING: @@ -73,11 +74,14 @@ class DmxDefaultValue(NamedTuple): value: int -class Scene: +class Scene(QObject): """Scene for a show file.""" + default_values_changed = Signal() + def __init__(self, scene_id: int, human_readable_name: str, board_configuration: BoardConfiguration) -> None: """Scene for a show file.""" + super().__init__() self._scene_id: int = scene_id self._human_readable_name: str = human_readable_name self._board_configuration: BoardConfiguration = board_configuration @@ -133,7 +137,8 @@ def dmx_default_values(self) -> list[DmxDefaultValue]: """Get the list of default values to be applied on scene switch.""" return self._dmx_default_values.copy() - def insert_dmx_default_value(self, universe: Universe | int, channel: int, value: int) -> bool: + def insert_dmx_default_value(self, universe: Universe | int, channel: int, value: int, + supress_emission: bool = False) -> bool: """Add a new default value to the scene. Existing values will be updated. @@ -142,6 +147,8 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, value universe: target universe or its ID. channel: target channel. value: value to set on scene entry. + supress_emission: If this is enabled to change signal will be enabled. Only use this if you're certain + you're taking care of all updates yourself. Returns: True if a value was updated and false if it was added. @@ -155,8 +162,28 @@ def insert_dmx_default_value(self, universe: Universe | int, channel: int, value if value_to_remove is not None: self._dmx_default_values.remove(value_to_remove) self._dmx_default_values.append(DmxDefaultValue(universe_id, channel, value)) + if not supress_emission: + self.default_values_changed.emit() return value_to_remove is not None + def remove_dmx_default_value(self, universe: Universe | int, channel: int, supress_emission: bool = False) -> None: + """Remove a default DMX value from the scene. + + Args: + universe: target universe or its ID. + channel: target channel. + supress_emission: If this is enabled to change signal will be enabled. Only use this if you're certain + you're taking care of all updates yourself. + + """ + universe_id = universe.id if isinstance(universe, Universe) else universe + values_to_remove = [val for val in self._dmx_default_values if + val.universe_id == universe_id and val.channel == channel] + for item in values_to_remove: + self._dmx_default_values.remove(item) + if len(values_to_remove) > 0 and not supress_emission: + self.default_values_changed.emit() + def insert_filterpage(self, fp: FilterPage) -> None: """Add a filterpage to the scene.""" self._filter_pages.append(fp) diff --git a/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py index c3919340..66839a5c 100644 --- a/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py +++ b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py @@ -11,7 +11,6 @@ QHBoxLayout, QLabel, QListWidget, - QListWidgetItem, QPushButton, QSizePolicy, QSpinBox, @@ -19,6 +18,8 @@ QWidget, ) +from view.show_mode.editor.show_browser.annotated_item import AnnotatedListWidgetItem + if TYPE_CHECKING: from model.scene import Scene @@ -62,7 +63,7 @@ def _accept(self) -> None: univ_id = self._universe_id_tb.currentData() channel = self._channel_tb.value() value = self._value_tb.value() - refresh_required = self._editor.scene.insert_dmx_default_value(univ_id, channel, value) + refresh_required = self._editor.scene.insert_dmx_default_value(univ_id, channel, value, supress_emission=True) if refresh_required: self._editor.refresh() else: @@ -83,11 +84,17 @@ def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: """ super().__init__(parent) self._scene: Scene = scene + self._scene.default_values_changed.connect(self.refresh) layout = QVBoxLayout() self._value_list_widget = QListWidget() self._value_list_widget.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) + self._value_list_widget.setSelectionMode(QListWidget.SelectionMode.MultiSelection) + + self._remove_value_button = QPushButton("Remove Default Value") + self._remove_value_button.clicked.connect(self._remove_entry) + self.refresh() self._add_value_button = QPushButton("Add Default Value") @@ -97,6 +104,8 @@ def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: layout.addWidget(self._value_list_widget) buttons_layout = QHBoxLayout() buttons_layout.addStretch() + buttons_layout.addWidget(self._remove_value_button) + buttons_layout.addSpacing(25) buttons_layout.addWidget(self._add_value_button) layout.addLayout(buttons_layout) @@ -105,8 +114,11 @@ def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: def refresh(self) -> None: """Refreshes the list view.""" self._value_list_widget.clear() + empty = True for univ_id, channel, value in self._scene.dmx_default_values: self.add_value_entry(univ_id, channel, value) + empty = False + self._remove_value_button.setEnabled(not empty) @property def scene(self) -> Scene: @@ -119,10 +131,20 @@ def _add_value_clicked(self) -> None: def add_value_entry(self, universe_id: int, channel: int, value: int) -> None: """Add an entry to the list view.""" - item = QListWidgetItem(self._value_list_widget) + item = AnnotatedListWidgetItem(self._value_list_widget) + item.annotated_data = (universe_id, channel, value) capability = "" associated_fixture = self._scene.board_configuration.get_fixture_by_address(universe_id, channel) if associated_fixture is not None: capability = associated_fixture.fixture_channels[channel - associated_fixture.start_index].name item.setText(f"{universe_id}:{channel} ({associated_fixture.name if associated_fixture is not None else ""}.{capability}) -> {value}") + self._remove_value_button.setEnabled(True) + + def _remove_entry(self) -> None: + for entry_item in self._value_list_widget.selectedItems(): + if not isinstance(entry_item, AnnotatedListWidgetItem): + continue + univ, channel, _ = entry_item.annotated_data + self.scene.remove_dmx_default_value(univ, channel, supress_emission=True) + self.refresh() From d63816cec4b9fbe425f19612d119a65696eb5a2e Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Thu, 16 Apr 2026 14:58:15 +0200 Subject: [PATCH 10/19] add: default mapping from console --- .../file/serializing/scene_serialization.py | 1 + src/model/board_configuration.py | 2 +- src/model/scene.py | 9 ++++++ .../console_mode/console_universe_selector.py | 32 ++++++++++++++++++- .../console_mode/console_universe_widget.py | 6 ++++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/controller/file/serializing/scene_serialization.py b/src/controller/file/serializing/scene_serialization.py index cf6ae7ed..3f86f2ce 100644 --- a/src/controller/file/serializing/scene_serialization.py +++ b/src/controller/file/serializing/scene_serialization.py @@ -107,6 +107,7 @@ def generate_scene_xml_description( for ui_page in scene.ui_pages: _add_ui_page_to_element(scene_element, ui_page) + scene.sort_dmx_default_values() for default_value in scene.dmx_default_values: ET.SubElement(scene_element, "dmxdefaultvalue", attrib={ "universe": default_value.universe_id, diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py index 435aad07..281261a0 100644 --- a/src/model/board_configuration.py +++ b/src/model/board_configuration.py @@ -338,6 +338,6 @@ def get_fixture_by_address(self, fixture_univ: int, fixture_chan: int) -> UsedFi """ for fixture in self._fixtures.values(): if fixture.universe_id == fixture_univ and \ - fixture.start_index <= fixture_chan <= fixture.start_index + fixture.channel_length: + fixture.start_index <= fixture_chan < fixture.start_index + fixture.channel_length: return fixture return None diff --git a/src/model/scene.py b/src/model/scene.py index 545a7a51..93ebeee2 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -336,3 +336,12 @@ def notify_about_filter_rename_action(self, sender: Filter, old_id: str) -> None for page in self._ui_pages: for widget in page.widgets: widget.notify_id_rename(old_id, sender.filter_id) + + def sort_dmx_default_values(self) -> None: + """Sorts the dmx defaults by their universe and channel. + + This improves human interaction and performance of fish. + + """ + self._dmx_default_values.sort(key=lambda x: (x.universe_id * 512) + x.channel) + self.default_values_changed.emit() diff --git a/src/view/console_mode/console_universe_selector.py b/src/view/console_mode/console_universe_selector.py index d29cbcb4..d424f0b4 100644 --- a/src/view/console_mode/console_universe_selector.py +++ b/src/view/console_mode/console_universe_selector.py @@ -2,11 +2,12 @@ from PySide6 import QtWidgets from PySide6.QtGui import QAction, Qt -from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSizePolicy, QWidget +from PySide6.QtWidgets import QHBoxLayout, QLabel, QMessageBox, QPushButton, QSizePolicy, QWidget from model import BoardConfiguration from model.universe import Universe from view.console_mode.console_universe_widget import DirectUniverseWidget +from view.dialogs.selection_dialog import SelectionDialog from view.show_mode.editor.node_editor_widgets.cue_editor.yes_no_dialog import YesNoDialog @@ -32,6 +33,7 @@ def __init__(self, board_configuration: BoardConfiguration, parent: QWidget) -> initial_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.addTab(initial_label, "") self._initial_tab_present: bool = True + self._dialog: SelectionDialog | None = None def add_universe(self, universe: Universe) -> None: """Add a new Universe to universe Selector. @@ -54,6 +56,12 @@ def add_universe(self, universe: Universe) -> None: automap_button.setToolTip("Would you like to automatically map all channels to bank sets?") automap_button.clicked.connect(self._automap) row_layout.addWidget(automap_button) + row_layout.addSpacing(25) + save_as_scene_default_button = QPushButton("Save as Scene default") + save_as_scene_default_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + save_as_scene_default_button.setToolTip("Save the current setup as a default for a scene.") + save_as_scene_default_button.clicked.connect(self._save_to_scene_default_clicked) + row_layout.addWidget(save_as_scene_default_button) row_layout.addStretch() layout.addLayout(row_layout) @@ -79,3 +87,25 @@ def notify_activate(self) -> None: def _automap(self) -> None: for uw in self._universe_widgets: uw.automap() + + def _save_to_scene_default_clicked(self) -> None: + if len(self._board_configuration.scenes) == 0: + self._dialog = QMessageBox(QMessageBox.Icon.Information, "No Scenes Created", + "You need to create at least one scene.") + self._dialog.show() + return + scene_list = [f"{scene.scene_id}: {scene.human_readable_name}" for scene in self._board_configuration.scenes] + self._dialog = SelectionDialog("Select Scene", "Please select the scene to apply the values to", + scene_list, self, False, + self._scene_selected_for_default_value_add) + self._dialog.show() + + def _scene_selected_for_default_value_add(self, dialog: SelectionDialog) -> None: + scene = self._board_configuration.get_scene_by_id(int(dialog.selected_items[0].split(": ", 1)[0])) + if scene is None: + return + for univ_widget in self._universe_widgets: + univ_widget.add_settings_to_scenes_default_values(scene) + scene.sort_dmx_default_values() + self._dialog.deleteLater() + self._dialog = None diff --git a/src/view/console_mode/console_universe_widget.py b/src/view/console_mode/console_universe_widget.py index 8f8c6c20..8483f220 100644 --- a/src/view/console_mode/console_universe_widget.py +++ b/src/view/console_mode/console_universe_widget.py @@ -7,6 +7,7 @@ from model.broadcaster import Broadcaster from model.control_desk import BankSet from model.ofl.fixture import UsedFixture +from model.scene import Scene from model.universe import Universe from view.console_mode.console_channel_widget import ChannelWidget @@ -112,6 +113,11 @@ def automap(self) -> None: index += 1 fixtures_per_bank = 0 + def add_settings_to_scenes_default_values(self, scene: Scene) -> None: + """Add the current universes configuration to the scenes default values.""" + for channel in self._universe.channels: + scene.insert_dmx_default_value(self._universe, channel.address, channel.value, supress_emission=True) + def _add_fixture(self, fixture: UsedFixture) -> None: if fixture.universe_id != self._universe.id: return From da301dadcda1d525c07208808320a518bbfa6cc5 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Mon, 20 Apr 2026 09:53:08 +0200 Subject: [PATCH 11/19] add: reasonable error message for missing fixture definitions --- src/controller/file/read.py | 25 +++++++++++++++++-------- src/model/ofl/fixture.py | 17 ++++++++++++++++- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/controller/file/read.py b/src/controller/file/read.py index cf45a06d..9d85a8f9 100644 --- a/src/controller/file/read.py +++ b/src/controller/file/read.py @@ -6,6 +6,7 @@ import xmlschema from defusedxml.ElementTree import parse +from PySide6.QtWidgets import QMessageBox import proto.Console_pb2 import proto.UniverseControl_pb2 @@ -21,7 +22,7 @@ from model.media_assets.asset_loading_factory import load_asset from model.media_assets.factory_hint import AssetFactoryObjectHint from model.media_assets.registry import clear as clear_media_registry -from model.ofl.fixture import load_fixture, make_used_fixture +from model.ofl.fixture import FixtureDefNotFoundError, load_fixture, make_used_fixture from model.scene import FilterPage from model.virtual_filters.vfilter_factory import construct_virtual_filter_instance from utility import resource_path @@ -662,13 +663,21 @@ def _parse_patching(board_configuration: BoardConfiguration, location_element: E fixtures_path = "/var/cache/missionDMX/fixtures" # TODO config file for child in location_element: - make_used_fixture( - board_configuration, - load_fixture(os.path.join(fixtures_path, child.attrib["fixture_file"])), - int(child.attrib["mode"]), - universe_id, - int(child.attrib["start"]), - ) + try: + make_used_fixture( + board_configuration, + load_fixture(os.path.join(fixtures_path, child.attrib["fixture_file"])), + int(child.attrib["mode"]), + universe_id, + int(child.attrib["start"]), + ) + except FixtureDefNotFoundError as e: + # Calling Dialog exec is not an issue here as we're in the process of loading the show file anyway + mb = QMessageBox(QMessageBox.Icon.Critical, "Failed to load fixture", str(e) + + "\n\nDo not continue until this error is fixed as the show is now corrupted.\nMaybe try " + "updating the fixture database.") + mb.exec_() + continue # TODO load fixture name from file diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index bf53681d..87b918e1 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -56,11 +56,26 @@ def __str__(self) -> str: return "+".join(s) +class FixtureDefNotFoundError(Exception): + """Exception raised when fixture definition could not be found on disk.""" + + def __init__(self, fixture_path: str, further_info: str) -> None: + """Initialize with default message and provided fixture info.""" + super().__init__("Fixture Definition Not Found") + self.fixture_path = fixture_path + self.further_info = further_info + + def __str__(self) -> str: + """Generate reasonable error message for observing human.""" + return f"Failed to load fixture {self.fixture_path}.\n{"File not Found.\n" if not + os.path.exists(self.fixture_path) else ""}Further info: {self.further_info}" + + def load_fixture(file: str) -> OflFixture | None: """Load fixture from OFL JSON.""" if not os.path.isfile(file): logger.error("Fixture definition %s not found.", file) - return None + raise FixtureDefNotFoundError(file, "Path is no file. Does it exist?") with open(file, "r", encoding="UTF-8") as f: try: ob: dict = json.load(f) From 19affa7e46ac3349382ef52a1078013388d94fbd Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Tue, 21 Apr 2026 10:23:57 +0200 Subject: [PATCH 12/19] fix: saving of show files --- .../file/serializing/scene_serialization.py | 2 +- .../console_mode/console_universe_selector.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/controller/file/serializing/scene_serialization.py b/src/controller/file/serializing/scene_serialization.py index 3f86f2ce..7e5d850d 100644 --- a/src/controller/file/serializing/scene_serialization.py +++ b/src/controller/file/serializing/scene_serialization.py @@ -110,7 +110,7 @@ def generate_scene_xml_description( scene.sort_dmx_default_values() for default_value in scene.dmx_default_values: ET.SubElement(scene_element, "dmxdefaultvalue", attrib={ - "universe": default_value.universe_id, + "universe": str(default_value.universe_id), "channel": str(default_value.channel), "value": str(default_value.value), }) diff --git a/src/view/console_mode/console_universe_selector.py b/src/view/console_mode/console_universe_selector.py index d424f0b4..3a569bb5 100644 --- a/src/view/console_mode/console_universe_selector.py +++ b/src/view/console_mode/console_universe_selector.py @@ -96,16 +96,17 @@ def _save_to_scene_default_clicked(self) -> None: return scene_list = [f"{scene.scene_id}: {scene.human_readable_name}" for scene in self._board_configuration.scenes] self._dialog = SelectionDialog("Select Scene", "Please select the scene to apply the values to", - scene_list, self, False, + scene_list, self, True, self._scene_selected_for_default_value_add) self._dialog.show() def _scene_selected_for_default_value_add(self, dialog: SelectionDialog) -> None: - scene = self._board_configuration.get_scene_by_id(int(dialog.selected_items[0].split(": ", 1)[0])) - if scene is None: - return - for univ_widget in self._universe_widgets: - univ_widget.add_settings_to_scenes_default_values(scene) - scene.sort_dmx_default_values() + for selected_item in dialog.selected_items: + scene = self._board_configuration.get_scene_by_id(int(selected_item.split(": ", 1)[0])) + if scene is None: + return + for univ_widget in self._universe_widgets: + univ_widget.add_settings_to_scenes_default_values(scene) + scene.sort_dmx_default_values() self._dialog.deleteLater() self._dialog = None From a1463fb70bc6cb127f25ccd00c3423d7d919737d Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Tue, 21 Apr 2026 12:38:10 +0200 Subject: [PATCH 13/19] fix: context menu for single value add --- src/model/channel.py | 10 ++++- src/model/universe.py | 2 +- .../console_mode/console_channel_widget.py | 38 ++++++++++++++++--- .../console_mode/console_universe_selector.py | 30 ++++++++++----- 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/model/channel.py b/src/model/channel.py index 365a7ebd..c469d16f 100644 --- a/src/model/channel.py +++ b/src/model/channel.py @@ -1,15 +1,23 @@ """Basic dmx channel with 256 values""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from PySide6 import QtCore +if TYPE_CHECKING: + from model import Universe class Channel(QtCore.QObject): """Basic dmx channel with 256 values""" updated: QtCore.Signal = QtCore.Signal(int) - def __init__(self, channel_address: int) -> None: + def __init__(self, parent_universe: Universe, channel_address: int) -> None: """Constructs a channel.""" super().__init__(None) + self.parent_universe = parent_universe if not (0 <= channel_address <= 511): raise ValueError(f"Tried to create a channel with address {channel_address}") self._address: int = channel_address diff --git a/src/model/universe.py b/src/model/universe.py index 7bd70ff1..c1bc69fe 100644 --- a/src/model/universe.py +++ b/src/model/universe.py @@ -14,7 +14,7 @@ class Universe: def __init__(self, universe_proto: proto.UniverseControl_pb2.Universe) -> None: self._broadcaster = Broadcaster() self._universe_proto: proto.UniverseControl_pb2 = universe_proto - self._channels: Final[list[Channel]] = [Channel(channel_address) for channel_address in + self._channels: Final[list[Channel]] = [Channel(self, channel_address) for channel_address in range(NUMBER_OF_CHANNELS)] self._name = f"Universe {self.universe_proto.id + 1}" diff --git a/src/view/console_mode/console_channel_widget.py b/src/view/console_mode/console_channel_widget.py index 3dcc0a33..fe0d0550 100644 --- a/src/view/console_mode/console_channel_widget.py +++ b/src/view/console_mode/console_channel_widget.py @@ -1,6 +1,7 @@ """Widget to edit a channel.""" +from logging import getLogger -from PySide6 import QtCore, QtWidgets +from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtWidgets import QWidget import style @@ -9,6 +10,8 @@ from model.patching.fixture_channel import FixtureChannel from view.console_mode.console_fader_bank_selector import ConsoleFaderBankSelectorWidget +logger = getLogger(__name__) + class ChannelWidget(QtWidgets.QWidget): """Widget to edit a channel. @@ -67,10 +70,11 @@ def __init__( self._value_editor.textChanged.connect(self.update_value) self._value_editor.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self._fixture_channel = QtWidgets.QLabel(fixture_channel.name) - self._fixture_channel.setWordWrap(True) - self._fixture_channel.setFixedSize(element_size, element_size * 2) - self._fixture_channel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._fixture_channel = fixture_channel + self._fixture_channel_label = QtWidgets.QLabel(fixture_channel.name) + self._fixture_channel_label.setWordWrap(True) + self._fixture_channel_label.setFixedSize(element_size, element_size * 2) + self._fixture_channel_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self._channel.updated.connect(self.update_value) @@ -108,7 +112,7 @@ def __init__( layout = QtWidgets.QVBoxLayout() layout.addWidget(address_label) - layout.addWidget(self._fixture_channel) + layout.addWidget(self._fixture_channel_label) layout.addWidget(self._bank_selector) layout.addWidget(self._value_editor) layout.addWidget(self._max_button) @@ -118,6 +122,28 @@ def __init__( self.setLayout(layout) self.setContentsMargins(0, 0, 0, 0) + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._open_context_menu) + self._menu: QtWidgets.QMenu | None = None + + def _open_context_menu(self, point: QtCore.QPoint) -> None: + self._menu = QtWidgets.QMenu(self) + self._menu.move(self.mapToGlobal(point)) + save_default_value_action = QtGui.QAction("Save Channel Value as default", self._menu) + save_default_value_action.triggered.connect(self._save_default_triggered) + self._menu.addAction(save_default_value_action) + self._menu.show() + + def _save_default_triggered(self) -> None: + p = self.parent() + from view.console_mode.console_universe_selector import UniverseSelector + while not isinstance(p, UniverseSelector): + if p is None: + logger.critical("Expected to have a UniverseSelector parent.") + return + p = p.parent() + p.save_to_scene_default_clicked(self._channel) + def _update(self, value: int) -> None: """Update the slider and value label.""" self._slider.setValue(value) diff --git a/src/view/console_mode/console_universe_selector.py b/src/view/console_mode/console_universe_selector.py index 3a569bb5..b48c79e2 100644 --- a/src/view/console_mode/console_universe_selector.py +++ b/src/view/console_mode/console_universe_selector.py @@ -1,15 +1,21 @@ """Contains select Universe widget.""" +from __future__ import annotations + +from typing import TYPE_CHECKING from PySide6 import QtWidgets from PySide6.QtGui import QAction, Qt from PySide6.QtWidgets import QHBoxLayout, QLabel, QMessageBox, QPushButton, QSizePolicy, QWidget -from model import BoardConfiguration -from model.universe import Universe from view.console_mode.console_universe_widget import DirectUniverseWidget from view.dialogs.selection_dialog import SelectionDialog from view.show_mode.editor.node_editor_widgets.cue_editor.yes_no_dialog import YesNoDialog +if TYPE_CHECKING: + from model import BoardConfiguration + from model.channel import Channel + from model.universe import Universe + class UniverseSelector(QtWidgets.QTabWidget): """select Universe from Tab Widget.""" @@ -60,7 +66,7 @@ def add_universe(self, universe: Universe) -> None: save_as_scene_default_button = QPushButton("Save as Scene default") save_as_scene_default_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) save_as_scene_default_button.setToolTip("Save the current setup as a default for a scene.") - save_as_scene_default_button.clicked.connect(self._save_to_scene_default_clicked) + save_as_scene_default_button.clicked.connect(self.save_to_scene_default_clicked) row_layout.addWidget(save_as_scene_default_button) row_layout.addStretch() layout.addLayout(row_layout) @@ -88,25 +94,31 @@ def _automap(self) -> None: for uw in self._universe_widgets: uw.automap() - def _save_to_scene_default_clicked(self) -> None: + def save_to_scene_default_clicked(self, channel: Channel | None = None) -> None: + """Save a single channel or all values to scenes.""" if len(self._board_configuration.scenes) == 0: self._dialog = QMessageBox(QMessageBox.Icon.Information, "No Scenes Created", "You need to create at least one scene.") self._dialog.show() return scene_list = [f"{scene.scene_id}: {scene.human_readable_name}" for scene in self._board_configuration.scenes] - self._dialog = SelectionDialog("Select Scene", "Please select the scene to apply the values to", + self._dialog = SelectionDialog("Select Scene", + f"Please select the scene to apply the value{'s' if channel is None else ''} to", scene_list, self, True, - self._scene_selected_for_default_value_add) + lambda d,c=channel: self._scene_selected_for_default_value_add(d, c)) self._dialog.show() - def _scene_selected_for_default_value_add(self, dialog: SelectionDialog) -> None: + def _scene_selected_for_default_value_add(self, dialog: SelectionDialog, channel: Channel | None) -> None: for selected_item in dialog.selected_items: scene = self._board_configuration.get_scene_by_id(int(selected_item.split(": ", 1)[0])) if scene is None: return - for univ_widget in self._universe_widgets: - univ_widget.add_settings_to_scenes_default_values(scene) + if channel is None: + for univ_widget in self._universe_widgets: + univ_widget.add_settings_to_scenes_default_values(scene) + else: + scene.insert_dmx_default_value(channel.parent_universe, channel.address, channel.value, + supress_emission=True) scene.sort_dmx_default_values() self._dialog.deleteLater() self._dialog = None From 6158a02146d30f1a114b966a1a41586dc98bf1d0 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Mon, 11 May 2026 12:12:07 +0200 Subject: [PATCH 14/19] fix: docs in model/universe.py --- src/model/channel.py | 10 ++++++---- src/model/universe.py | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/model/channel.py b/src/model/channel.py index c469d16f..f24e7a51 100644 --- a/src/model/channel.py +++ b/src/model/channel.py @@ -1,4 +1,4 @@ -"""Basic dmx channel with 256 values""" +"""Basic dmx channel with 256 values.""" from __future__ import annotations @@ -10,7 +10,7 @@ from model import Universe class Channel(QtCore.QObject): - """Basic dmx channel with 256 values""" + """Basic dmx channel with 256 values.""" updated: QtCore.Signal = QtCore.Signal(int) @@ -25,21 +25,23 @@ def __init__(self, parent_universe: Universe, channel_address: int) -> None: @property def address(self) -> int: - """Address of the channel. 0-indexed""" + """Address of the channel. 0-indexed.""" return self._address @property def value(self) -> int: - """The current value of the channel""" + """The current value of the channel.""" return self._value @value.setter def value(self, value: int) -> None: """Updates the value of the channel. + Must be between 0 and 255. Raises: ValueError: The value is below 0 or above 255. + """ if not (0 <= value <= 511): raise ValueError(f"Tried to set channel {self._address} to {value}.") diff --git a/src/model/universe.py b/src/model/universe.py index c1bc69fe..55bc72ef 100644 --- a/src/model/universe.py +++ b/src/model/universe.py @@ -1,4 +1,4 @@ -"""DMX Universe""" +"""DMX Universe.""" from typing import Final import proto.UniverseControl_pb2 @@ -9,9 +9,15 @@ class Universe: - """DMX universe with 512 channels""" + """DMX universe with 512 channels.""" def __init__(self, universe_proto: proto.UniverseControl_pb2.Universe) -> None: + """Initialize universe model using provided protobuf definition. + + Args: + universe_proto: The protobuf universe declaration to use. + + """ self._broadcaster = Broadcaster() self._universe_proto: proto.UniverseControl_pb2 = universe_proto self._channels: Final[list[Channel]] = [Channel(self, channel_address) for channel_address in @@ -23,7 +29,7 @@ def __init__(self, universe_proto: proto.UniverseControl_pb2.Universe) -> None: @property def universe_proto(self) -> proto.UniverseControl_pb2.Universe: - """the UniverseProto of the Universe""" + """UniverseProto definition of the Universe.""" return self._universe_proto @universe_proto.setter @@ -32,12 +38,12 @@ def universe_proto(self, proto_: proto.UniverseControl_pb2.Universe) -> None: @property def channels(self) -> list[Channel]: - """List of all 512 dmx channels belonging to the Universe""" + """List of all 512 dmx channels belonging to the Universe.""" return self._channels @property def id(self) -> int: - """id of the universe""" + """ID of the universe.""" return self._universe_proto.id @property @@ -61,7 +67,7 @@ def description(self, description: str) -> None: @property def location( self) -> int | proto.UniverseControl_pb2.Universe.ArtNet | proto.UniverseControl_pb2.Universe.USBConfig: - """network location""" + """Network location.""" if self._universe_proto.remote_location.ip_address != "": return self._universe_proto.remote_location if self._universe_proto.ftdi_dongle.vendor_id != "": From 38ca5f586ea78d5a55492f7d5b4101ab51e6bfbc Mon Sep 17 00:00:00 2001 From: Doralitze Date: Thu, 14 May 2026 09:46:17 +0200 Subject: [PATCH 15/19] fix: crash on outdated fixture loading (missing mode) --- src/model/ofl/fixture.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index 87b918e1..618124d0 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -214,6 +214,11 @@ def comment(self) -> str: @property def mode(self) -> FixtureMode: """Mode of theFixture.""" + if len(self._fixture.modes) <= self._mode_index: + raise FixtureDefNotFoundError( + self._fixture.fileName, + "Fixture does not have requested mode. Are the fixture defintions up to date?" + ) return self._fixture.modes[self._mode_index] @property @@ -340,4 +345,8 @@ def make_used_fixture( board_configuration: BoardConfiguration, fixture: OflFixture, mode_index: int, universe_id: int, start_index: int ) -> UsedFixture: """Generate a new Used Fixture from a oflFixture.""" - return UsedFixture(board_configuration, fixture, mode_index, universe_id, start_index) + try: + return UsedFixture(board_configuration, fixture, mode_index, universe_id, start_index) + except ValueError as e: + logger.error(e) + raise FixtureDefNotFoundError(fixture.fileName, str(e)) From 106f6cfccced126969c41718775e28e93c3c07b7 Mon Sep 17 00:00:00 2001 From: Doralitze Date: Thu, 14 May 2026 09:51:42 +0200 Subject: [PATCH 16/19] upd: docs of fixture.mode --- src/model/ofl/fixture.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index 618124d0..3667b9a8 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -213,7 +213,12 @@ def comment(self) -> str: @property def mode(self) -> FixtureMode: - """Mode of theFixture.""" + """Mode of theFixture. + + Raises: + FixtureDefNotFoundError if the fixture mode does not exist. + + """ if len(self._fixture.modes) <= self._mode_index: raise FixtureDefNotFoundError( self._fixture.fileName, @@ -349,4 +354,4 @@ def make_used_fixture( return UsedFixture(board_configuration, fixture, mode_index, universe_id, start_index) except ValueError as e: logger.error(e) - raise FixtureDefNotFoundError(fixture.fileName, str(e)) + raise FixtureDefNotFoundError(fixture.fileName, str(e)) from e From 381857ecd8a25fc3978fc250a89ed0986f22d6d5 Mon Sep 17 00:00:00 2001 From: Doralitze Date: Thu, 16 Jul 2026 15:05:08 +0200 Subject: [PATCH 17/19] upd: submodules --- submodules/resources | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/resources b/submodules/resources index 5ac5f5f8..b3cc0a5c 160000 --- a/submodules/resources +++ b/submodules/resources @@ -1 +1 @@ -Subproject commit 5ac5f5f8d6b9a667fa727da00441b09453c01d2d +Subproject commit b3cc0a5c89abec62939e7d4c44ebb650fb833018 From f993d235bcf772d740da3e06d65224d173b81187 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Fri, 17 Jul 2026 10:30:23 +0200 Subject: [PATCH 18/19] chg: refactored as requested --- src/controller/file/read.py | 3 ++- src/model/ofl/fixture.py | 18 ++---------------- src/model/ofl/fixture_not_found_exception.py | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 17 deletions(-) create mode 100644 src/model/ofl/fixture_not_found_exception.py diff --git a/src/controller/file/read.py b/src/controller/file/read.py index 9d85a8f9..6c7cc56b 100644 --- a/src/controller/file/read.py +++ b/src/controller/file/read.py @@ -22,7 +22,8 @@ from model.media_assets.asset_loading_factory import load_asset from model.media_assets.factory_hint import AssetFactoryObjectHint from model.media_assets.registry import clear as clear_media_registry -from model.ofl.fixture import FixtureDefNotFoundError, load_fixture, make_used_fixture +from model.ofl.fixture import load_fixture, make_used_fixture +from model.ofl.fixture_not_found_exception import FixtureDefNotFoundError from model.scene import FilterPage from model.virtual_filters.vfilter_factory import construct_virtual_filter_instance from utility import resource_path diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index 3667b9a8..a90c60ee 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -15,6 +15,7 @@ import numpy as np from PySide6 import QtCore +from model.ofl.fixture_not_found_exception import FixtureDefNotFoundError from model.ofl.ofl_fixture import CapabilityType, FixtureMode, MatrixChannelInsert, OflFixture from model.patching.fixture_channel import FixtureChannel, FixtureChannelType @@ -56,22 +57,7 @@ def __str__(self) -> str: return "+".join(s) -class FixtureDefNotFoundError(Exception): - """Exception raised when fixture definition could not be found on disk.""" - - def __init__(self, fixture_path: str, further_info: str) -> None: - """Initialize with default message and provided fixture info.""" - super().__init__("Fixture Definition Not Found") - self.fixture_path = fixture_path - self.further_info = further_info - - def __str__(self) -> str: - """Generate reasonable error message for observing human.""" - return f"Failed to load fixture {self.fixture_path}.\n{"File not Found.\n" if not - os.path.exists(self.fixture_path) else ""}Further info: {self.further_info}" - - -def load_fixture(file: str) -> OflFixture | None: +def load_fixture(file: str) -> OflFixture: """Load fixture from OFL JSON.""" if not os.path.isfile(file): logger.error("Fixture definition %s not found.", file) diff --git a/src/model/ofl/fixture_not_found_exception.py b/src/model/ofl/fixture_not_found_exception.py new file mode 100644 index 00000000..ba108ce1 --- /dev/null +++ b/src/model/ofl/fixture_not_found_exception.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import os + + +class FixtureDefNotFoundError(Exception): + """Exception raised when fixture definition could not be found on disk.""" + + def __init__(self, fixture_path: str, further_info: str) -> None: + """Initialize with default message and provided fixture info.""" + super().__init__("Fixture Definition Not Found") + self.fixture_path = fixture_path + self.further_info = further_info + + def __str__(self) -> str: + """Generate reasonable error message for observing human.""" + return f"Failed to load fixture {self.fixture_path}.\n{"File not Found.\n" if not + os.path.exists(self.fixture_path) else ""}Further info: {self.further_info}" From 9a3acd2d5c6f3f8352d7cd4605b263c63c742821 Mon Sep 17 00:00:00 2001 From: Leon Dietrich Date: Fri, 17 Jul 2026 10:32:54 +0200 Subject: [PATCH 19/19] add: missing doc --- src/model/ofl/fixture_not_found_exception.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/model/ofl/fixture_not_found_exception.py b/src/model/ofl/fixture_not_found_exception.py index ba108ce1..0fb7ef46 100644 --- a/src/model/ofl/fixture_not_found_exception.py +++ b/src/model/ofl/fixture_not_found_exception.py @@ -1,3 +1,5 @@ +"""Provides FixtureDefNotFoundError.""" + from __future__ import annotations import os