diff --git a/src/controller/file/read.py b/src/controller/file/read.py
index e3597b59..7da92303 100644
--- a/src/controller/file/read.py
+++ b/src/controller/file/read.py
@@ -1,8 +1,9 @@
-"""Handles reading a xml document"""
+"""Handle reading a xml document."""
import os
import xml.etree.ElementTree as ET
from logging import getLogger
+from uuid import UUID
import xmlschema
from defusedxml.ElementTree import parse
@@ -28,10 +29,12 @@
def _parse_and_add_bankset(child: ET.Element, loaded_banksets: dict[str, BankSet]) -> None:
- """
- Parse and add a bank set to the show file.
- :param child: The XML element to examine
- :param loaded_banksets: The list to add the bankset to.
+ """Parse and add a bank set to the show file.
+
+ Args:
+ child: The XML element to examine.
+ loaded_banksets: The list to add the bank set to.
+
"""
_id = child.attrib.get("id")
bs: BankSet = BankSet(gui_controlled=True, id_=_id)
@@ -68,15 +71,16 @@ def _parse_and_add_bankset(child: ET.Element, loaded_banksets: dict[str, BankSet
def read_document(file_name: str, board_configuration: BoardConfiguration) -> bool:
- """Parses the specified file to a board configuration data model.
+ """Parse the specified file to a board configuration data model.
Args:
- file_name: The path to the file to be parsed.
+ file_name: The path to the file to parse.
+ board_configuration: The current BoardConfiguration.
Returns:
A BoardConfiguration instance parsed from the provided file.
- """
+ """
pn = get_process_notifier("Load Showfile", 5)
try:
@@ -159,10 +163,14 @@ def read_document(file_name: str, board_configuration: BoardConfiguration) -> bo
def lcd_color_from_string(display_color: str) -> proto.Console_pb2.lcd_color:
- """
- Convert the string representation of the LCD backlight color to the enum.
- :param display_color: The string representation
- :returns: The enum representation
+ """Convert the string representation of the LCD backlight color to the enum.
+
+ Args:
+ display_color: The string representation.
+
+ Returns:
+ The enum representation.
+
"""
match display_color:
case "white":
@@ -186,18 +194,20 @@ def lcd_color_from_string(display_color: str) -> proto.Console_pb2.lcd_color:
def _clean_tags(element: ET.Element, prefix: str) -> None:
- """This method recursively cleans up immediate XML tag prefixes."""
+ """Recursively clean up immediate XML tag prefixes."""
for child in element:
child.tag = child.tag.replace(prefix, "")
_clean_tags(child, prefix)
def _parse_filter_page(element: ET.Element, parent_scene: Scene, instantiated_pages: list[FilterPage]) -> bool:
- """
- Load a filter page from the XML representation.
- :param element: The XML element to load the data from
- :param parent_scene: The scene to add the page to
- :param instantiated_pages: The list of all loaded filter pages, where this element is appended to
+ """Load a filter page from the XML representation.
+
+ Args:
+ element: The XML element to load the data from.
+ parent_scene: The scene to add the page to.
+ instantiated_pages: The list of all loaded filter pages, to which this element is appended.
+
"""
f = FilterPage(parent_scene)
for key, value in element.attrib.items():
@@ -241,11 +251,13 @@ def _parse_filter_page(element: ET.Element, parent_scene: Scene, instantiated_pa
def _parse_scene(
scene_element: ET.Element, board_configuration: BoardConfiguration, loaded_banksets: dict[str, BankSet]
) -> None:
- """
- Load a scene from the show file data structure.
- :param scene_element: The XML element to use
- :param board_configuration: The show configuration object to insert the scene into.
- :param loaded_banksets: A list of bank sets that are associated with the scene.
+ """Load a scene from the show file data structure.
+
+ Args:
+ scene_element: The XML element to use.
+ board_configuration: The show configuration object to insert the scene into.
+ loaded_banksets: A list of bank sets associated with the scene.
+
"""
human_readable_name = ""
scene_id = 0
@@ -297,10 +309,12 @@ def _parse_scene(
def _append_ui_page(page_def: ET.Element, scene: Scene) -> None:
- """
- Load a UI page (the ones that contain the widgets) from the XML data.
- :param page_def: The XML data structure
- :param scene: The scene to add it to.
+ """Load a UI page (containing widgets) from XML data.
+
+ Args:
+ page_def: The XML data structure.
+ scene: The scene to add it to.
+
"""
page = UIPage(scene)
for k, v in page_def.attrib.items():
@@ -364,10 +378,12 @@ def _append_ui_page(page_def: ET.Element, scene: Scene) -> None:
def _parse_filter(filter_element: ET.Element, scene: Scene) -> None:
- """
- Load a filter from the XML definition.
- :param filter_element: THe XML data to load the filter from
- :param scene: The scene to append the filter to
+ """Load a filter from the XML definition.
+
+ Args:
+ filter_element: The XML data to load the filter from.
+ scene: The scene to append the filter to.
+
"""
filter_id = ""
filter_type = 0
@@ -408,10 +424,12 @@ def _parse_filter(filter_element: ET.Element, scene: Scene) -> None:
def _parse_channel_link(initial_parameters_element: ET.Element, filter_: Filter) -> None:
- """
- Load a connection between two filters.
- :param initial_parameters_element: The XML element describing the connection.
- :param filter_: The parent filter (whose input this is) to attach the connection to.
+ """Load a connection between two filters.
+
+ Args:
+ initial_parameters_element: The XML element describing the connection.
+ filter_: The parent filter (whose input this is) to attach the connection to.
+
"""
cl_key = ""
cl_value = ""
@@ -428,10 +446,12 @@ def _parse_channel_link(initial_parameters_element: ET.Element, filter_: Filter)
def _parse_initial_parameters(initial_parameters_element: ET.Element, filter_: Filter) -> None:
- """
- Load the parameters of a filter.
- :param initial_parameters_element: The XML definition to load the parameters from
- :param filter_: The filter whose parameters these are.
+ """Load the parameters of a filter.
+
+ Args:
+ initial_parameters_element: The XML definition to load the parameters from.
+ filter_: The filter whose parameters these are.
+
"""
ip_key = ""
ip_value = ""
@@ -450,11 +470,13 @@ def _parse_initial_parameters(initial_parameters_element: ET.Element, filter_: F
def _parse_filter_configuration(filter_configuration_element: ET.Element, filter_: Filter, fc: dict[str, str]) -> None:
- """
- Load the configuration of a filter.
- :param filter_configuration_element: The XML data to load the configuration from
- :param filter_: The filter which the configuration belongs to
- :param fc: The existing configuration to append to
+ """Load the configuration of a filter.
+
+ Args:
+ filter_configuration_element: The XML data to load the configuration from.
+ filter_: The filter to which the configuration belongs.
+ fc: The existing configuration to append to.
+
"""
fc_key = ""
fc_value = ""
@@ -476,10 +498,12 @@ def _parse_filter_configuration(filter_configuration_element: ET.Element, filter
def _parse_universe(universe_element: ET.Element, board_configuration: BoardConfiguration) -> None:
- """
- Load a universe description from XML data.
- :param universe_element: The XML data to use.
- :param board_configuration: The show to register the universe with.
+ """Load a universe description from XML data.
+
+ Args:
+ universe_element: The XML data to use.
+ board_configuration: The show to register the universe with.
+
"""
universe_id = None
name = ""
@@ -523,19 +547,27 @@ def _parse_universe(universe_element: ET.Element, board_configuration: BoardConf
def _parse_physical_location(location_element: ET.Element) -> int:
- """
- Parse a universe definition for one attached directly to the IO mainboard.
- :param location_element: The XML data to load from
- :returns: The location
+ """Parse a universe definition for one attached directly to the IO mainboard.
+
+ Args:
+ location_element: The XML data to load from.
+
+ Returns:
+ The location.
+
"""
return int(location_element.text)
def _parse_artnet_location(location_element: ET.Element) -> proto.UniverseControl_pb2.Universe.ArtNet:
- """
- Parse a universe definition of an ArtNet stage box.
- :param location_element: The XML data to load from
- :returns: An ArtNet universe location
+ """Parse a universe definition of an ArtNet stage box.
+
+ Args:
+ location_element: The XML data to load from.
+
+ Returns:
+ An ArtNet universe location.
+
"""
device_universe_id = 0
ip_address = ""
@@ -557,10 +589,14 @@ def _parse_artnet_location(location_element: ET.Element) -> proto.UniverseContro
def _parse_ftdi_location(location_element: ET.Element) -> proto.UniverseControl_pb2.Universe.USBConfig:
- """
- Load a universe location definition of an USB DMX adapter.
- :param location_element: The XML data to load from
- :returns: THe loaded connection details
+ """Load a universe location definition of a USB DMX adapter.
+
+ Args:
+ location_element: The XML data to load from.
+
+ Returns:
+ The loaded connection details.
+
"""
product_id = 0
vendor_id = 0
@@ -585,11 +621,16 @@ def _parse_ftdi_location(location_element: ET.Element) -> proto.UniverseControl_
def _parse_patching(board_configuration: BoardConfiguration, location_element: ET.Element, universe_id: int) -> None:
- """
- Load patching information from XML data.
- :param location_element: The XML data to load from
- :param universe_id: The id of the universe which this fixture belongs to.
- :returns: The loaded fixtures
+ """Load patching information from XML data.
+
+ Args:
+ board_configuration: The current BoardConfiguration.
+ location_element: The XML data to load from.
+ universe_id: The ID of the universe this fixture belongs to.
+
+ Returns:
+ The loaded fixtures.
+
"""
fixtures_path = "/var/cache/missionDMX/fixtures" # TODO config file
@@ -598,18 +639,22 @@ def _parse_patching(board_configuration: BoardConfiguration, location_element: E
board_configuration,
load_fixture(os.path.join(fixtures_path, child.attrib["fixture_file"])),
int(child.attrib["mode"]),
- universe_id,
+ board_configuration.universe(universe_id),
int(child.attrib["start"]),
+ UUID(child.attrib.get("id")) if child.attrib.get("id") else None,
+ child.attrib.get("color"),
)
# TODO load fixture name from file
def _parse_ui_hint(ui_hint_element: ET.Element, board_configuration: BoardConfiguration) -> None:
- """
- Load general configuration data.
- :param ui_hint_element: The XML representation to load from
- :param board_configuration: THe show file to apply the settings on.
+ """Load general configuration data.
+
+ Args:
+ ui_hint_element: The XML representation to load from.
+ board_configuration: The show file to apply the settings on.
+
"""
ui_hint_key = ""
ui_hint_value = ""
diff --git a/src/controller/file/serializing/general_serialization.py b/src/controller/file/serializing/general_serialization.py
index 1672faba..f53d5b19 100644
--- a/src/controller/file/serializing/general_serialization.py
+++ b/src/controller/file/serializing/general_serialization.py
@@ -1,4 +1,5 @@
-"""serialization functions"""
+"""Serialization functions."""
+
import xml.etree.ElementTree as ET
from controller.file.serializing.events_and_macros import _write_event_sender, _write_macro
@@ -16,18 +17,21 @@
from model.events import get_all_senders
-def create_xml(board_configuration: BoardConfiguration, pn: ProcessNotifier,
- assemble_for_fish_loading: bool = False) -> ET.Element:
- """Creates an XML element from the given board configuration.
+def create_xml(
+ board_configuration: BoardConfiguration, pn: ProcessNotifier, assemble_for_fish_loading: bool = False
+) -> ET.Element:
+ """Create an XML element from the given board configuration.
Args:
- board_configuration: The board configuration to be converted.
- assemble_for_fish_loading: Pass True if the XML is build for fish.
- This will skip the UI and resolve virtual filters
+ board_configuration: The board configuration to convert.
+ pn: The Process notifier to use.
+ assemble_for_fish_loading: Pass True if the XML is built for fish.
+ This will skip the UI and resolve virtual filters.
Returns:
The XML element containing the board configuration.
See https://github.com/Mission-DMX/Docs/blob/main/FormatSchemes/ProjectFile/ShowFile_v0.xsd for more information
+
"""
pn.current_step_description = "Creating document root."
pn.total_step_count += 1 + len(board_configuration.scenes) + 3
@@ -55,7 +59,8 @@ def create_xml(board_configuration: BoardConfiguration, pn: ProcessNotifier,
if fixtures := board_configuration.fixtures:
patching_element = ET.SubElement(universe_element, "patching")
for fixture in fixtures:
- _create_fixture_element(fixture, patching_element, assemble_for_fish_loading)
+ if fixture.universe == universe:
+ _create_fixture_element(fixture, patching_element, assemble_for_fish_loading)
pn.total_step_count += 1
pn.current_step_description = "Storing device list."
@@ -78,27 +83,34 @@ def create_xml(board_configuration: BoardConfiguration, pn: ProcessNotifier,
def _create_board_configuration_element(board_configuration: BoardConfiguration) -> ET.Element:
- """Creates an XML element of a type scene.
+ """Create an XML element of type `BoardConfiguration`.
+
+ Example:
+
+ ...
+
-
- ...
-
"""
# TODO we're not filling in the version attribute
- return ET.Element("bord_configuration", attrib={
- "xmlns": "http://www.asta.uni-luebeck.de/MissionDMX/ShowFile",
- "xsi:schemaLocation": "http://www.asta.uni-luebeck.de/MissionDMX/ShowFile",
- "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
- "show_name": str(board_configuration.show_name),
- "default_active_scene": str(board_configuration.default_active_scene),
- "notes": str(board_configuration.notes),
- })
+ return ET.Element(
+ "bord_configuration",
+ attrib={
+ "xmlns": "http://www.asta.uni-luebeck.de/MissionDMX/ShowFile",
+ "xsi:schemaLocation": "http://www.asta.uni-luebeck.de/MissionDMX/ShowFile",
+ "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
+ "show_name": str(board_configuration.show_name),
+ "default_active_scene": str(board_configuration.default_active_scene),
+ "notes": str(board_configuration.notes),
+ },
+ )
def _create_device_element(device: Device, parent: ET.Element) -> ET.Element:
- """TODO implement patching of devices
+ """TODO: Implement patching of devices.
+
+ Example:
+
-
"""
diff --git a/src/controller/file/serializing/universe_serialization.py b/src/controller/file/serializing/universe_serialization.py
index a31db710..5a195163 100644
--- a/src/controller/file/serializing/universe_serialization.py
+++ b/src/controller/file/serializing/universe_serialization.py
@@ -1,4 +1,4 @@
-"""serialization of universes"""
+"""Serialization of universes."""
import xml.etree.ElementTree as ET
@@ -8,70 +8,99 @@
def _create_universe_element(universe: Universe, parent: ET.Element) -> ET.Element:
- """Creates an XML element of type physical_location.
+ """Create an XML element of type `physical_location`.
+
+ Example:
+
+ ...
+
-
- ...
-
"""
- return ET.SubElement(parent, "universe", attrib={
- "id": str(universe.id),
- "name": str(universe.name),
- "description": str(universe.description),
- })
+ return ET.SubElement(
+ parent,
+ "universe",
+ attrib={
+ "id": str(universe.id),
+ "name": str(universe.name),
+ "description": str(universe.description),
+ },
+ )
def _create_physical_location_element(physical: int, parent: ET.Element) -> ET.Element:
- """Creates an XML element of type physical_location.
+ """Create an XML element of type `physical_location`.
+
+ Example:
+ 0
- 0
"""
physical_location = ET.SubElement(parent, "physical_location")
physical_location.text = str(physical)
return physical_location
-def _create_artnet_location_element(artnet_location: proto.UniverseControl_pb2.Universe.ArtNet,
- parent: ET.Element) -> ET.Element:
- """Creates an xml element of type artnet_location.
+def _create_artnet_location_element(
+ artnet_location: proto.UniverseControl_pb2.Universe.ArtNet, parent: ET.Element
+) -> ET.Element:
+ """Create an XML element of type `artnet_location`.
+
+ Example:
+
-
"""
- return ET.SubElement(parent, "artnet_location", attrib={
- "ip_address": str(artnet_location.ip_address),
- "udp_port": str(artnet_location.port),
- "device_universe_id": str(artnet_location.universe_on_device),
- })
+ return ET.SubElement(
+ parent,
+ "artnet_location",
+ attrib={
+ "ip_address": str(artnet_location.ip_address),
+ "udp_port": str(artnet_location.port),
+ "device_universe_id": str(artnet_location.universe_on_device),
+ },
+ )
-def _create_ftdi_location_element(ftdi_location: proto.UniverseControl_pb2.Universe.USBConfig,
- parent: ET.Element) -> ET.Element:
- """Creates a xml element of type ftdi_location.
+def _create_ftdi_location_element(
+ ftdi_location: proto.UniverseControl_pb2.Universe.USBConfig, parent: ET.Element
+) -> ET.Element:
+ """Create an XML element of type `ftdi_location`.
+
+ Example:
+
-
"""
# vendor_id=0x0403, product_id=0x6001
- return ET.SubElement(parent, "ftdi_location", attrib={
- "vendor_id": str(ftdi_location.vendor_id),
- "product_id": str(ftdi_location.product_id),
- "device_name": str(ftdi_location.device_name),
- "serial_identifier": str(ftdi_location.serial),
- })
-
+ return ET.SubElement(
+ parent,
+ "ftdi_location",
+ attrib={
+ "vendor_id": str(ftdi_location.vendor_id),
+ "product_id": str(ftdi_location.product_id),
+ "device_name": str(ftdi_location.device_name),
+ "serial_identifier": str(ftdi_location.serial),
+ },
+ )
+
+
+def _create_fixture_element(fixture: UsedFixture, patching_element: ET.Element, assemble_for_fish: bool) -> None:
+ """Add patching information of a fixture to the show file XML structure.
+
+ Args:
+ fixture: The fixture to add.
+ patching_element: The parent element to add to.
+ assemble_for_fish: Whether this information should be omitted.
-def _create_fixture_element(fixture: UsedFixture, patching_element: ET.Element,
- assemble_for_fish: bool) -> None:
- """
- add patching information of a fixture to the show file XML structure.
- :param fixture: The Fixture to add
- :param parent: The parent element to add to
- :param assemble_for_fish: Should this information be omitted?
"""
if assemble_for_fish:
return
- ET.SubElement(patching_element, "fixture", attrib={
- # Todo add to doku "universe": str(fixture.universe_id),
- "start": str(fixture.start_index),
- "fixture_file": fixture.fixture_file,
- "mode": str(fixture.mode_index),
- })
+ ET.SubElement(
+ patching_element,
+ "fixture",
+ attrib={
+ # Todo add to doku "universe": str(fixture.universe_id),
+ "id": str(fixture.uuid),
+ "start": str(fixture.start_index),
+ "fixture_file": fixture.fixture_file,
+ "mode": str(fixture.mode_index),
+ "color": fixture.color_on_stage.name(),
+ },
+ )
diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py
index dba66273..6d5cab56 100644
--- a/src/model/board_configuration.py
+++ b/src/model/board_configuration.py
@@ -1,18 +1,25 @@
-"""Provides data structures with accessors and modifiers for DMX"""
-from collections.abc import Callable, Sequence
+"""Provides data structures with accessors and modifiers for DMX."""
+
+from __future__ import annotations
+
from logging import getLogger
+from typing import TYPE_CHECKING
import numpy as np
from PySide6 import QtCore, QtGui
-import proto.FilterMode_pb2
-
from .broadcaster import Broadcaster
-from .device import Device
-from .macro import Macro
-from .ofl.fixture import UsedFixture
-from .scene import Scene
-from .universe import Universe
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Sequence
+
+ import proto.FilterMode_pb2
+
+ from .device import Device
+ from .macro import Macro
+ from .ofl.fixture import UsedFixture
+ from .scene import Scene
+ from .universe import Universe
logger = getLogger(__name__)
@@ -21,6 +28,7 @@ class BoardConfiguration:
"""Board configuration of a show file."""
def __init__(self, show_name: str = "", default_active_scene: int = 0, notes: str = "") -> None:
+ """Board configuration of a show file."""
self._show_name: str = show_name
self._default_active_scene: int = default_active_scene
self._notes: str = notes
@@ -48,7 +56,7 @@ def __init__(self, show_name: str = "", default_active_scene: int = 0, notes: st
self._broadcaster.update_filter_parameter.connect(self._distribute_filter_update_message)
def _clear(self) -> None:
- """This method resets the show data prior to loading a new one."""
+ """Reset the show data prior to loading new one."""
for scene in self._scenes:
self._broadcaster.delete_scene.emit(scene)
for universe in self._universes:
@@ -69,38 +77,44 @@ def _clear(self) -> None:
self._macros.clear()
def _add_scene(self, scene: Scene) -> None:
- """Adds a scene to the list of scenes.
+ """Add a scene to the list of scenes.
Args:
- scene: The scene to be added.
+ scene: The scene to add.
+
"""
self._scenes.append(scene)
self._scenes_index[scene.scene_id] = len(self._scenes) - 1
def _delete_scene(self, scene: Scene) -> None:
- """Removes the passed scene from the list of scenes.
+ """Remove the given scene from the list of scenes.
Args:
- scene: The scene to be removed.
+ scene: The scene to remove.
+
"""
self._scenes.remove(scene)
self._scenes_index.pop(scene.scene_id)
def _add_universe(self, universe: Universe) -> None:
- """Creates and adds a universe from passed patching universe.
+ """Create and add a universe from the given patching universe.
+
Args:
universe: The universe to add.
+
"""
self._universes.update({universe.id: universe})
def _add_fixture(self, used_fixture: UsedFixture) -> None:
+ """Handle add Fixture signal."""
self._fixtures.append(used_fixture)
def _delete_universe(self, universe: Universe) -> None:
- """Removes the passed universe from the list of universes.
+ """Remove the given universe from the list of universes.
Args:
- universe: The universe to be removed.
+ universe: The universe to remove.
+
"""
try:
del self._universes[universe.id]
@@ -108,105 +122,103 @@ def _delete_universe(self, universe: Universe) -> None:
logger.exception("Unable to remove universe %s", universe.name)
def _add_device(self, device: Device) -> None:
- """Adds the device to the board configuration.
+ """Add the given device to the board configuration.
Args:
- device: The device to be added.
+ device: The device to add.
+
"""
self._devices.append(device)
def _delete_device(self, device: Device) -> None:
- """Removes the passed device from the list of devices.
+ """Remove the given device from the list.
Args:
- device: The device to be removed.
+ device: The device to remove.
+
"""
def universe(self, universe_id: int) -> Universe | None:
- """Tries to find a universe by id.
+ """Find a universe by its ID.
- Arg:
- universe_id: The id of the universe requested.
+ Args:
+ universe_id: The ID of the requested universe.
Returns:
The universe if found, else None.
+
"""
return self._universes.get(universe_id, None)
@property
def fixtures(self) -> Sequence[UsedFixture]:
- """Fixtures associated with this Show"""
+ """Fixtures associated with this Show."""
return self._fixtures
@property
def show_name(self) -> str:
- """The name of the show"""
+ """Name of the show."""
return self._show_name
@show_name.setter
def show_name(self, show_name: str) -> None:
- """Sets the show name"""
self._show_name = show_name
@property
def default_active_scene(self) -> int:
- """Scene to be activated by fish on loadup"""
+ """Scene to be activated by fish on loadup."""
return self._default_active_scene
@default_active_scene.setter
def default_active_scene(self, default_active_scene: int) -> None:
- """Setss the scene to be activated by fish on loadup"""
self._default_active_scene = default_active_scene
@property
def notes(self) -> str:
- """Notes for the show"""
+ """Notes for the show."""
return self._notes
@notes.setter
def notes(self, notes: str) -> None:
- """Sets the notes for the show"""
self._notes = notes
@property
def scenes(self) -> list[Scene]:
- """The scenes of the show"""
+ """Scenes of the show."""
return self._scenes
@property
def devices(self) -> list[Device]:
- """The devices of the show"""
+ """Devices of the show."""
return self._devices
@property
def universes(self) -> list[Universe]:
- """The universes of the show"""
+ """Universes of the show."""
return list(self._universes.values())
@property
def ui_hints(self) -> dict[str, str]:
- """UI hints for the show"""
+ """UI hints for the show."""
return self._ui_hints
@property
def broadcaster(self) -> Broadcaster:
- """The broadcaster the board configuration uses"""
+ """The broadcaster the board configuration use."""
return self._broadcaster
@property
def file_path(self) -> str:
- """ path to the showfile"""
+ """Path to the showfile."""
return self._show_file_path
@file_path.setter
def file_path(self, new_path: str) -> None:
- """Update the show file path.
- :param new_path: The location to save the show file to"""
self._show_file_path = new_path
self._broadcaster.show_file_path_changed.emit(new_path)
def get_scene_by_id(self, scene_id: int) -> Scene | None:
- """Returns the scene by her id"""
+ """Return the scene by id."""
looked_up_position = self._scenes_index.get(scene_id)
if looked_up_position is not None and looked_up_position < len(self._scenes):
return self._scenes[looked_up_position]
@@ -216,21 +228,23 @@ def get_scene_by_id(self, scene_id: int) -> Scene | None:
return None
def _distribute_filter_update_message(self, param: proto.FilterMode_pb2.update_parameter) -> None:
- """Find listeners to incoming filter update message and distribute it to them."""
+ """Find listeners for an incoming filter update message and distribute it."""
candidate_list = self._filter_update_msg_register.get((param.scene_id, param.filter_id))
if candidate_list is not None:
for c in candidate_list:
c(param)
def register_filter_update_callback(self, target_scene: int, target_filter_id: str, c: Callable) -> None:
- """
- Register a new callback for filter update messages.
+ """Register a new callback for filter update messages.
+
+ If filter update messages are received, they are routed to their intended
+ destination. Suitable callables receive the update message as a parameter.
+
+ Args:
+ target_scene: The scene the callback belongs to.
+ target_filter_id: The filter ID to listen on.
+ c: The callable to register.
- If filter update messages are received, they need to be routed to their intended destination. This is done using
- this registration method. Suitable callables receive the update message as a parameter.
- :param target_scene: The scene the callback belongs to.
- :param target_filter_id: The filter id to listen on.
- :param c: The callable to register.
"""
callable_list = self._filter_update_msg_register.get((target_scene, target_filter_id))
if callable_list is None:
@@ -240,11 +254,13 @@ def register_filter_update_callback(self, target_scene: int, target_filter_id: s
callable_list.append(c)
def remove_filter_update_callback(self, target_scene: int, target_filter_id: str, c: Callable) -> None:
- """
- Remove a previously registered callback.
- :param target_scene: The scene the callback belongs to.
- :param target_filter_id: The filter id which it is listening on.
- :param c: The callable to be removed.
+ """Remove a previously registered callback.
+
+ Args:
+ target_scene: The scene the callback belongs to.
+ target_filter_id: The filter ID it is listening on.
+ c: The callable to remove.
+
"""
callable_list = self._filter_update_msg_register.get((target_scene, target_filter_id))
if callable_list is None:
@@ -253,12 +269,13 @@ def remove_filter_update_callback(self, target_scene: int, target_filter_id: str
callable_list.remove(c)
def add_macro(self, m: Macro) -> None:
- """
- Add a new macro to the show file.
+ """Add a new macro to the show file.
This method must be called from a QObject as it triggers an event.
- :param m: The macro to add.
+ Args:
+ m: The macro to add.
+
"""
new_index = len(self._macros)
self._macros.append(m)
@@ -266,7 +283,11 @@ def add_macro(self, m: Macro) -> None:
def get_macro(self, macro_id: int | str) -> Macro | None:
"""Get the macro specified by its index.
- :returns: The macro or None if none was found."""
+
+ Returns:
+ The macro, or None if none was found.
+
+ """
if isinstance(macro_id, int):
if macro_id >= len(self._macros):
return None
@@ -279,22 +300,22 @@ def get_macro(self, macro_id: int | str) -> Macro | None:
@property
def macros(self) -> list[Macro]:
- """Get a list of registered macros."""
+ """List of registered macros."""
return self._macros.copy()
def next_universe_id(self) -> int:
- """next empty universe id"""
+ """Next empty universe id."""
nex_id = len(self._universes)
while self._universes.get(nex_id):
nex_id += 1
return nex_id
- def get_occupied_channels(self, universe_id: int) -> np.typing.NDArray[int]:
- """Returns a list of all channels that are occupied by a scene."""
+ def get_occupied_channels(self, universe: Universe) -> np.typing.NDArray[int]:
+ """Return a list of all channels that are occupied by Fixtures in a Universe."""
ranges = [
np.arange(fixture.start_index, fixture.start_index + fixture.channel_length)
for fixture in self.fixtures
- if fixture.universe_id == universe_id
+ if fixture.universe == universe
]
return np.concatenate(ranges) if ranges else np.array([], dtype=int)
diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py
index f89c820a..fd701cbc 100644
--- a/src/model/ofl/fixture.py
+++ b/src/model/ofl/fixture.py
@@ -12,6 +12,7 @@
import numpy as np
from PySide6 import QtCore
+from PySide6.QtGui import QColor
from model.ofl.ofl_fixture import FixtureMode, OflFixture
from model.patching.fixture_channel import FixtureChannel, FixtureChannelType
@@ -21,7 +22,7 @@
from numpy.typing import NDArray
- from model import BoardConfiguration
+ from model import BoardConfiguration, Universe
logger = getLogger(__name__)
@@ -37,6 +38,7 @@ class ColorSupport(IntFlag):
HAS_UV_SEGMENT = 16
def __str__(self) -> str:
+ """Return the string representation of ColorSupport."""
if self == ColorSupport.NO_COLOR_SUPPORT:
return "No Color Support"
s = []
@@ -54,7 +56,7 @@ def __str__(self) -> str:
def load_fixture(file: str) -> OflFixture:
- """load fixture from OFL JSON"""
+ """Load fixture from OFL JSON."""
with open(file, "r", encoding="UTF-8") as f:
ob: dict = json.load(f)
ob.update({"fileName": file.split("/fixtures/")[1]})
@@ -65,17 +67,19 @@ class UsedFixture(QtCore.QObject):
"""Fixture in use with a specific mode."""
static_data_changed: QtCore.Signal = QtCore.Signal()
+ universe_changed: QtCore.Signal = QtCore.Signal(int)
def __init__(
self,
board_configuration: BoardConfiguration,
fixture: OflFixture,
mode_index: int,
- parent_universe: int,
+ parent_universe: Universe,
start_index: int,
uuid: UUID | None = None,
- color: str | None = None,
+ color_on_stage: str | None = None,
) -> None:
+ """Fixture in use with a specific mode."""
super().__init__()
self._board_configuration: Final[BoardConfiguration] = board_configuration
self._fixture: Final[OflFixture] = fixture
@@ -83,7 +87,7 @@ def __init__(
self._start_index: int = start_index
self._mode_index: int = mode_index
- self._universe_id: int = parent_universe
+ self._universe: Universe = parent_universe
channels, segment_map, color_support = self._generate_fixture_channels()
@@ -91,24 +95,22 @@ def __init__(
self._segment_map: dict[FixtureChannelType, NDArray[np.int_]] = segment_map
self._color_support: Final[ColorSupport] = color_support
- self._color_on_stage: str = (
- color if color else "#" + "".join([random.choice("0123456789ABCDEF") for _ in range(6)]) # noqa: S311 not a secret
+ self._color_on_stage: QColor = QColor(
+ color_on_stage if color_on_stage else "#" + "".join([random.choice("0123456789ABCDEF") for _ in range(6)]) # noqa: S311 not a secret
)
self._name_on_stage: str = self.short_name if self.short_name else self.name
- self.parent_universe: int = parent_universe
+ self.parent_universe: int = parent_universe.id # TODO remove
self._board_configuration.broadcaster.add_fixture.emit(self)
@property
def uuid(self) -> UUID:
- """uuid of the fixture"""
+ """UUID of the fixture."""
return self._uuid
@property
def power(self) -> float:
- """
- Fixture maximum continuous power draw (not accounting for capacitor charging as well as lamp warmup) in W.
- """
+ """Fixture maximum continuous power draw (not accounting for capacitor charging as well as lamp warmup) in W."""
return self._fixture.physical.power
@property
@@ -136,6 +138,12 @@ def start_index(self) -> int:
"""Start index of theFixture in the Universe indexed by 0."""
return self._start_index
+ @start_index.setter
+ def start_index(self, start_index: int) -> None:
+ if start_index != self._start_index:
+ self._start_index = start_index
+ self.static_data_changed.emit()
+
@property
def fixture_file(self) -> str:
"""File of the fixture."""
@@ -148,12 +156,21 @@ def mode_index(self) -> int:
@property
def universe_id(self) -> int:
- """Id of the universe for the fixture."""
- return self._universe_id
+ """Universe for the fixture."""
+ # TODO remove
+ return self._universe.id
+
+ @property
+ def universe(self) -> Universe:
+ """Universe of the fixture."""
+ return self._universe
- @universe_id.setter
- def universe_id(self, universe_id: int) -> None:
- self._universe_id = universe_id
+ @universe.setter
+ def universe(self, universe: Universe) -> None:
+ if universe != self._universe:
+ old_id = self._universe
+ self._universe = universe
+ self.universe_changed.emit(old_id)
@property
def channel_length(self) -> int:
@@ -171,14 +188,15 @@ def fixture_channels(self) -> tuple[FixtureChannel, ...]:
return tuple(self._fixture_channels)
@property
- def color_on_stage(self) -> str:
+ def color_on_stage(self) -> QColor:
"""Color of the fixture on stage."""
return self._color_on_stage
@color_on_stage.setter
- def color_on_stage(self, color: str) -> None:
- self._color_on_stage = color
- self.static_data_changed.emit()
+ def color_on_stage(self, color: QColor) -> None:
+ if color != self._color_on_stage:
+ self._color_on_stage = color
+ self.static_data_changed.emit()
@property
def name_on_stage(self) -> str:
@@ -187,8 +205,9 @@ def name_on_stage(self) -> str:
@name_on_stage.setter
def name_on_stage(self, name: str) -> None:
- self._name_on_stage = name
- self.static_data_changed.emit()
+ if name != self._name_on_stage:
+ self._name_on_stage = name
+ self.static_data_changed.emit()
@property
def color_support(self) -> ColorSupport:
@@ -237,7 +256,13 @@ def __str__(self) -> str:
def make_used_fixture(
- board_configuration: BoardConfiguration, fixture: OflFixture, mode_index: int, universe_id: int, start_index: int
+ board_configuration: BoardConfiguration,
+ fixture: OflFixture,
+ mode_index: int,
+ universe: Universe,
+ start_index: int,
+ uuid: UUID | None = None,
+ color: str | None = None,
) -> UsedFixture:
"""Generate a new Used Fixture from a oflFixture."""
- return UsedFixture(board_configuration, fixture, mode_index, universe_id, start_index)
+ return UsedFixture(board_configuration, fixture, mode_index, universe, start_index, uuid, color)
diff --git a/src/model/ofl/fixture_library_loader.py b/src/model/ofl/fixture_library_loader.py
new file mode 100644
index 00000000..8c91f641
--- /dev/null
+++ b/src/model/ofl/fixture_library_loader.py
@@ -0,0 +1,33 @@
+import os
+import zipfile
+from logging import getLogger
+
+import requests
+
+logger = getLogger(__name__)
+
+def ensure_standard_fixture_library_exists(prefix="/var/cache/missionDMX") -> tuple[bool, str]:
+ """This function ensures that the standard library fixture library exists.
+
+ This method will attempt to download the library if it is missing.
+
+ :return: True if the standard library fixture library exists or was successfully downloaded, otherwise False.
+ """
+ if not os.path.exists(prefix):
+ os.mkdir(prefix)
+ fixtures_path = os.path.join(prefix, "fixtures/")
+ if not os.path.exists(fixtures_path):
+ zip_path = os.path.join(prefix, "fixtures.zip")
+ logger.info("Downloading fixture library. Please wait")
+ url = "https://open-fixture-library.org/download.ofl"
+ r = requests.get(url, allow_redirects=True, timeout=5)
+ if r.status_code != 200:
+ logger.error("Failed to download fixture library")
+ return False, None
+
+ with open(zip_path, "wb") as file:
+ file.write(r.content)
+ with zipfile.ZipFile(zip_path) as zip_ref:
+ zip_ref.extractall(fixtures_path)
+ logger.info("Fixture lib downloaded and installed.")
+ return True, fixtures_path
\ No newline at end of file
diff --git a/src/model/patching/fixture_channel.py b/src/model/patching/fixture_channel.py
index 0b3ef568..596f672d 100644
--- a/src/model/patching/fixture_channel.py
+++ b/src/model/patching/fixture_channel.py
@@ -1,4 +1,5 @@
-"""Channels of a Fixture"""
+"""Channel of a fixture."""
+
from enum import IntFlag
from typing import Final
@@ -6,7 +7,8 @@
class FixtureChannelType(IntFlag):
- """Types of channels of a fixture"""
+ """Channels Types of a fixture."""
+
UNDEFINED = 0
RED = 1
GREEN = 2
@@ -24,32 +26,34 @@ class FixtureChannelType(IntFlag):
class FixtureChannel:
- """A channel of a fixture"""
- updated: QtCore.Signal(int) = QtCore.Signal(int)
+ """Channel of a fixture."""
+
+ updated: QtCore.Signal = QtCore.Signal(int)
def __init__(self, name: str) -> None:
+ """Channel of a fixture."""
self._name: Final[str] = name
self._type: Final[FixtureChannelType] = self._get_channel_type_from_string()
self._ignore_black = True
@property
def name(self) -> str:
- """Returns the name of the channel"""
+ """Name of the channel."""
return self._name
@property
def type(self) -> FixtureChannelType:
- """Returns the channel type"""
+ """Type of the channel."""
return self._type
@property
def type_as_list(self) -> list[FixtureChannelType]:
- """export the Fixture Channel Types as a list"""
+ """Export the Fixture Channel Types as a list."""
return [flag for flag in type(self._type) if flag in self._type]
@property
def ignore_black(self) -> bool:
- """ignore this channel blackout"""
+ """Ignore this channel blackout."""
return self._ignore_black
@ignore_black.setter
@@ -57,7 +61,7 @@ def ignore_black(self, ignore_black: bool) -> None:
self._ignore_black = ignore_black
def _get_channel_type_from_string(self) -> FixtureChannelType:
- """Returns the channel type"""
+ """Generate channel types from a string."""
types: FixtureChannelType = FixtureChannelType.UNDEFINED
# TODO vielleicht aus OFL sauber extrahieren
for channel_type in FixtureChannelType:
diff --git a/src/model/universe.py b/src/model/universe.py
index 7bd70ff1..fe51e41d 100644
--- a/src/model/universe.py
+++ b/src/model/universe.py
@@ -1,4 +1,5 @@
-"""DMX Universe"""
+"""DMX Universe."""
+
from typing import Final
import proto.UniverseControl_pb2
@@ -9,13 +10,15 @@
class Universe:
- """DMX universe with 512 channels"""
+ """DMX universe with 512 channels."""
def __init__(self, universe_proto: proto.UniverseControl_pb2.Universe) -> None:
+ """DMX universe with 512 channels."""
self._broadcaster = Broadcaster()
- self._universe_proto: proto.UniverseControl_pb2 = universe_proto
- self._channels: Final[list[Channel]] = [Channel(channel_address) for channel_address in
- range(NUMBER_OF_CHANNELS)]
+ self._universe_proto: proto.UniverseControl_pb2.Universe = universe_proto
+ self._channels: Final[list[Channel]] = [
+ Channel(channel_address) for channel_address in range(NUMBER_OF_CHANNELS)
+ ]
self._name = f"Universe {self.universe_proto.id + 1}"
self._description = self.name
@@ -23,7 +26,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 of the Universe."""
return self._universe_proto
@universe_proto.setter
@@ -32,12 +35,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
@@ -60,8 +63,9 @@ def description(self, description: str) -> None:
@property
def location(
- self) -> int | proto.UniverseControl_pb2.Universe.ArtNet | proto.UniverseControl_pb2.Universe.USBConfig:
- """network location"""
+ self,
+ ) -> int | proto.UniverseControl_pb2.Universe.ArtNet | proto.UniverseControl_pb2.Universe.USBConfig:
+ """Network location."""
if self._universe_proto.remote_location.ip_address != "":
return self._universe_proto.remote_location
if self._universe_proto.ftdi_dongle.vendor_id != "":
diff --git a/src/model/virtual_filters/auto_tracker_filter.py b/src/model/virtual_filters/auto_tracker_filter.py
index 19ce8265..fd7cc03b 100644
--- a/src/model/virtual_filters/auto_tracker_filter.py
+++ b/src/model/virtual_filters/auto_tracker_filter.py
@@ -1,6 +1,12 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
from model import Filter, Scene
from model.filter import DataType, FilterTypeEnumeration, VirtualFilter
-from view.show_mode.show_ui_widgets.autotracker.v_filter_light_controller import VFilterLightController
+
+if TYPE_CHECKING:
+ from view.show_mode.show_ui_widgets.autotracker.v_filter_light_controller import VFilterLightController
class _MHControlInstance:
diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py
new file mode 100644
index 00000000..104aac5e
--- /dev/null
+++ b/src/model/virtual_filters/color_to_colorwheel.py
@@ -0,0 +1,138 @@
+"""Contains ColorToColorwheel vFilter."""
+
+from __future__ import annotations
+
+import os
+from typing import TYPE_CHECKING, override
+
+from jinja2 import Environment
+
+from model.filter import Filter, VirtualFilter, FilterTypeEnumeration
+from utility import resource_path
+
+if TYPE_CHECKING:
+ from jinja2.environment import Template
+
+ from model import Scene
+
+with open(resource_path(os.path.join("resources", "data", "color-to-colorwheel-template.lua.j2")), "r") as f:
+ _FILTER_CONTENT_TEMPLATE: Template = Environment().from_string(f.read())
+
+
+class ColorToColorWheel(VirtualFilter):
+ """A vFilter that takes a color channel as an input and maps it to a color wheel channel."""
+
+ def __init__(self, scene: Scene, filter_id: str, pos: tuple[int] | None = None) -> None:
+ """Initialize the vFilter.
+
+ The following filter configuration properties are available:
+ - mode: Either automatic (the filter loads its color points from the fixture) or manual.
+ - fixture-uuid: The fixture UUID to use in automatic mode.
+ - color-mappings: The color mappings to use in manual mode. Format: h:s:cw;h:s:cw;...
+ - dimmer-input: Should a dimmer input be used? Can either be "8bit", "16bit", "float" or "".
+ - dimmer-output: The data type of the dimmer output channel. Can either be "8bit", "16bit", "float" or "".
+ - colorwheel-datatype: The data type of the color wheel slot. Can either be "8bit" or "16bit".
+ - wheel_speed: Time in ms to switch between two adjacent wheel slots in manual mode.
+ - dim_when_off: If true, the brightness will be suspended as long as the wrong color wheel slot is dialed in.
+
+ The following ports are provided:
+ - input: The color input channel (input, color)
+ - in_dimmer: Optional, Input dimmer mixin (input, 8bit or 16bit or float)
+ - dimmer: Output dimmer channel (output, 8bit or 16bit or float)
+ - colorwheel: The color wheel slot to use (output, 8bit or 16bit)
+
+ Args:
+ scene: The scene to use for this filter.
+ filter_id: The id of the filter to use.
+ pos: Optional, The position of the filter to use.
+
+ """
+ super().__init__(scene, filter_id, FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL, pos=pos)
+ if "mode" not in self._filter_configurations:
+ self.filter_configurations["mode"] = "automatic"
+ if "fixture-uuid" not in self._filter_configurations:
+ self.filter_configurations["fixture-uuid"] = ""
+ if "color-mappings" not in self._filter_configurations:
+ self.filter_configurations["color-mappings"] = ""
+ if "dimmer-input" not in self._filter_configurations:
+ self.filter_configurations["dimmer-input"] = "8bit"
+ if "dimmer-output" not in self._filter_configurations:
+ self.filter_configurations["dimmer-output"] = ""
+ if "colorwheel-datatype" not in self._filter_configurations:
+ self.filter_configurations["colorwheel-datatype"] = "8bit"
+ if not "wheel_speed" in self.filter_configurations:
+ self.filter_configurations["wheel_speed"] = "300"
+ if not "dim_when_off" in self.filter_configurations:
+ self.filter_configurations["dim_when_off"] = "true"
+
+ @override
+ def resolve_output_port_id(self, virtual_port_id: str) -> str | None:
+ if virtual_port_id not in ["dimmer", "colorwheel"]:
+ raise ValueError(f"Invalid virtual port ID. Filter ID: {self.filter_id}, Requested Port: {virtual_port_id}")
+ return f"{self.filter_id}:{virtual_port_id}"
+
+ @override
+ def instantiate_filters(self, filter_list: list[Filter]) -> None:
+ if self.filter_configurations["mode"] == "automatic":
+ # TODO
+ raise ValueError(f"Automatic mode is currently not implemented. Filter ID: {self.filter_id}")
+ input_dimmer_channel = self.channel_links.get("in_dimmer", "")
+ hue_values: list[float] = []
+ saturation_values: list[float] = []
+ slots: list[int] = []
+ required_dimmer_output_data_type = self.filter_configurations["dimmer-output"]
+ dimmer_input_data_type = self.filter_configurations["dimmer-input"]
+
+ colorwheel_is_16bit = self.filter_configurations["colorwheel-datatype"] == "16bit"
+
+ input_dimmer_configured = input_dimmer_channel != ""
+ dimmer_output_required = required_dimmer_output_data_type != ""
+ wheel_speed = self.filter_configurations.get("wheel_speed", "300")
+
+ for mapping_str in self.filter_configurations.get("color-mappings", "").split(";"):
+ hue, saturation, slot = mapping_str.split(":")
+ hue_values.append(float(hue))
+ saturation_values.append(float(saturation))
+ slots.append(int(slot))
+
+ match required_dimmer_output_data_type:
+ case "8bit":
+ output_dimmer_multiplier = "255"
+ case "16bit":
+ output_dimmer_multiplier = "65535"
+ case _:
+ output_dimmer_multiplier = "1"
+
+ match dimmer_input_data_type:
+ case "8bit":
+ input_dimmer_multiplier = "255"
+ case "16bit":
+ input_dimmer_multiplier = "65535"
+ case _:
+ input_dimmer_multiplier = "1"
+
+ script = _FILTER_CONTENT_TEMPLATE.render({
+ "input_dimmer_channel_connected": input_dimmer_configured,
+ "input_dimmer_channel_data_type": dimmer_input_data_type,
+ "input_dimmer_multiplier": input_dimmer_multiplier,
+ "hue_values": hue_values,
+ "saturation_values": saturation_values,
+ "slots": slots,
+ "wheel_speed": wheel_speed,
+ "dimmer_output_required": dimmer_output_required,
+ "output_dimmer_data_type": required_dimmer_output_data_type,
+ "output_dimmer_multiplier": output_dimmer_multiplier,
+ "dim_when_off": self.filter_configurations.get("dim_when_off", "true") == "true",
+ "colorwheel_datatype": self.filter_configurations.get("colorwheel-datatype", "8bit"),
+ })
+
+ f = Filter(self.scene, self.filter_id, FilterTypeEnumeration.FILTER_SCRIPTING_LUA, pos=self.pos)
+ f.initial_parameters["script"] = script
+ f.filter_configurations["in_mapping"] = "input:color;time:float"
+ if input_dimmer_configured:
+ f.filter_configurations["in_mapping"] += f";in_dimmer:{dimmer_input_data_type}"
+ f.filter_configurations["out_mapping"] = f"colorwheel:{"16bit" if colorwheel_is_16bit else "8bit"}"
+ if dimmer_output_required:
+ f.filter_configurations["out_mapping"] += f";dimmer:{required_dimmer_output_data_type}"
+ f.channel_links.update(self.channel_links)
+ filter_list.append(f)
diff --git a/src/view/dialogs/fixture_dialog.py b/src/view/dialogs/fixture_dialog.py
new file mode 100644
index 00000000..9472baf4
--- /dev/null
+++ b/src/view/dialogs/fixture_dialog.py
@@ -0,0 +1,141 @@
+"""Dialog for editing Fixtures."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+from PySide6.QtWidgets import (
+ QColorDialog,
+ QComboBox,
+ QDialog,
+ QGridLayout,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QSpinBox,
+ QVBoxLayout,
+)
+
+import style
+from model.universe import NUMBER_OF_CHANNELS
+
+if TYPE_CHECKING:
+ from PySide6.QtGui import QColor
+ from PySide6.QtWidgets import QWidget
+
+ from model import BoardConfiguration
+ from model.ofl.fixture import UsedFixture
+
+
+class FixtureDialog(QDialog):
+ """Dialog for editing Fixtures."""
+
+ def __init__(self, fixture: UsedFixture, board_configuration: BoardConfiguration, parent: QWidget = None) -> None:
+ """Dialog for editing Fixtures."""
+ super().__init__(parent)
+ self._fixture: UsedFixture = fixture
+ self._board_configuration: BoardConfiguration = board_configuration
+
+ layout = QVBoxLayout()
+
+ layout_fixture = QGridLayout()
+ layout_fixture.addWidget(QLabel("Fixture name:"), 0, 0)
+ layout_fixture.addWidget(
+ QLabel(self._fixture.short_name if self._fixture.short_name else self._fixture.name), 0, 1
+ )
+
+ layout_fixture.addWidget(QLabel("Anzeigename"), 1, 0)
+ self._name_on_stage = QLineEdit(self._fixture.name_on_stage)
+ layout_fixture.addWidget(self._name_on_stage, 1, 1)
+
+ layout_fixture.addWidget(QLabel("Universum"), 2, 0)
+ self._universe = QComboBox()
+ for index, universe in enumerate(self._board_configuration.universes):
+ self._universe.addItem(universe.name, userData=universe)
+ if self._fixture.universe == universe:
+ self._universe.setCurrentIndex(index)
+
+ layout_fixture.addWidget(self._universe, 2, 1)
+
+ layout_fixture.addWidget(QLabel("Start Index"), 3, 0)
+ self._start_index = QSpinBox()
+ self._start_index.setMinimum(1)
+ self._start_index.setMaximum(NUMBER_OF_CHANNELS)
+ self._start_index.setValue(self._fixture.start_index + 1)
+ self._start_index.textChanged.connect(self._validate_input)
+ layout_fixture.addWidget(self._start_index, 3, 1)
+
+ self._color_label = QLabel("Anzeigefarbe")
+ self._selected_color = self._fixture.color_on_stage
+ self._color_label.setStyleSheet(f"background-color: {self._fixture.color_on_stage.name()};")
+ layout_fixture.addWidget(self._color_label, 4, 0)
+ color_button = QPushButton("Farbe wählen")
+ color_button.clicked.connect(self._open_color_picker)
+ layout_fixture.addWidget(color_button, 4, 1)
+
+ layout_error = QHBoxLayout()
+ self._error_label = QLabel("No Error Found!")
+ self._error_label.setFixedHeight(20)
+ self._error_label.setStyleSheet(style.LABEL_OKAY)
+ layout_error.addWidget(self._error_label)
+
+ layout_exit = QHBoxLayout()
+ self._ok_button = QPushButton()
+ self._ok_button.setText("Okay")
+ self._ok_button.clicked.connect(self._ok)
+ _cancel_button = QPushButton()
+ _cancel_button.setText("cancel")
+ _cancel_button.clicked.connect(self._cancel)
+ layout_exit.addWidget(_cancel_button)
+ layout_exit.addWidget(self._ok_button)
+
+ layout.addLayout(layout_fixture)
+ layout.addLayout(layout_error)
+ layout.addLayout(layout_exit)
+ self.setLayout(layout)
+
+ def _ok(self) -> None:
+ """Handle OK clicked."""
+ self._fixture.color_on_stage = self._selected_color
+ self._fixture.start_index = int(self._start_index.text()) - 1
+ self._fixture.name_on_stage = self._name_on_stage.text()
+ self._fixture.universe = self._universe.itemData(self._universe.currentIndex())
+ self.accept()
+
+ def _cancel(self) -> None:
+ """Handle Cancel clicked."""
+ self.reject()
+
+ def _open_color_picker(self) -> None:
+ """Open Color picker."""
+ dialog = QColorDialog(self)
+ dialog.setOption(QColorDialog.ColorDialogOption.DontUseNativeDialog, True)
+
+ dialog.colorSelected.connect(self._color_chosen)
+ dialog.open()
+
+ def _color_chosen(self, color: QColor) -> None:
+ """Handle color chosen."""
+ self._selected_color = color
+ self._color_label.setStyleSheet(f"background-color: {color.name()};")
+
+ def _validate_input(self) -> None:
+ """Validate input."""
+ self._ok_button.setEnabled(False)
+ occupied = np.arange(
+ int(self._start_index.value() - 1), int(self._start_index.value() - 1) + self._fixture.channel_length
+ )
+
+ if np.isin(
+ occupied,
+ self._board_configuration.get_occupied_channels(self._universe.itemData(self._universe.currentIndex())),
+ ).any():
+ self._error_label.setText("Channels already occupied!")
+ self._error_label.setStyleSheet(style.LABEL_ERROR)
+ return
+
+ self._error_label.setText("No Error Found!")
+ self._error_label.setStyleSheet(style.LABEL_OKAY)
+ self._ok_button.setEnabled(True)
diff --git a/src/view/dialogs/patching_dialog.py b/src/view/dialogs/patching_dialog.py
index 82a02761..0bf6f2d0 100644
--- a/src/view/dialogs/patching_dialog.py
+++ b/src/view/dialogs/patching_dialog.py
@@ -1,4 +1,4 @@
-"""Dialog for Patching Fixture"""
+"""Dialog for Patching Fixture."""
import re
from dataclasses import dataclass
@@ -6,6 +6,7 @@
import numpy as np
from PySide6 import QtCore, QtGui, QtWidgets
+import style
from model import BoardConfiguration
from model.ofl.fixture import make_used_fixture
from model.ofl.ofl_fixture import OflFixture
@@ -13,9 +14,10 @@
@dataclass
class PatchingInformation:
- """Information for Patching"""
+ """Information for Patching."""
def __init__(self, fixture: OflFixture) -> None:
+ """Information for Patching."""
self._fixture: OflFixture = fixture
self.count: int = 0
self.universe: int = 0
@@ -24,16 +26,17 @@ def __init__(self, fixture: OflFixture) -> None:
@property
def fixture(self) -> OflFixture:
- """property of the Fixture"""
+ """OFL Fixture."""
return self._fixture
class PatchingDialog(QtWidgets.QDialog):
- """Dialog for Patching Fixture"""
+ """Dialog for Patching Fixture."""
def __init__(
self, board_configuration: BoardConfiguration, fixture: tuple[OflFixture, int], parent: object = None
) -> None:
+ """Dialog for Patching Fixture."""
super().__init__(parent)
# Create widgets
self._board_configuration = board_configuration
@@ -41,7 +44,7 @@ def __init__(
layout_fixture = QtWidgets.QHBoxLayout()
self._select_mode = QtWidgets.QComboBox()
- self._select_mode.currentIndexChanged.connect(self._update_used_fixture)
+ self._select_mode.currentIndexChanged.connect(self._validate_input)
layout_fixture.addWidget(QtWidgets.QLabel(fixture[0].name))
layout_fixture.addWidget(self._select_mode)
@@ -63,7 +66,8 @@ def __init__(
patching_layout.addWidget(self._patching)
error_layout = QtWidgets.QHBoxLayout()
- self._error_label = QtWidgets.QLabel("no Error Found")
+ self._error_label = QtWidgets.QLabel("No Error Found!")
+ self._error_label.setStyleSheet(style.LABEL_OKAY)
error_layout.addWidget(self._error_label)
layout_exit = QtWidgets.QHBoxLayout()
@@ -93,26 +97,18 @@ def __init__(
@property
def patching_information(self) -> PatchingInformation:
- """property of used Fixture"""
+ """Patching Information."""
return self._patching_information
- def set_error(self, text: str) -> None:
- """update Error Label"""
- self._error_label.setText(text)
-
- def _update_used_fixture(self) -> None:
- self._validate_input()
-
def generate_fixtures(self) -> None:
- """generate a used Fixture list from Patching information"""
-
+ """Generate a used Fixture list from Patching information."""
start_index = self.patching_information.channel
for _ in range(self.patching_information.count):
used_fixture = make_used_fixture(
self._board_configuration,
self._patching_information.fixture,
self._select_mode.currentIndex(),
- self.patching_information.universe,
+ self._board_configuration.universe(self.patching_information.universe),
start_index,
)
@@ -122,15 +118,15 @@ def generate_fixtures(self) -> None:
start_index += self._patching_information.offset
def _accept(self) -> None:
- """accept the Fixture"""
+ """Handle Accept button."""
self.accept()
def _reject(self) -> None:
- """cancel Patching"""
+ """Handle Cancel button."""
self.reject()
def _validate_input(self) -> None:
- """validate the patching String and update count, universe, channel and offset"""
+ """Validate the patching String and update count, universe, channel and offset."""
patching = self._patching.text()
if patching == "":
patching = "1"
@@ -147,10 +143,12 @@ def _validate_input(self) -> None:
self._ok.setEnabled(False)
if not self._board_configuration.universe(self._patching_information.universe):
- self._error_label.setText("no matching Universes")
+ self._error_label.setText("No matching Universe!")
+ self._error_label.setStyleSheet(style.LABEL_ERROR)
return
if 0 < self._patching_information.offset < channel_count:
- self._error_label.setText("offset to low")
+ self._error_label.setText("Offset to low!")
+ self._error_label.setStyleSheet(style.LABEL_ERROR)
return
start_index = self.patching_information.channel
@@ -161,14 +159,20 @@ def _validate_input(self) -> None:
occupied = (block_starts[:, np.newaxis] + channel_offsets).ravel()
if occupied[-1] > 511:
- self._error_label.setText("not enough channels")
+ self._error_label.setText("Not enough channels!")
+ self._error_label.setStyleSheet(style.LABEL_ERROR)
return
if np.isin(
- occupied, self._board_configuration.get_occupied_channels(self._patching_information.universe)
+ occupied,
+ self._board_configuration.get_occupied_channels(
+ self._board_configuration.universe(self._patching_information.universe)
+ ),
).any():
- self._error_label.setText("channels already occupied")
+ self._error_label.setText("Channels already occupied!")
+ self._error_label.setStyleSheet(style.LABEL_ERROR)
return
- self._error_label.setText("No Error Found")
+ self._error_label.setText("No Error Found!")
+ self._error_label.setStyleSheet(style.LABEL_OKAY)
self._ok.setEnabled(True)
diff --git a/src/view/dialogs/universe_dialog.py b/src/view/dialogs/universe_dialog.py
index 2bff447a..83075f99 100644
--- a/src/view/dialogs/universe_dialog.py
+++ b/src/view/dialogs/universe_dialog.py
@@ -1,14 +1,20 @@
-"""dialog for editing patching universe"""
+"""Dialog for editing patching universe."""
+
+from typing import Any
+
from PySide6 import QtWidgets
+from PySide6.QtWidgets import QWidget
import proto.UniverseControl_pb2
class UniverseDialog(QtWidgets.QDialog):
- """dialog for editing patching universe"""
+ """Dialog for editing patching universe."""
- def __init__(self, patching_universe_or_id: proto.UniverseControl_pb2.Universe | int,
- parent: object = None) -> None:
+ def __init__(
+ self, patching_universe_or_id: proto.UniverseControl_pb2.Universe | int, parent: QWidget = None
+ ) -> None:
+ """Dialog for editing patching universe."""
super().__init__(parent)
if isinstance(patching_universe_or_id, int):
patching_proto: proto.UniverseControl_pb2.Universe = proto.UniverseControl_pb2.Universe(
@@ -34,12 +40,18 @@ def __init__(self, patching_universe_or_id: proto.UniverseControl_pb2.Universe |
self._switch_button.clicked.connect(self._change_widget)
ftdi_dongle = patching_proto.ftdi_dongle
- ftdi_items = [["vendor id", ftdi_dongle.vendor_id], ["product id", ftdi_dongle.product_id],
- ["serial", ftdi_dongle.serial], ["device_name", ftdi_dongle.device_name]]
+ ftdi_items = [
+ ["vendor id", ftdi_dongle.vendor_id],
+ ["product id", ftdi_dongle.product_id],
+ ["serial", ftdi_dongle.serial],
+ ["device_name", ftdi_dongle.device_name],
+ ]
remote_location = patching_proto.remote_location
- art_net_items: list[list[str, any]] = [["ip address", remote_location.ip_address],
- ["port", remote_location.port],
- ["universe on device", remote_location.universe_on_device]]
+ art_net_items: list[list[str, any]] = [
+ ["ip address", remote_location.ip_address],
+ ["port", remote_location.port],
+ ["universe on device", remote_location.universe_on_device],
+ ]
ftdi_widget, self._ftdi_widgets = _generate_widget(ftdi_items, "ftdi dongle")
art_net_widget, self._remote_location_widgets = _generate_widget(art_net_items, "art net")
@@ -74,7 +86,7 @@ def _change_widget(self) -> None:
self._widgets.setCurrentIndex(0)
def ok(self) -> None:
- """accept the universe"""
+ """Handle Ok button."""
if self._widgets.currentIndex() == 0:
# art net
self.output = proto.UniverseControl_pb2.Universe(
@@ -99,11 +111,12 @@ def ok(self) -> None:
self.accept()
def cancel(self) -> None:
- """cancel universe"""
+ """Handle cancel button."""
self.reject()
-def _generate_widget(items: list[list[str, any]], name: str) -> tuple[QtWidgets.QWidget, list[QtWidgets.QLineEdit]]:
+def _generate_widget(items: list[tuple[str, Any]], name: str) -> tuple[QtWidgets.QWidget, list[QtWidgets.QLineEdit]]:
+ """Generate a widget for a patching universe."""
output = QtWidgets.QWidget()
layout = QtWidgets.QGridLayout()
widgets = []
diff --git a/src/view/patch_view/patch_plan/channel_item_generator.py b/src/view/patch_view/patch_plan/channel_item_generator.py
index 01a53c34..9801fb90 100644
--- a/src/view/patch_view/patch_plan/channel_item_generator.py
+++ b/src/view/patch_view/patch_plan/channel_item_generator.py
@@ -1,22 +1,29 @@
-""" item of the Patching """
+"""Channel items of the Patching."""
+
from PySide6.QtGui import QColor, QColorConstants, QFont, QPainter, QPixmap
_WIDTH: int = 100
_HEIGHT: int = 100
+_SPACING: int = 1
-def item_width() -> int:
- """width of the item"""
+def channel_item_width() -> int:
+ """Width of one Channel item."""
return _WIDTH
-def item_height() -> int:
- """height of the item"""
+def channel_item_height() -> int:
+ """Height of one Channel item."""
return _HEIGHT
-def create_item(number: int, color: QColor = QColorConstants.White) -> QPixmap:
- """creates a pixmap of the item"""
+def channel_item_spacing() -> int:
+ """Spacing between two Channel items."""
+ return _SPACING
+
+
+def create_item(number: int, color: QColor) -> QPixmap:
+ """Create a pixmap of a Channel item."""
pixmap = QPixmap(_WIDTH, _HEIGHT)
pixmap.fill(color)
diff --git a/src/view/patch_view/patch_plan/patch_base_item.py b/src/view/patch_view/patch_plan/patch_base_item.py
new file mode 100644
index 00000000..ac5bda84
--- /dev/null
+++ b/src/view/patch_view/patch_plan/patch_base_item.py
@@ -0,0 +1,40 @@
+"""QGraphicsItem for patching items."""
+
+import math
+from typing import override
+
+from PySide6.QtCore import QRectF
+from PySide6.QtWidgets import QGraphicsItem
+
+from model.universe import NUMBER_OF_CHANNELS
+from view.patch_view.patch_plan.channel_item_generator import (
+ channel_item_height,
+ channel_item_spacing,
+ channel_item_width,
+)
+
+
+class PatchBaseItem(QGraphicsItem):
+ """QGraphicsItem for patching items."""
+
+ _cols = 1
+ _rows = 1
+ _view_width = 100
+
+ def __init__(self) -> None:
+ """Initialize."""
+ super().__init__()
+
+ @classmethod
+ def resize(cls, view_width: int) -> None:
+ """Handle resize the view."""
+ cls._view_width = view_width
+ new_cols = max(1, cls._view_width // (channel_item_width() + channel_item_spacing()))
+ if new_cols != cls._cols:
+ cls._cols = new_cols
+ cls._rows = math.ceil(NUMBER_OF_CHANNELS / new_cols)
+
+ @override
+ def boundingRect(self) -> QRectF:
+ """Bounding rectangle of this item."""
+ return QRectF(0, 0, self._view_width, self._rows * (channel_item_height() + channel_item_spacing()))
diff --git a/src/view/patch_view/patch_plan/patch_plan_selector.py b/src/view/patch_view/patch_plan/patch_plan_selector.py
index 5861aa4b..0fe7d5d5 100644
--- a/src/view/patch_view/patch_plan/patch_plan_selector.py
+++ b/src/view/patch_view/patch_plan/patch_plan_selector.py
@@ -1,27 +1,33 @@
-"""selector for Patching witch holds all Patching Universes"""
+"""Selector for Patching witch holds all Patching Universes."""
+
+from __future__ import annotations
+
+from functools import partial
from logging import getLogger
from typing import TYPE_CHECKING, override
from PySide6 import QtCore, QtGui, QtWidgets
-from PySide6.QtGui import QContextMenuEvent
-from PySide6.QtWidgets import QScrollArea
+from PySide6.QtWidgets import QGraphicsScene
from model import BoardConfiguration, Universe
from model.broadcaster import Broadcaster
-from model.ofl.fixture import UsedFixture
from view.dialogs.universe_dialog import UniverseDialog
-from view.patch_view.patch_plan.patch_plan_widget import PatchPlanWidget
+from view.patch_view.patch_plan.patch_plan_widget import AutoResizeView, PatchPlanWidget
+from view.patch_view.patch_plan.used_fixture_widget import UsedFixtureWidget
if TYPE_CHECKING:
- from view.patch_view.patch_mode import PatchMode
+ from PySide6.QtGui import QContextMenuEvent
+ from model.ofl.fixture import UsedFixture
+ from view.patch_view.patch_mode import PatchMode
logger = getLogger(__name__)
class PatchPlanSelector(QtWidgets.QTabWidget):
- """selector for Patching witch holds all Patching Universes"""
+ """Selector for Patching witch holds all Patching Universes."""
- def __init__(self, board_configuration: BoardConfiguration, parent: "PatchMode") -> None:
+ def __init__(self, board_configuration: BoardConfiguration, parent: PatchMode) -> None:
+ """Selector for Patching witch holds all Patching Universes."""
super().__init__(parent=parent)
self._board_configuration = board_configuration
self._broadcaster = Broadcaster()
@@ -29,7 +35,8 @@ def __init__(self, board_configuration: BoardConfiguration, parent: "PatchMode")
self._broadcaster.delete_universe.connect(self._remove_universe)
self._broadcaster.add_fixture.connect(self._add_fixture)
- self._patch_planes: dict[int, PatchPlanWidget] = {}
+ self._patch_planes: dict[int, AutoResizeView] = {}
+ self._fixture_items: dict[UsedFixture, UsedFixtureWidget] = {}
self.setTabPosition(QtWidgets.QTabWidget.TabPosition.West)
self.addTab(QtWidgets.QWidget(), "+")
@@ -38,19 +45,25 @@ def __init__(self, board_configuration: BoardConfiguration, parent: "PatchMode")
self.tabBar().setCurrentIndex(0)
def _add_fixture(self, fixture: UsedFixture) -> None:
- widget: PatchPlanWidget = self._patch_planes[fixture.parent_universe]
- widget.add_fixture(fixture)
+ new_widget = UsedFixtureWidget(fixture, self._board_configuration)
+ fixture.universe_changed.connect(partial(self._switch_universe, fixture))
+ self._fixture_items[fixture] = new_widget
+ self._patch_planes[fixture.universe.id].scene().addItem(new_widget)
- def _generate_universe(self) -> None:
- """add a new Universe to universe Selector"""
+ def _switch_universe(self, fixture: UsedFixture, old_universe_id: int) -> None:
+ widget = self._fixture_items[fixture]
+ self._patch_planes[old_universe_id].scene().removeItem(widget)
+ self._patch_planes[fixture.universe.id].scene().addItem(widget)
+ def _generate_universe(self) -> None:
+ """Add a new Universe to universe Selector."""
dialog = UniverseDialog(self._board_configuration.next_universe_id())
if dialog.exec():
Universe(dialog.output)
@override
def contextMenuEvent(self, event: QContextMenuEvent) -> None:
- """context menu"""
+ """Context menu."""
for index in range(self.tabBar().count() - 1):
if self.tabBar().tabRect(index).contains(event.pos()):
menu = QtWidgets.QMenu(self)
@@ -73,14 +86,16 @@ def _rename_universe(self, index: int) -> None:
def _add_universe(self, universe: Universe) -> None:
index = self.tabBar().count() - 1
- patch_plan = QScrollArea()
- patch_plan.setWidgetResizable(True)
- patch_plan.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
- patch_plan.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
- widget = PatchPlanWidget()
- patch_plan.setWidget(widget)
- self._patch_planes.update({universe.id: widget})
- self.insertTab(index, patch_plan, str(universe.name))
+ scene = QGraphicsScene(self)
+ view = AutoResizeView(scene)
+ view.setRenderHints(view.renderHints() | QtGui.QPainter.RenderHint.Antialiasing)
+ view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
+ view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+
+ background = PatchPlanWidget()
+ scene.addItem(background)
+ self._patch_planes.update({universe.id: view})
+ self.insertTab(index, view, str(universe.name))
def _remove_universe(self, universe: Universe) -> None:
del self._patch_planes[universe.id]
diff --git a/src/view/patch_view/patch_plan/patch_plan_widget.py b/src/view/patch_view/patch_plan/patch_plan_widget.py
index ce62b388..111274f3 100644
--- a/src/view/patch_view/patch_plan/patch_plan_widget.py
+++ b/src/view/patch_view/patch_plan/patch_plan_widget.py
@@ -1,66 +1,49 @@
-"""patch Plan Widget for one Universe"""
-import math
-from typing import override
+"""Patch Plan Widget for one Universe."""
-from PySide6.QtGui import QPainter, QPaintEvent, QPixmap, QResizeEvent
-from PySide6.QtWidgets import QWidget
+from typing import Final, override
-from model.ofl.fixture import UsedFixture
-from model.universe import NUMBER_OF_CHANNELS
-from view.patch_view.patch_plan.channel_item_generator import create_item, item_height, item_width
-from view.patch_view.patch_plan.used_fixture_widget import UsedFixtureWidget
+from PySide6.QtGui import QColorConstants, QPainter, QPixmap, QResizeEvent
+from PySide6.QtWidgets import QGraphicsView, QStyleOptionGraphicsItem, QWidget
+from model.universe import NUMBER_OF_CHANNELS
+from view.patch_view.patch_plan.channel_item_generator import (
+ channel_item_height,
+ channel_item_spacing,
+ channel_item_width,
+ create_item,
+)
+from view.patch_view.patch_plan.patch_base_item import PatchBaseItem
-class PatchPlanWidget(QWidget):
- """Patch Plan Widget for one Universe"""
- def __init__(self) -> None:
- super().__init__()
- self._chanel_items: list[QPixmap] = []
- self._cols = 1
- self._init_items()
- self._fixtures: list[UsedFixtureWidget] = []
+class AutoResizeView(QGraphicsView):
+ """View, automatically resizes scene."""
- def _init_items(self) -> None:
- """initiate Channel Items"""
- for i in range(1, 513):
- pixmap = create_item(i)
- self._chanel_items.append(pixmap)
+ _base_item = PatchBaseItem()
@override
- def paintEvent(self, _: QPaintEvent) -> None:
- """paint the widget"""
- painter = QPainter(self)
- cols = self.width() // item_width()
- for i, channel_item in enumerate(self._chanel_items):
- x = (i % cols) * item_width()
- y = (i // cols) * item_height()
- painter.drawPixmap(x, y, channel_item)
+ def resizeEvent(self, event: QResizeEvent, /) -> None:
+ """Resize the View."""
+ super().resizeEvent(event)
+ new_width = self.viewport().width()
+ self._base_item.resize(new_width)
+ self.setSceneRect(0, 0, new_width, self._base_item.boundingRect().height())
- for fixture in self._fixtures:
- for i, fixture_channel in enumerate(fixture.pixmap):
- x = ((i + fixture.start_index) % cols) * item_width()
- y = ((i + fixture.start_index) // cols) * item_height()
- painter.drawPixmap(x, y, fixture_channel)
- painter.end()
+class PatchPlanWidget(PatchBaseItem):
+ """Patch Plan Widget for one Universe."""
- @override
- def resizeEvent(self, event: QResizeEvent) -> None:
- """resize the widget"""
- new_cols = max(1, self.width() // item_width())
- if new_cols != self._cols:
- self._cols = new_cols
- self.update_widget_height()
- super().resizeEvent(event)
+ _background_tiles: Final[list[QPixmap]] = [
+ create_item(i + 1, QColorConstants.White) for i in range(NUMBER_OF_CHANNELS)
+ ]
- def update_widget_height(self) -> None:
- """update the widget height"""
- rows = math.ceil(NUMBER_OF_CHANNELS / self._cols)
- self.setFixedHeight(rows * item_height())
+ def __init__(self) -> None:
+ """Patch Plan Widget for one Universe."""
+ super().__init__()
+ self.setZValue(-10)
- def add_fixture(self, fixture: UsedFixture) -> None:
- """add a fixture to the widget"""
- new_fixture = UsedFixtureWidget(fixture)
- self._fixtures.append(new_fixture)
- self.update()
+ @override
+ def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, /, widget: QWidget | None = ...) -> None:
+ for i, channel_item in enumerate(self._background_tiles):
+ x = (i % self._cols) * (channel_item_width() + channel_item_spacing())
+ y = (i // self._cols) * (channel_item_height() + channel_item_spacing())
+ painter.drawPixmap(x, y, channel_item)
diff --git a/src/view/patch_view/patch_plan/used_fixture_widget.py b/src/view/patch_view/patch_plan/used_fixture_widget.py
index a74c803a..0bb97dd8 100644
--- a/src/view/patch_view/patch_plan/used_fixture_widget.py
+++ b/src/view/patch_view/patch_plan/used_fixture_widget.py
@@ -1,36 +1,73 @@
-"""A Used Fixture in the patching view"""
-from PySide6.QtGui import QColorConstants, QFont, QPainter, QPixmap
-from PySide6.QtWidgets import QWidget
+"""A Used Fixture in the patching view."""
-from model.ofl.fixture import UsedFixture
-from view.patch_view.patch_plan.channel_item_generator import create_item
+from __future__ import annotations
+import math
+from typing import TYPE_CHECKING, override
-class UsedFixtureWidget(QWidget):
- """
- UI Widget of a Used Fixture
- """
+from PySide6 import QtWidgets
+from PySide6.QtGui import QAction, QColorConstants, QContextMenuEvent, QFont, QPainter, QPainterPath, QPixmap
+from PySide6.QtWidgets import QGraphicsItem, QStyleOptionGraphicsItem, QWidget
- def __init__(self, fixture: UsedFixture) -> None:
+from view.dialogs.fixture_dialog import FixtureDialog
+from view.patch_view.patch_plan.channel_item_generator import (
+ channel_item_height,
+ channel_item_spacing,
+ channel_item_width,
+ create_item,
+)
+from view.patch_view.patch_plan.patch_plan_widget import PatchBaseItem
+
+if TYPE_CHECKING:
+ from model import BoardConfiguration
+ from model.ofl.fixture import UsedFixture
+
+
+class UsedFixtureWidget(PatchBaseItem):
+ """UI Widget of a Used Fixture."""
+
+ def __init__(self, fixture: UsedFixture, board_configuration: BoardConfiguration) -> None:
+ """UI Widget of a Used Fixture."""
super().__init__()
- self._fixture = fixture
+ self._fixture: UsedFixture = fixture
+ self._board_configuration: BoardConfiguration = board_configuration
+ self._shape_path = QPainterPath()
self._channels_static: list[QPixmap] = []
- fixture.static_data_changed.connect(self._build_static_pixmap)
+ fixture.static_data_changed.connect(self._rebild)
+ self._rebild()
+ self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
- for chanel_index in range(fixture.channel_length):
- self._channels_static.append(self._build_static_pixmap(chanel_index))
+ @override
+ def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, /, widget: QWidget | None = ...) -> None:
+ x = (self._fixture.start_index % self._cols) * (channel_item_width() + channel_item_spacing())
+ y = math.floor(self._fixture.start_index / self._cols) * (channel_item_height() + channel_item_spacing())
+ self._shape_path = QPainterPath()
+ for channel_item in self._channels_static:
+ if x + channel_item_width() > self._view_width:
+ x = 0
+ y += channel_item_height() + channel_item_spacing()
+ painter.drawPixmap(x, y, channel_item)
+ self._shape_path.addRect(x, y, channel_item_width(), channel_item_height())
+ x += channel_item_width() + channel_item_spacing()
+
+ @override
+ def shape(self) -> QPainterPath:
+ return self._shape_path
- @property
- def pixmap(self) -> list[QPixmap]:
- """pixmap of the widget"""
- return self._channels_static
+ @override
+ def contextMenuEvent(self, event: QContextMenuEvent) -> None:
+ """Context Menu."""
+ menu = QtWidgets.QMenu()
+ action_modify = QAction("Bearbeiten", menu)
- @property
- def start_index(self) -> int:
- """start index of the fixture"""
- return self._fixture.start_index
+ action_modify.triggered.connect(self._modify_fixture)
+
+ menu.addAction(action_modify)
+
+ menu.exec(event.screenPos())
def _build_static_pixmap(self, channel_id: int) -> QPixmap:
+ """Build Static Pixmap for one Cannel."""
pixmap = create_item(self._fixture.start_index + channel_id + 1, self._fixture.color_on_stage)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
@@ -38,9 +75,21 @@ def _build_static_pixmap(self, channel_id: int) -> QPixmap:
painter.setPen(QColorConstants.Black)
font = QFont("Arial", 10)
painter.setFont(font)
- painter.drawText(5, 35, str(self._fixture.short_name) if self._fixture.short_name else str(self._fixture.name))
+ painter.drawText(5, 35, self._fixture.short_name if self._fixture.short_name else self._fixture.name)
painter.drawText(5, 50, str(self._fixture.get_fixture_channel(channel_id).name))
painter.drawText(5, 65, str(self._fixture.name_on_stage))
painter.end()
return pixmap
+
+ def _rebild(self) -> None:
+ """Rebuild all Channel Pixmap's."""
+ self._channels_static: list[QPixmap] = []
+ for chanel_index in range(self._fixture.channel_length):
+ self._channels_static.append(self._build_static_pixmap(chanel_index))
+ self.update()
+
+ def _modify_fixture(self) -> None:
+ """Modify clicked Fixture."""
+ self._dialog = FixtureDialog(self._fixture, self._board_configuration)
+ self._dialog.show()
diff --git a/src/view/patch_view/patching/patching_select.py b/src/view/patch_view/patching/patching_select.py
index abc338af..5ad2bb57 100644
--- a/src/view/patch_view/patching/patching_select.py
+++ b/src/view/patch_view/patching/patching_select.py
@@ -2,16 +2,14 @@
from __future__ import annotations
-import os
-import zipfile
from logging import getLogger
from typing import TYPE_CHECKING
-import requests
from PySide6 import QtWidgets
import style
from layouts.flow_layout import FlowLayout
+from model.ofl.fixture_library_loader import ensure_standard_fixture_library_exists
from model.ofl.manufacture import Manufacture, generate_manufacturers
from view.dialogs.patching_dialog import PatchingDialog
from view.patch_view.patching.fixture_item import FixtureItem
@@ -32,24 +30,9 @@ class PatchingSelect(QtWidgets.QScrollArea):
def __init__(self, board_configuration: BoardConfiguration, parent: QWidget) -> None:
super().__init__(parent)
self._board_configuration = board_configuration
- cache_path = "/var/cache/missionDMX"
- if not os.path.exists(cache_path):
- os.mkdir(cache_path)
- fixtures_path = os.path.join(cache_path, "fixtures/")
- zip_path = os.path.join(cache_path, "fixtures.zip")
- if not os.path.exists(fixtures_path):
- logger.info("Downloading fixture library. Please wait")
- url = "https://open-fixture-library.org/download.ofl"
- r = requests.get(url, allow_redirects=True, timeout=5)
- if r.status_code != 200:
- logger.error("Failed to download fixture library")
- return
-
- with open(zip_path, "wb") as file:
- file.write(r.content)
- with zipfile.ZipFile(zip_path) as zip_ref:
- zip_ref.extractall(fixtures_path)
- logger.info("Fixture lib downloaded and installed.")
+ library_exists, fixtures_path = ensure_standard_fixture_library_exists()
+ if not library_exists:
+ return
manufacturers: list[tuple[Manufacture, list[OflFixture]]] = generate_manufacturers(fixtures_path)
self.index = 0
self.container = QtWidgets.QStackedWidget()
diff --git a/src/view/utility_widgets/wizzards/patch_plan_export.py b/src/view/utility_widgets/wizzards/patch_plan_export.py
index 85ce7cde..0326db4b 100644
--- a/src/view/utility_widgets/wizzards/patch_plan_export.py
+++ b/src/view/utility_widgets/wizzards/patch_plan_export.py
@@ -1,6 +1,4 @@
-"""
-This file provides a wizard to automatically export the configured patch plan as a spreadsheet.
-"""
+"""Wizard for automatically exporting the configured patch plan as a spreadsheet."""
import csv
import os.path
@@ -31,13 +29,10 @@
class PatchPlanExportWizard(QWizard):
- """
- This wizard guides the user to export the patching configuration as a CSV file, providing a power distribution
- guide in the process.
- """
+ """Wizard to export the patching configuration as a CSV file, including a power distribution guide."""
def __init__(self, parent: QWidget, show_data: BoardConfiguration) -> None:
- """Instantiate a new wizard object"""
+ """Wizard to export the patching configuration as a CSV file, including a power distribution guide."""
super().__init__(parent)
self.setModal(True)
self.setMinimumSize(600, 300)
@@ -92,19 +87,16 @@ def __init__(self, parent: QWidget, show_data: BoardConfiguration) -> None:
self._show = show_data
def _select_export_location(self) -> None:
- """This button callback prompts the user to select an CSV file export destination."""
+ """Prompt the user to select a CSV file export destination."""
self._file_selection_dialog.show()
def _export_location_selected(self, file_name: str) -> None:
- """This callback applies the path of the user-selected location into the text box."""
+ """Apply the path of the user-selected location to the text box."""
self._export_location_tb.setText(file_name)
self._first_page.completeChanged.emit()
def _load_fixture_list(self, _: ComposableWizardPage) -> None:
- """
- This method loads all available fixtures into the list widget,
- prompting the user to select the one desired for export.
- """
+ """Load all available fixtures into the list widget and prompt the user to select one for export."""
for fixture in self._show.fixtures:
item = AnnotatedListWidgetItem(self._fixture_list)
item.setText(str(fixture))
@@ -113,7 +105,7 @@ def _load_fixture_list(self, _: ComposableWizardPage) -> None:
item.setCheckState(Qt.CheckState.Checked)
def _commit_changes(self, _: ComposableWizardPage) -> bool:
- """After the user finished the wizard, the export will be generated using this method."""
+ """Generate the export after the user finishes the wizard."""
pn = get_process_notifier("Export Fixtures to CSV list", 3)
pn.current_step_description = "Loading Fixtures"
pn.current_step_number = 0
@@ -148,8 +140,8 @@ def _commit_changes(self, _: ComposableWizardPage) -> bool:
def _write_csv_file(
self, fixtures: list[UsedFixture], phase_association: dict[UsedFixture, int], phases: Counter[int]
) -> None:
- """Given a fixture list with its phase schedule, this method writes the CSV file."""
- fixtures.sort(key=lambda f: f.universe_id * 512 + f.start_index)
+ """Write a CSV file for a fixture list with its phase schedule."""
+ fixtures.sort(key=lambda f: f.universe.id * 512 + f.start_index)
with open(self._export_location_tb.text(), "w", newline="") as csv_file:
logger.info("Exporting Fixtures as CSV to %s.", csv_file.name)
writer = csv.writer(csv_file, delimiter=";")
@@ -170,7 +162,7 @@ def _write_csv_file(
[
fixture.name_on_stage or fixture.name,
fixture.fixture_file,
- str(fixture.universe_id),
+ str(fixture.universe.id),
str(fixture.start_index),
f"L{fixture_phase + 1}",
str(fixture.power),
@@ -181,12 +173,13 @@ def _write_csv_file(
def _schedule_phases(
self, fixtures: list[UsedFixture], phase_association: dict[UsedFixture, int], phases: Counter[int]
) -> None:
- """
- This method distributes the fixtures on the available power phases.
+ """Distribute fixtures on the available power phases.
+
+ Args:
+ fixtures: The list of fixtures to use.
+ phase_association: The mapping of fixtures to their phases. This dictionary will be filled in.
+ phases: The load on each power phase in watts.
- :param fixtures: The fixture list to use
- :param phase_association: The mapping of fixtures to their phases. This dictionary will be filled in.
- :param phases: The load on each power phase in Watt.
"""
number_of_phases = self._number_phases_sb.value()
fixtures.sort(key=lambda f: f.physical.power, reverse=True)
diff --git a/submodules/docs b/submodules/docs
index 3075205e..0dfa250a 160000
--- a/submodules/docs
+++ b/submodules/docs
@@ -1 +1 @@
-Subproject commit 3075205ea28b7ca999aeb8cb64b56f5f64af93a3
+Subproject commit 0dfa250a78d14f2bfe80b678021a49a5a5ae93a7
diff --git a/submodules/resources b/submodules/resources
index 9df13559..b2d72dd3 160000
--- a/submodules/resources
+++ b/submodules/resources
@@ -1 +1 @@
-Subproject commit 9df1355968c6ef9d3b21555f3cdbac8245b96754
+Subproject commit b2d72dd3958ebae00245be69380248124c2b15eb
diff --git a/test/unittests/.gitignore b/test/unittests/.gitignore
new file mode 100644
index 00000000..f85c6b18
--- /dev/null
+++ b/test/unittests/.gitignore
@@ -0,0 +1 @@
+config.py
\ No newline at end of file
diff --git a/test/unittests/config.sample.py b/test/unittests/config.sample.py
new file mode 100644
index 00000000..c4c41659
--- /dev/null
+++ b/test/unittests/config.sample.py
@@ -0,0 +1 @@
+FISH_EXEC_PATH = "../realtime-fish/bin/fish"
\ No newline at end of file
diff --git a/test/unittests/fixture_inst_test.py b/test/unittests/fixture_inst_test.py
new file mode 100644
index 00000000..00f11385
--- /dev/null
+++ b/test/unittests/fixture_inst_test.py
@@ -0,0 +1,82 @@
+import json
+import os
+import shutil
+import unittest
+import urllib.request
+from logging import getLogger
+
+from model import BoardConfiguration, Scene
+from model.filter import FilterTypeEnumeration
+from model.ofl.fixture import load_fixture, UsedFixture
+from model.ofl.fixture_library_loader import ensure_standard_fixture_library_exists
+from model.scene import FilterPage
+from test.unittests.test_universe import TestUniverse
+from view.show_mode.editor.show_browser.fixture_to_filter import place_fixture_filters_in_scene
+
+TEST_FIXTURE_SET_URL = "https://raw.githubusercontent.com/OpenLightingProject/open-fixture-library/refs/heads/master/tests/test-fixtures.json"
+
+logger = getLogger(__name__)
+
+class FixtureInstantiationTest(unittest.TestCase):
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ tmp_fixture_lib_prefix = "/tmp/missiondmx-unittests/fixturetests"
+ logger.info("Initializing FixtureInstantiationTest (Downloading fixtures to %s)", tmp_fixture_lib_prefix)
+ if os.path.exists(tmp_fixture_lib_prefix):
+ shutil.rmtree(tmp_fixture_lib_prefix)
+ os.makedirs(tmp_fixture_lib_prefix)
+ with urllib.request.urlopen(TEST_FIXTURE_SET_URL) as f:
+ self._test_fixture_set = json.load(f)
+ self._library_exists, self._library_path = ensure_standard_fixture_library_exists(prefix=tmp_fixture_lib_prefix)
+ logger.info("Done.")
+
+ def test_set_fixtures(self):
+ """This test simply ensurers that all capabilities can be instantiated."""
+ self.assertTrue(self._library_exists)
+ for fixture_request in self._test_fixture_set:
+ name = fixture_request["key"] + ".json"
+ manufacturer = fixture_request["man"]
+ capabilities = fixture_request["features"]
+ print("Testing fixture ", name)
+ bc = BoardConfiguration()
+ scene = Scene(0, f"Test scene for fixture {name}", bc)
+ bc._add_scene(scene)
+ fp = FilterPage(scene)
+ scene.insert_filterpage(fp)
+ fixture_template = load_fixture(os.path.join(self._library_path, manufacturer, name))
+ model_index = len(fixture_template.modes) - 1
+ universe = TestUniverse()
+ used_fixture = UsedFixture(bc, fixture_template, model_index, universe, 1)
+ place_fixture_filters_in_scene(used_fixture, fp)
+ self.assertTrue(len(fp.filters) > 0)
+ found_required_filter = False
+ for filter in fp.filters:
+ if filter.filter_type == FilterTypeEnumeration.VFILTER_UNIVERSE:
+ found_required_filter = True
+ self.assertTrue(found_required_filter, "Expecting filter inst to produce Universe output VFilter for fixture.")
+ if "fine-channel-alias" in capabilities:
+ found_required_filter = False
+ for filter in fp.filters:
+ if filter.filter_type == FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT:
+ found_required_filter = True
+ self.assertTrue(found_required_filter, "Expecting a 16bit to dual 8bit filter as fine channels exist. "
+ "Found the following filter types: " + ", ".join(
+ str(FilterTypeEnumeration(f.filter_type).name) for f in fp.filters
+ ))
+
+ def test_segment_group_loading(self):
+ # TODO Matrix channel inserts should automatically group their channels in order to identify colors
+ # fixture capabilities should be grouped and instantiation should use them. All Non-grouped channels should be
+ # part of a single default group.
+ self.assertTrue(False, "This test is not yet implemented.")
+
+ def test_instantiation_of_fine_channels(self):
+ # TODO for a given fixture, the pan/tilt channels should be grouped accordingly based on their "fineChannelAliases"
+ # TODO Make sure that only channels with capability.type=Pan or capability.type=Tilt get the pan tilt constant
+ # TODO Make sure that channels with capability.type="WheelSlot" and matching wheel name with color filters receive a colo2colorwheel v-filter
+ self.assertTrue(False, "This test is not yet implemented.")
+
+ def test_gobo_resource_loading(self):
+ # TODO load a fixture with gobos (for example spot 230) and make sure the gobos are available as an asset afterwards
+ self.assertTrue(False, "This test is not yet implemented.")
diff --git a/test/unittests/test_universe.py b/test/unittests/test_universe.py
new file mode 100644
index 00000000..d547e8eb
--- /dev/null
+++ b/test/unittests/test_universe.py
@@ -0,0 +1,14 @@
+from model import Universe
+import proto.UniverseControl_pb2
+
+issued_test_universes = 0
+
+class TestUniverse(Universe):
+ """Test Universe mockup"""
+
+ def __init__(self):
+ global issued_test_universes
+ definition = proto.UniverseControl_pb2.Universe(id=issued_test_universes)
+ #definition.name = "Test Universe"
+ issued_test_universes += 1
+ super().__init__(definition)