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)