diff --git a/src/main.py b/src/main.py index 2dc7556d..11bf6fbe 100644 --- a/src/main.py +++ b/src/main.py @@ -2,12 +2,13 @@ if __name__ == "__main__": import os + import sys from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QSplashScreen QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseDesktopOpenGL) - app = QApplication([]) + app = QApplication(sys.argv) from PySide6.QtGui import QPixmap from utility import resource_path @@ -44,7 +45,6 @@ import logging.config import logging.handlers import pathlib - import sys from PySide6.QtCore import QEventLoop diff --git a/src/model/filter.py b/src/model/filter.py index 696f6874..93bd3533 100644 --- a/src/model/filter.py +++ b/src/model/filter.py @@ -82,6 +82,7 @@ class FilterTypeEnumeration(IntFlag): Negative values indicate virtual filters. """ + VFILTER_DIMMER_BRIGHTNESS_MIXIN = -13 VFILTER_SEQUENCER = -12 VFILTER_COLOR_MIXER = -11 VFILTER_IMPORT = -10 diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index 129b197e..035d5141 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -61,7 +61,10 @@ def load_fixture(file: str) -> OflFixture | None: logger.error("Fixture definition %s not found.", file) return None with open(file, "r", encoding="UTF-8") as f: - ob: dict = json.load(f) + try: + ob: dict = json.load(f) + except json.decoder.JSONDecodeError as e: + logger.error("Fixture definition (%s) JSON error: %s", file, e) ob.update({"fileName": file.split("/fixtures/")[1]}) return OflFixture.model_validate(ob) diff --git a/src/model/virtual_filters/range_adapters.py b/src/model/virtual_filters/range_adapters.py index e92793ea..1575c340 100644 --- a/src/model/virtual_filters/range_adapters.py +++ b/src/model/virtual_filters/range_adapters.py @@ -115,6 +115,219 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: filter_list.append(filter_) +class DimmerGlobalBrightnessMixinVFilter(VirtualFilter): + """V-Filter that allows brightness mixin for 8bit and 16bit values. + + The filter allows the configuration of an input and a mixin input channel. + Their defaults are the global brightness and a constant 1. + If they're connected their input data typed can both be configured as either 8bit or 16bit. + The optional offset input channel needs to be a float. Reasonable values range from (-1, 1). + + The outputs can be configured as 8bit or 16bit. + """ + + def __init__(self, scene: Scene, filter_id: str, pos: tuple[int, int] | None = None) -> None: + """Instantiate a new dimmer brightness mixin vfilter.""" + super().__init__(scene, filter_id, FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN, pos=pos) + self._configuration_supported = True + self.filter_configurations.setdefault("has_16bit_output", "true") + self.filter_configurations.setdefault("has_8bit_output", "true") + self.filter_configurations.setdefault("input_method", "8bit") + self.filter_configurations.setdefault("input_method_mixin", "8bit") + self._out_data_types["dimmer_out8b"] = DataType.DT_8_BIT + self._out_data_types["dimmer_out16b"] = DataType.DT_16_BIT + self._in_data_types["offset"] = DataType.DT_DOUBLE + self.deserialize() + + @override + def resolve_output_port_id(self, virtual_port_id: str) -> str | None: + out_16b = self.filter_configurations.get("has_16bit_output") == "true" + out_8b = self.filter_configurations.get("has_8bit_output") == "true" + if virtual_port_id == "dimmer_out8b": + if out_8b and out_16b: + return f"{self.filter_id}_16b_downsampler:value_upper" + if out_8b: + return f"{self._filter_id}_8b_range:value" + raise ValueError(f"Requested 8bit output port but 8bit output is disabled. Filter ID: {self.filter_id}") + if virtual_port_id == "dimmer_out16b": + if out_16b: + return f"{self._filter_id}_16b_range:value" + raise ValueError(f"Requested 16bit output port but 16bit output is disabled. Filter ID: {self.filter_id}") + raise ValueError("Unknown output port") + + @override + def instantiate_filters(self, filter_list: list[Filter]) -> None: + out_16b = self.filter_configurations.get("has_16bit_output") == "true" + out_8b = self.filter_configurations.get("has_8bit_output") == "true" + needs_8bit_input = self.filter_configurations["input_method"] == "8bit" + required_mixin_input_method = 1 if self.filter_configurations["input_method_mixin"] == "8bit" else 2 + needs_global_brightness_input = self.channel_links.get("input") is None + needs_const_mixin = self.channel_links.get("mixin") is None + needs_offset = self.channel_links.get("offset") is None + + if needs_const_mixin and (not needs_global_brightness_input or not needs_offset): + const_mixin_filter = Filter( + self.scene, + f"{self.filter_id}_const_mixin", + FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + pos=self.pos + ) + const_mixin_filter.initial_parameters["value"] = "1.0" + filter_list.append(const_mixin_filter) + mixin_port_name = f"{self.filter_id}_const_mixin:value" + required_mixin_input_method = 0 + elif needs_const_mixin: + required_mixin_input_method = 0 + mixin_port_name = None + else: + mixin_port_name = self.channel_links.get("mixin") + + if needs_global_brightness_input: + global_brightness_filter = Filter( + self.scene, + f"{self.filter_id}_global_brightness_input", + FilterTypeEnumeration.FILTER_TYPE_MAIN_BRIGHTNESS, + pos=self.pos + ) + filter_list.append(global_brightness_filter) + input_port_name = f"{self.filter_id}_global_brightness_input:brightness" + needs_8bit_input = False + else: + input_port_name = self.channel_links.get("input") + + if needs_8bit_input: + range_8b_to_float_filter = self._generate_8b_to_float_range(filter_list, input_port_name) + input_port_name = range_8b_to_float_filter.resolve_output_port_id("value") + else: + range_16b_to_float_filter = self._generate_16b_to_float_range(filter_list, input_port_name) + input_port_name = range_16b_to_float_filter.resolve_output_port_id("value") + + if required_mixin_input_method == 1: + range_8b_to_float_filter = self._generate_8b_to_float_range(filter_list, mixin_port_name) + mixin_port_name = range_8b_to_float_filter.resolve_output_port_id("value") + elif required_mixin_input_method == 2: + range_16b_to_float_filter = self._generate_16b_to_float_range(filter_list, mixin_port_name) + mixin_port_name = range_16b_to_float_filter.resolve_output_port_id("value") + + if not (needs_global_brightness_input and needs_const_mixin and needs_offset): + if needs_offset: + offset_filter = Filter( + self.scene, + f"{self.filter_id}_offset", + FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + pos=self.pos + ) + offset_filter.initial_parameters["value"] = "0.0" + filter_list.append(offset_filter) + offset_input_port = offset_filter.filter_id + ":value" + else: + offset_input_port = self.channel_links.get("offset") + mac_filter = Filter( + self.scene, + f"{self.filter_id}_mac", + FilterTypeEnumeration.FILTER_ARITHMETICS_MAC, + pos=self.pos + ) + mac_filter.channel_links["factor1"] = input_port_name + mac_filter.channel_links["factor2"] = mixin_port_name + mac_filter.channel_links["summand"] = offset_input_port + filter_list.append(mac_filter) + input_port_name = mac_filter.filter_id + ":value" + + if out_16b: + range16b_out_filter = Filter( + self.scene, + f"{self._filter_id}_16b_range", + FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_16BIT_RANGE, + pos=self.pos, + filter_configurations={ + "lower_bound_in": "0.0", + "upper_bound_in": "1.0", + "lower_bound_out": "0", + "upper_bound_out": "65565", + "limit_range": "1" + } + ) + range16b_out_filter.channel_links["value_in"] = input_port_name + filter_list.append(range16b_out_filter) + if out_8b: + downsampling_filter = Filter( + self.scene, + f"{self._filter_id}_16b_downsampler", + FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT, + pos=self.pos + ) + downsampling_filter.channel_links["value"] = f"{range16b_out_filter.filter_id}:value" + filter_list.append(downsampling_filter) + else: + range8b_out_filter = Filter( + self.scene, + f"{self._filter_id}_8b_range", + FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_8BIT_RANGE, + pos=self.pos, + filter_configurations={ + "lower_bound_in": "0.0", + "upper_bound_in": "1.0", + "lower_bound_out": "0", + "upper_bound_out": "255", + "limit_range": "1" + } + ) + range8b_out_filter.channel_links["value_in"] = input_port_name + filter_list.append(range8b_out_filter) + + def _generate_16b_to_float_range(self, filter_list: list[Filter], input_port_name: str) -> SixteenBitToFloatRange: + range_16b_to_float_filter = SixteenBitToFloatRange( + self.scene, + f"{self.filter_id}_16bit_to_float", + pos=self.pos + ) + range_16b_to_float_filter.filter_configurations.update({ + "lower_bound_in": "0", + "upper_bound_in": "65565", + "lower_bound_out": "0.0", + "upper_bound_out": "1.0", + "limit_range": "1" + }) + range_16b_to_float_filter.channel_links["value_in"] = input_port_name + range_16b_to_float_filter.instantiate_filters(filter_list) + return range_16b_to_float_filter + + def _generate_8b_to_float_range(self, filter_list: list[Filter], input_port_name: str) -> EightBitToFloatRange: + range_8b_to_float_filter = EightBitToFloatRange( + self.scene, + f"{self.filter_id}_8bit_to_float", + pos=self.pos + ) + range_8b_to_float_filter.filter_configurations.update({ + "lower_bound_in": "0", + "upper_bound_in": "255", + "lower_bound_out": "0.0", + "upper_bound_out": "1.0", + "limit_range": "1" + }) + range_8b_to_float_filter.channel_links["value_in"] = input_port_name + range_8b_to_float_filter.instantiate_filters(filter_list) + return range_8b_to_float_filter + + @override + def deserialize(self) -> None: + if self.filter_configurations.get("has_8bit_output") is None: + self.filter_configurations["has_8bit_output"] = "true" + if self.filter_configurations.get("has_16bit_output") is None: + self.filter_configurations["has_16bit_output"] = "false" + if self.filter_configurations.get("input_method") is None: + self.filter_configurations["input_method"] = "16bit" + if self.filter_configurations.get("input_method") == "8bit": + self._in_data_types["input"] = DataType.DT_8_BIT + else: + self._in_data_types["input"] = DataType.DT_16_BIT + if self.filter_configurations.get("input_method_mixin") == "8bit": + self._in_data_types["mixin"] = DataType.DT_8_BIT + else: + self._in_data_types["mixin"] = DataType.DT_16_BIT + + class ColorGlobalBrightnessMixinVFilter(VirtualFilter): """V-Filter that provides the global brightness property.""" diff --git a/src/model/virtual_filters/vfilter_factory.py b/src/model/virtual_filters/vfilter_factory.py index b8494eb0..af7da8eb 100644 --- a/src/model/virtual_filters/vfilter_factory.py +++ b/src/model/virtual_filters/vfilter_factory.py @@ -17,6 +17,7 @@ from model.virtual_filters.pan_tilt_constant import PanTiltConstantFilter from model.virtual_filters.range_adapters import ( ColorGlobalBrightnessMixinVFilter, + DimmerGlobalBrightnessMixinVFilter, EightBitToFloatRange, SixteenBitToFloatRange, ) @@ -51,11 +52,11 @@ def construct_virtual_filter_instance( return None case FilterTypeEnumeration.VFILTER_POSITION_CONSTANT: return PanTiltConstantFilter(scene, filter_id, pos=pos) - + case FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN: + return DimmerGlobalBrightnessMixinVFilter(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_CUES: return CueFilter(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_EFFECTSSTACK: - # TODO implement effects stack virtual filter (as described in issue #87) return EffectsStack(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_AUTOTRACKER: return AutoTrackerFilter(scene, filter_id, pos=pos) diff --git a/src/view/show_mode/editor/filter_settings_item.py b/src/view/show_mode/editor/filter_settings_item.py index 916bb305..e4d7f02f 100644 --- a/src/view/show_mode/editor/filter_settings_item.py +++ b/src/view/show_mode/editor/filter_settings_item.py @@ -29,6 +29,7 @@ from .node_editor_widgets.autotracker_settings import AutotrackerSettingsWidget from .node_editor_widgets.color_mixing_setup_widget import ColorMixingSetupWidget from .node_editor_widgets.column_select import ColumnSelect +from .node_editor_widgets.dimmer_brightness_mixin_config_widget import DimmerBrightnessMixinConfigWidget from .node_editor_widgets.import_vfilter_settings_widget import ImportVFilterSettingsWidget from .node_editor_widgets.lua_widget import LuaScriptConfigWidget from .node_editor_widgets.sequencer_editor.widget import SequencerEditor @@ -133,7 +134,8 @@ def check_if_filter_has_special_widget(filter_: Filter) -> NodeEditorFilterConfi return ColorMixingSetupWidget() if filter_.filter_type == FilterTypeEnumeration.VFILTER_SEQUENCER: return SequencerEditor(f=filter_) - + if filter_.filter_type == FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN: + return DimmerBrightnessMixinConfigWidget() return None diff --git a/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py b/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py new file mode 100644 index 00000000..8ef61aaf --- /dev/null +++ b/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py @@ -0,0 +1,90 @@ +"""Module contains dimmer brightness mixin node config widget.""" + +from typing import override + +from PySide6.QtWidgets import QButtonGroup, QCheckBox, QFormLayout, QHBoxLayout, QLabel, QRadioButton, QWidget + +from view.show_mode.editor.node_editor_widgets import NodeEditorFilterConfigWidget + + +class DimmerBrightnessMixinConfigWidget(NodeEditorFilterConfigWidget): + """Configuration widget for dimmer brightness mixin node.""" + + def __init__(self, parent: QWidget | None = None) -> None: + """Load the filter and prepare a widget.""" + super().__init__() + self._widget = QWidget(parent=parent) + layout = QFormLayout() + self._cb_has_16bit = QCheckBox(self._widget) + self._cb_has_16bit.setText("Enable 16bit output") + self._cb_has_16bit.checkStateChanged.connect(self._update_warning_visibility) + layout.addWidget(self._cb_has_16bit) + self._cb_has_8bit = QCheckBox(self._widget) + self._cb_has_8bit.setText("Enable 8bit output") + self._cb_has_8bit.checkStateChanged.connect(self._update_warning_visibility) + layout.addWidget(self._cb_has_8bit) + self._output_warning_label = QLabel("At least one output should be configured.", self._widget) + self._output_warning_label.setVisible(False) + self._output_warning_label.setStyleSheet("color: red;") + layout.addWidget(self._output_warning_label) + + self._input_method_rb_group = QButtonGroup(self._widget) + self._input_8bit = QRadioButton("8bit", self._widget) + self._input_method_rb_group.addButton(self._input_8bit) + self._input_16bit = QRadioButton("16bit", self._widget) + self._input_method_rb_group.addButton(self._input_16bit) + button_layout = QHBoxLayout() + button_layout.addWidget(self._input_8bit) + button_layout.addWidget(self._input_16bit) + layout.addRow("Input Port Data Type:", button_layout) + + self._mixin_method_rb_group = QButtonGroup(self._widget) + self._mixin_8bit = QRadioButton("8bit", self._widget) + self._mixin_method_rb_group.addButton(self._mixin_8bit) + self._mixin_16bit = QRadioButton("16bit", self._widget) + self._mixin_method_rb_group.addButton(self._mixin_16bit) + button_layout = QHBoxLayout() + button_layout.addWidget(self._mixin_8bit) + button_layout.addWidget(self._mixin_16bit) + layout.addRow("Mixin Port Data Type:", button_layout) + + self._widget.setLayout(layout) + + def _update_warning_visibility(self) -> None: + self._output_warning_label.setVisible(not self._cb_has_8bit.isChecked() and not self._cb_has_16bit.isChecked()) + + @override + def _get_configuration(self) -> dict[str, str]: + return { + "has_16bit_output": "true" if self._cb_has_16bit.isChecked() else "false", + "has_8bit_output": "true" if self._cb_has_8bit.isChecked() else "false", + "input_method": "8bit" if self._input_8bit.isChecked() else "16bit", + "input_method_mixin": "8bit" if self._mixin_8bit.isChecked() else "16bit", + } + + @override + def _load_configuration(self, conf: dict[str, str]) -> None: + self._cb_has_16bit.setChecked(conf.get("has_16bit_output", "false") == "true") + self._cb_has_8bit.setChecked(conf.get("has_8bit_output", "false") == "true") + self._input_8bit.setChecked(conf.get("input_method", "8bit") == "8bit") + self._input_16bit.setChecked(conf.get("input_method", "8bit") == "16bit") + self._mixin_8bit.setChecked(conf.get("input_method_mixin", "8bit") == "8bit") + self._mixin_16bit.setChecked(conf.get("input_method_mixin", "8bit") == "16bit") + + @override + def get_widget(self) -> QWidget: + return self._widget + + @override + def _load_parameters(self, parameters: dict[str, str]) -> dict: + # Nothing to do here + pass + + @override + def _get_parameters(self) -> dict[str, str]: + return {} + + @override + def parent_opened(self) -> None: + # Nothing to do here + pass diff --git a/src/view/show_mode/editor/nodes/filter_node_library.py b/src/view/show_mode/editor/nodes/filter_node_library.py index a9f97cec..1b8e711d 100644 --- a/src/view/show_mode/editor/nodes/filter_node_library.py +++ b/src/view/show_mode/editor/nodes/filter_node_library.py @@ -19,6 +19,7 @@ AdapterFloatToRange, ColorBrightnessMixinNode, CombineTwo8BitToSingle16Bit, + DimmerBrightnessMixinNode, Map8BitTo16Bit, ) from view.show_mode.editor.nodes.impl.arithmetics import ( @@ -159,6 +160,7 @@ def _register_adapters_nodes(self) -> None: self.addNodeType(CombineTwo8BitToSingle16Bit, [("Adapters",)]) self.addNodeType(Map8BitTo16Bit, [("Adapters",)]) self.addNodeType(ColorBrightnessMixinNode, [("Adapters",)]) + self.addNodeType(DimmerBrightnessMixinNode, [("Adapters",)]) def _register_arithmetic_nodes(self) -> None: """Register all the arithmetics nodes.""" diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index 4d693530..e7ec8fab 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -1,14 +1,18 @@ -"""Adapters and converters filter nodes""" +"""Adapters and converters filter nodes.""" +from typing import override + from model import DataType, Scene -from model.filter import Filter, FilterTypeEnumeration +from model.filter import Filter, FilterTypeEnumeration, VirtualFilter from view.show_mode.editor.nodes.base.filternode import FilterNode class Adapter16BitTo8BitNode(FilterNode): """Filter to convert a 16 bit value to two 8 bit values.""" + nodeName = "16 bit to 8 bit converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to 8bit splitter adapter node.""" super().__init__(model=model, filter_type=int(FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT), name=name, terminals={ "value": {"io": "in"}, @@ -23,11 +27,14 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16BitToBoolNode(FilterNode): """Filter to convert a 16 bit value to a boolean. + If input is 0, output is 0, else 1. """ + nodeName = "16 bit to bool converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to boolean adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_BOOL, name=name, terminals={ "value_in": {"io": "in"}, @@ -39,9 +46,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16bitToFloat(FilterNode): + """Node for 16bit to float adapter filter.""" + nodeName = "16bit to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to float adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_TYPE_ADAPTER_16BIT_TO_FLOAT, name=name, terminals={ "value_in": {"io": "in"}, @@ -54,9 +64,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter8bitToFloat(FilterNode): + """Node for 8bit to float adapter filter.""" + nodeName = "8bit to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 8bit to float adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_TYPE_ADAPTER_8BIT_TO_FLOAT, name=name, terminals={ "value_in": {"io": "in"}, @@ -70,9 +83,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBNode(FilterNode): """Filter to convert a color value to a rgb value.""" + nodeName = "Color to rgb converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to rgb adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGB, name=name, terminals={ "value": {"io": "in"}, @@ -89,9 +104,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBWNode(FilterNode): """Filter to convert a color value to a rgbw value.""" + nodeName = "Color to rgb-w converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to rgb-w adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGBW, name=name, terminals={ "value": {"io": "in"}, @@ -110,9 +127,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBWANode(FilterNode): """Filter to convert a color value to a RGBWA value.""" + nodeName = "Color to rgb-wa converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to rgb-wa adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGBWA, name=name, terminals={ "value": {"io": "in"}, @@ -133,9 +152,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatToColorNode(FilterNode): """Filter to convert a float/double value to a color value.""" + nodeName = "Float to color converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Float to color combining converter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_COLOR, name=name, terminals={ "h": {"io": "in"}, @@ -153,9 +174,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToFloatsNode(FilterNode): """Filter that splits the HSI values into three individual float channels.""" + nodeName = "Color to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to Float converter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_FLOAT, name=name, terminals={ "input": {"io": "in"}, @@ -171,13 +194,14 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatToRange(FilterNode): - """Filter maps a range of floats to another range of specific type (template)""" + """Filter maps a range of floats to another range of specific type (template).""" nodeName = "float range to float range" # noqa: N815 def __init__(self, model: Filter | Scene, name: str, filter_type: FilterTypeEnumeration = FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_FLOAT_RANGE) -> None: + """Initialize float range to float range template node.""" super().__init__(model, int(filter_type), name, terminals={ "value_in": {"io": "in"}, "value": {"io": "out"}, @@ -213,10 +237,12 @@ def __init__(self, model: Filter | Scene, name: str, class AdapterFloatTo8BitRange(AdapterFloatToRange): - """Filter maps a range of float to a range of 8bit""" + """Filter maps a range of float to a range of 8bit.""" + nodeName = "Float range to 8bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize float range to 8bit range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_8BIT_RANGE, name=name) try: self.filter.initial_parameters["upper_bound_out"] = model.initial_parameters["upper_bound_out"] @@ -226,10 +252,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatTo16BitRange(AdapterFloatToRange): - """Filter maps a range of float to a range of 16bit""" + """Filter maps a range of float to a range of 16bit.""" + nodeName = "Float range to 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize float range to 16bit range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_16BIT_RANGE, name=name) try: self.filter.initial_parameters["upper_bound_out"] = model.initial_parameters["upper_bound_out"] @@ -239,10 +267,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16BitToRangeFloat(AdapterFloatToRange): - """Filter maps a range of 16bit to a range of float""" + """Filter maps a range of 16bit to a range of float.""" + nodeName = "16bit range to Float" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to float range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_FILTER_ADAPTER_16BIT_TO_FLOAT_RANGE, name=name) try: @@ -254,10 +284,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter8BitToRangeFloat(AdapterFloatToRange): - """Filter maps a range of 8bit to a range of floats""" + """Filter maps a range of 8bit to a range of floats.""" + nodeName = "8bit range to Float" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 8bit to float range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_FILTER_ADAPTER_8BIT_TO_FLOAT_RANGE, name=name) try: @@ -270,9 +302,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class CombineTwo8BitToSingle16Bit(FilterNode): """Filter that combines two 8bit values to a 16bit one.""" + nodeName = "Dual 8bit to single 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Dual 8bit to single 16bit combiner node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_DUAL_BYTE_TO_16BIT, name=name, terminals={ "lower": {"io": "in"}, @@ -287,9 +321,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Map8BitTo16Bit(FilterNode): """Filter that maps an 8-Bit value to a 16-Bit one.""" + nodeName = "Map 8bit to 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Map 8bit to 16bit map node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_8BIT_TO_16BIT, name=name, terminals={ "value_in": {"io": "in"}, @@ -301,9 +337,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class ColorBrightnessMixinNode(FilterNode): + """Node for color brightness mixin v-filter.""" + nodeName = "Color Brightness Mixin" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color Brightness Mixin node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_COLOR_GLOBAL_BRIGHTNESS_MIXIN, name=name, terminals={ "out": {"io": "out"}, @@ -316,3 +355,35 @@ def __init__(self, model: Filter | Scene, name: str) -> None: self.filter.in_data_types["brightness"] = DataType.DT_8_BIT self.channel_hints["brightness"] = "[0-255, optional]" self.filter._configuration_supported = False + + +class DimmerBrightnessMixinNode(FilterNode): + """Node for dimmer brightness mixin v-filter.""" + + nodeName = "Dimmer Brightness Mixin" # noqa: N815 + + def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Dimmer Brightness Mixin node.""" + super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN, name=name, + terminals={"input": {"io": "in"}, "mixin": {"io": "in"}, "offset": {"io": "in"}}) + self.channel_hints["offset"] = "[(-1, 1), optional]" + self.channel_hints["input"] = "[default: global brightness]" + self.channel_hints["mixin"] = "[optional]" + self._update_output_terminals() + + def _update_output_terminals(self) -> None: + for setting, term_name in [("has_8bit_output", "dimmer_out8b"), ("has_16bit_output", "dimmer_out16b")]: + if self.filter.filter_configurations.get(setting) == "true": + if self.outputs().get(term_name) is None: + self.addOutput(term_name) + else: + if self.outputs().get(term_name) is not None: + self.removeTerminal(term_name) + + @override + def update_node_after_settings_changed(self) -> None: + if isinstance(self.filter, VirtualFilter): + self.filter.deserialize() + else: + raise ValueError("Expected filter instance to be a DimmerGlobalBrightnessMixinVFilter, implying a vFilter.") + self._update_output_terminals() diff --git a/src/view/show_mode/editor/nodes/type_to_node_dict.py b/src/view/show_mode/editor/nodes/type_to_node_dict.py index 5114ac21..4a83fc4b 100644 --- a/src/view/show_mode/editor/nodes/type_to_node_dict.py +++ b/src/view/show_mode/editor/nodes/type_to_node_dict.py @@ -18,6 +18,7 @@ AdapterFloatToRange, ColorBrightnessMixinNode, CombineTwo8BitToSingle16Bit, + DimmerBrightnessMixinNode, Map8BitTo16Bit, ) from view.show_mode.editor.nodes.impl.arithmetics import ( @@ -101,6 +102,7 @@ FilterTypeEnumeration.VFILTER_SEQUENCER: SequencerNode.nodeName, FilterTypeEnumeration.VFILTER_COLOR_MIXER: ColorMixerVFilterNode.nodeName, FilterTypeEnumeration.VFILTER_IMPORT: ImportNode.nodeName, + FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN: DimmerBrightnessMixinNode.nodeName, FilterTypeEnumeration.VFILTER_COLOR_GLOBAL_BRIGHTNESS_MIXIN: ColorBrightnessMixinNode.nodeName, FilterTypeEnumeration.VFILTER_POSITION_CONSTANT: PanTiltConstant.nodeName, FilterTypeEnumeration.VFILTER_CUES: CueListNode.nodeName, diff --git a/src/view/show_mode/editor/show_browser/fixture_to_filter.py b/src/view/show_mode/editor/show_browser/fixture_to_filter.py index e2564b97..eb5c042e 100644 --- a/src/view/show_mode/editor/show_browser/fixture_to_filter.py +++ b/src/view/show_mode/editor/show_browser/fixture_to_filter.py @@ -1,4 +1,4 @@ -"""write fixture to file""" +"""write fixture to file.""" from logging import getLogger from typing import Union @@ -6,6 +6,7 @@ from model.filter import FilterTypeEnumeration from model.ofl.fixture import ColorSupport, UsedFixture from model.scene import FilterPage +from model.virtual_filters.range_adapters import DimmerGlobalBrightnessMixinVFilter from model.virtual_filters.vfilter_factory import construct_virtual_filter_instance logger = getLogger(__name__) @@ -26,6 +27,21 @@ def _sanitize_name(input_: str | dict) -> str: def place_fixture_filters_in_scene(fixture: UsedFixture | tuple[UsedFixture, ColorSupport], filter_page: FilterPage, output_map: dict[Union[ ColorSupport, str], str] | None = None) -> bool: + """Generate fixture control filters from a given fixture. + + Purpose of the output map: A fixture has certain features (such as color segments). These features receive special + filters to drive them. Their input ports are placed in this map in order to enable higher level automated tools + (such as the effects stack system) to find them and connect to them. + + Args: + fixture: The fixture to generate filters from. + filter_page: The page to place the new filters in. + output_map: A map that should receive the mapped fixture features. + + Returns: + True if the operation was successful. + + """ # TODO output_map do nothing if isinstance(fixture, tuple): fixture = fixture[0] @@ -116,7 +132,7 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co _sanitize_name(channel.name)] = adapter_name + ":value_upper" fp.filters.append(split_filter) # if output_map is not None: - # output_map[c[c_i]] = split_filter.filter_id + ":value" #TODO + # output_map[c[c_i]] = split_filter.filter_id + ":value" #FIXME already_added_filters.append(split_filter) i += 1 elif channel.name.startswith("Red"): @@ -162,15 +178,24 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co fp.filters.append(rgb_filter) already_added_filters.append(rgb_filter) # if output_map is not None: - # output_map[c[c_i]] = adapter_name + ":value" #TODO + # output_map[c[c_i]] = adapter_name + ":value" # FIXME i += 1 - elif channel.name == "Dimmer": + elif channel.name.lower() == "dimmer" or channel.name.lower() == "intensity": dimmer_name = _sanitize_name(f"dimmer_{i}_{name}") - global_dimmer_filter = Filter(scene=fp.parent_scene, + double_channel_dimmer_required = any( + ("dimmer" in fc.name.lower() or "intensity" in fc.name.lower()) and "fine" in fc.name.lower() + for fc in fixture.fixture_channels) + global_dimmer_filter = DimmerGlobalBrightnessMixinVFilter(scene=fp.parent_scene, filter_id=dimmer_name, - filter_type=49, - pos=(x - 2 * _additional_filter_depth, - compute_filter_height(channel_count, i))) + pos=(int(x - 2 * _additional_filter_depth), + int(compute_filter_height(channel_count, i)))) + if double_channel_dimmer_required: + global_dimmer_filter.filter_configurations["has_16bit_output"] = "true" + global_dimmer_filter.filter_configurations["has_8bit_output"] = "false" + else: + global_dimmer_filter.filter_configurations["has_16bit_output"] = "false" + global_dimmer_filter.filter_configurations["has_8bit_output"] = "true" + global_dimmer_filter.deserialize() added_depth = max(added_depth, 2 * _additional_filter_depth) global_dimmer_found = True fp.filters.append(global_dimmer_filter) @@ -178,18 +203,29 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co already_added_filters.append(global_dimmer_filter) dimmer_name = global_dimmer_filter.filter_id x += 10 - adapter_name = _sanitize_name(f"dimmer2byte_{i}_{name}") - dimmer_to_byte_filter = Filter(scene=fp.parent_scene, - filter_id=adapter_name, - filter_type=8, - pos=(x - _additional_filter_depth, - compute_filter_height(channel_count, i))) - fp.parent_scene.append_filter(dimmer_to_byte_filter) - already_added_filters.append(dimmer_to_byte_filter) - adapter_name = dimmer_to_byte_filter.filter_id - dimmer_to_byte_filter.channel_links["value"] = dimmer_name + ":brightness" - universe_filter.channel_links[_sanitize_name(channel.name)] = adapter_name + ":value_upper" - fp.filters.append(dimmer_to_byte_filter) + + if double_channel_dimmer_required: + adapter_name = _sanitize_name(f"dimmer2byte_{i}_{name}") + dimmer_to_byte_filter = Filter(scene=fp.parent_scene, + filter_id=adapter_name, + filter_type=FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT, + pos=(x - _additional_filter_depth, + compute_filter_height(channel_count, i))) + fp.parent_scene.append_filter(dimmer_to_byte_filter) + already_added_filters.append(dimmer_to_byte_filter) + adapter_name = dimmer_to_byte_filter.filter_id + dimmer_to_byte_filter.channel_links["value"] = dimmer_name + ":dimmer_out16b" + fp.filters.append(dimmer_to_byte_filter) + + if double_channel_dimmer_required: + universe_filter.channel_links[_sanitize_name(channel.name)] = adapter_name + ":value_upper" + for fc in fixture.fixture_channels: + if (("dimmer" in fc.name.lower() or "intensity" in fc.name.lower()) + and "fine" in fc.name.lower()): + universe_filter.channel_links[_sanitize_name(fc.name)] = adapter_name + ":value_lower" + else: + universe_filter.channel_links[_sanitize_name(channel.name)] = dimmer_name + ":dimmer_out8b" + i += 1 except IndexError: continue diff --git a/src/view/show_mode/editor/show_browser/show_browser.py b/src/view/show_mode/editor/show_browser/show_browser.py index 3de3ddf0..a7a50891 100644 --- a/src/view/show_mode/editor/show_browser/show_browser.py +++ b/src/view/show_mode/editor/show_browser/show_browser.py @@ -343,7 +343,14 @@ def _universe_item_double_clicked(self, item: QTreeWidgetItem) -> None: current_widget.refresh() def _upload_showfile(self) -> None: - transmit_to_fish(self._show, False) + try: + transmit_to_fish(self._show, False) + except ValueError as e: + self._input_dialog = QMessageBox(QMessageBox.Icon.Critical, "Uploading Showfile Failed", + "An error occurred while uploading the show file to fish:", + parent=self.widget, detailedText=str(e)) + self._input_dialog.setModal(True) + self._input_dialog.show() def _duplicate_scene(self, selected_items: list[QTreeWidgetItem]) -> None: i: int = 1 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/__init__.py b/test/unittests/__init__.py new file mode 100644 index 00000000..dc2612aa --- /dev/null +++ b/test/unittests/__init__.py @@ -0,0 +1 @@ +"""Unit test collection.""" \ No newline at end of file diff --git a/test/unittests/test_dimmer_brightness_mixin_vfilter.py b/test/unittests/test_dimmer_brightness_mixin_vfilter.py new file mode 100644 index 00000000..42480a73 --- /dev/null +++ b/test/unittests/test_dimmer_brightness_mixin_vfilter.py @@ -0,0 +1,96 @@ +"""Unit test for dimmer brightness mixin.""" +import logging +import unittest +from logging import getLogger, basicConfig + +from model import BoardConfiguration, Scene, Filter +from model.filter import FilterTypeEnumeration +from model.virtual_filters.range_adapters import DimmerGlobalBrightnessMixinVFilter +from test.unittests.utilities import execute_board_configuration + + +logger = getLogger(__name__) + + +class DimmerBrightnessMixinTest(unittest.TestCase): + """Unit test for dimmer brightness mixin.""" + + def _prepare_show_config(self) -> tuple[BoardConfiguration, list[tuple[str, float]]]: + show = BoardConfiguration() + scene = Scene(0, "Test Scene for dimmer brightness mixin", show) + show._add_scene(scene) + output_list = [] + row: int = 0 + + def create_input(method: str, mf: DimmerGlobalBrightnessMixinVFilter, is_mixin: bool) -> str: + mf.filter_configurations["input_method_mixin" if is_mixin else "input_method"] = method.replace("-", "") + if "-" not in method: + input_filter = Filter( + scene, + f"input_filter_{row}_{method}_{"mixin" if is_mixin else "input"}", + FilterTypeEnumeration.FILTER_CONSTANT_8BIT if method == "8bit" else FilterTypeEnumeration.FILTER_CONSTANT_16_BIT, + pos=(-10, row * 15 + (5 if is_mixin else -5)) + ) + input_filter.initial_parameters["value"] = str(255 / 2) if method == "8bit" else str(65565 / 2) + scene.append_filter(input_filter) + mf.channel_links["mixin" if is_mixin else "input"] = f"{input_filter.filter_id}:value" + + def create_output(is_8b: bool): + output_filter = Filter( + scene, f"output_{"8b" if is_8b else "16b"}_{row}", FilterTypeEnumeration.FILTER_REMOTE_DEBUG_8BIT if is_8b else FilterTypeEnumeration.FILTER_REMOTE_DEBUG_16BIT, + pos=(5, row * 15 + (-5 if is_8b else 5)) + ) + scene.append_filter(output_filter) + output_filter.channel_links["value"] = mixin_filter.filter_id + (":dimmer_out8b" if is_8b else ":dimmer_out16b") + expected_output = (255 if is_8b else 65565) * calculate_dimmer_val(input_method, mixin_method, has_offset) + logger.info("Registering output %s => %s.", output_filter.filter_id, expected_output) + output_list.append( + (output_filter.filter_id, expected_output)) + + def calculate_dimmer_val(in_m: str, mixin_m: str, has_offset: bool) -> float: + in_stream = 1.0 if "-" in in_m else 0.5 + mixin_stream = 1.0 + offset_stream = 0.25 if has_offset else 0.0 + return max(0.0, min(1.0, in_stream * mixin_stream + offset_stream)) + + for input_method in ["-8bit", "-16bit", "8bit", "16bit"]: + for mixin_method in ["-8bit", "-16bit", "8bit", "16bit"]: + for output_8b_enabled in [True, False]: + for output_16b_enabled in [True, False]: + for has_offset in [True, False]: + logger.info("Creating Config Row: %s, Input: %s, Mixin: %s, Out 8bit: %s, Out 16bit: %s, has Offset: %s", row, input_method, mixin_method, output_8b_enabled, output_16b_enabled, has_offset) + mixin_filter = DimmerGlobalBrightnessMixinVFilter(scene, f"mixin_filter_r{row}", (0, row * 15)) + create_input(input_method, mixin_filter, False) + create_input(mixin_method, mixin_filter, True) + + if has_offset: + offset_filter = Filter( + scene, f"offset_filter_{row}", FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + pos=(-5, row * 15) + ) + offset_filter.initial_parameters["value"] = "0.25" + scene.append_filter(offset_filter) + mixin_filter.channel_links["offset"] = offset_filter.filter_id + ":value" + + scene.append_filter(mixin_filter) + + if output_8b_enabled: + create_output(True) + if output_16b_enabled: + create_output(False) + row += 1 + return show, output_list + + def test_inst(self): + """Test instanciation and results of v filter.""" + basicConfig(level=logging.DEBUG) + show, expected_output_list = self._prepare_show_config() + recorded_output_list = [] + expected_output_dict = {} + for key, expected_val in expected_output_list: + expected_output_dict[key] = expected_val + self.assertTrue(execute_board_configuration(show, recorded_gui_updates=recorded_output_list)) + for scene_id, filter_id, key, value_str in recorded_output_list: + self.assertEqual(scene_id, 0, "Expected scene ID to be 0.") + self.assertTrue(expected_output_dict[filter_id] - 5 < int(value_str) < expected_output_dict[filter_id] + 5, + f"Expected configuration of {filter_id} to be {expected_output_dict[filter_id]} but got {value_str} instead.") diff --git a/test/unittests/utilities.py b/test/unittests/utilities.py new file mode 100644 index 00000000..0a6d6a5d --- /dev/null +++ b/test/unittests/utilities.py @@ -0,0 +1,118 @@ +"""Unit test utils. + +This module requires a config.py to be in the same package. This config file needs to contain a variable called +FISH_EXEC_PATH pointing to a local fish binary. +""" +import os.path +import subprocess +from logging import getLogger +from time import sleep + +from PySide6.QtWidgets import QApplication + +import proto.FilterMode_pb2 +from controller.file.serializing.general_serialization import create_xml +from controller.network import NetworkManager +from controller.utils.process_notifications import get_process_notifier +from model import BoardConfiguration, Broadcaster + +from .config import FISH_EXEC_PATH + +logger = getLogger(__name__) + +def _stall_until_stdout_reached_target(process: subprocess.Popen, target: str): + while True: + output = process.stdout.readline() + if not output and process.poll() is not None: + return False + if target in output: + return True + + +_last_error_message = "" +_GOOD_MESSAGES = ["No Error occured", "Showfile Applied."] + +def execute_board_configuration(bc: BoardConfiguration, cycles: int = 25, recorded_gui_updates: list[tuple[int, str, str, str]] | None = None, main_brightness: int = 65565) -> bool: + """Execute a board configuration. + + This method starts a fish instance, connects to it, uploads a board configuration and enables filter execution. + If after the specified amount of iterations, no error occurred, the method stops, returning true. Otherwise, false. + + Args: + bc: The board configuration to apply + cycles: The amount of cycles to wait + recorded_gui_updates: If A list is provided, any GUI updates received during execution are stored in there. + main_brightness: The Main brightness value used for the test. + + Returns: + True if no error occurred during execution. Otherwise, false. + + """ + global _last_error_message + _last_error_message = _GOOD_MESSAGES[0] + logger.debug("Starting fish...") + if os.path.exists("/tmp/fish.sock"): + logger.warning("Removing fish socket.") + os.remove("/tmp/fish.sock") + process = subprocess.Popen( + [FISH_EXEC_PATH], + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True + ) + _stall_until_stdout_reached_target(process, "[debug] Entering ev defloop") + logger.debug("Starting QApplication...") + application = QApplication([]) + logger.debug("Starting network manager.") + nm = NetworkManager() + def fish_error_received(msg: str): + global _last_error_message + if msg != _last_error_message: + if msg not in _GOOD_MESSAGES: + logger.error("Received error from fish: '%s'", msg) + _last_error_message = msg + nm.status_updated.connect(fish_error_received) + broadcaster = Broadcaster() + application.processEvents() + logger.debug("Connecting to fish...") + nm.start() + application.processEvents() + error_occurred: bool = False + + def receive_update_from_fish(msg: proto.FilterMode_pb2.update_parameter): + if recorded_gui_updates is not None: + recorded_gui_updates.append((msg.scene_id, msg.filter_id, msg.parameter_key, msg.parameter_value)) + + broadcaster.update_filter_parameter.connect(receive_update_from_fish) + for i in range(10): + application.processEvents() + sleep(0.01) + pn = get_process_notifier("test upload notifier", 300) + logger.info("Creating Show XML...") + xml = create_xml(bc, pn, assemble_for_fish_loading=True) + logger.info("Sending it to fish...") + nm.transmit_show_file(xml, True) + while _last_error_message == _GOOD_MESSAGES[0] and nm.connection_state(): + application.processEvents() + sleep(0.01) + if _last_error_message != _GOOD_MESSAGES[1]: + logger.error("Final error state from fish was: %s. Failing execution.", _last_error_message) + error_occurred = True + nm.set_main_brightness_fader_position(int((main_brightness / 65565) * 255)) + logger.info("Evaluating execution...") + if not error_occurred: + for i in range(cycles * 4): + application.processEvents() + sleep(0.01) + logger.info("Ending execution...") + nm.disconnect() + sleep(1) + del broadcaster + del nm + del application + process.stdin.write("k\n") + process.communicate() + if process.returncode != 0: + logger.error("Fish terminated with code %s", process.returncode) + return not error_occurred