Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Project-Editor.spec
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
added_files = [
( 'src/resources/' , 'resources'),
( 'src/resources/autotrack_models', 'resources/autotrack_models' ),
( 'src/resources/3dmodels', 'resources/3dmodels' ),
( 'src/resources/data', 'resources/data' ),
( 'src/resources/fonts', 'resources/fonts' ),
( 'src/resources/icons', 'resources/icons' ),
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ authors = [
{ name = "CorsCodini", email = "cors.codini@web.de" },
{ name = "Niklas Naumann", email = "niklas.naumann@student.uni-luebeck.de" },
{ name = "Doralitze", email = "doralitze@chaotikum.org" },
{ name = "Ludwig Rahlff"},
{ name = "Joell Keanu"},
]

requires-python = "==3.13.*"
Expand All @@ -18,7 +20,7 @@ dependencies = [
"xmlschema>=3.4.5",
"requests>=2.32.3",
"numpy>=2.2.4",
"ruamel-yaml>=0.18.10",
"ruamel-yaml>=0.18.14",
"html2text>=2024.2.26",
"markdown>=3.7",
"typing-extensions>=4.13.1",
Expand All @@ -31,6 +33,7 @@ dependencies = [
"pyjoystick>=1.2.4",
"pyopengl>=3.1.9",
"onnxruntime-openvino>=1.21.0",
"PySDL2>=0.9.17",
"pydantic>=2.11.7",
"defusedxml>=0.7.1",
"tzlocal>=5.3.1",
Expand Down
2 changes: 1 addition & 1 deletion src/controller/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ def _react_request_dmx_data(self, universe: Universe) -> None:

"""
if self._socket.state() == QtNetwork.QLocalSocket.LocalSocketState.ConnectedState:
msg = proto.DirectMode_pb2.request_dmx_data(universe_id=universe.universe_proto.id)
msg = proto.DirectMode_pb2.request_dmx_data(universe_id=universe.id)
self._send_with_format(msg.SerializeToString(), proto.MessageTypes_pb2.MSGT_REQUEST_DMX_DATA)

def _generate_universe(self, universe: Universe) -> None:
Expand Down
5 changes: 4 additions & 1 deletion src/model/broadcaster.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@
view_to_temperature: QtCore.Signal = QtCore.Signal()
view_leave_temperature: QtCore.Signal = QtCore.Signal()

view_to_visualizer: QtCore.Signal = QtCore.Signal()
view_leave_visualizer: QtCore.Signal = QtCore.Signal()

view_to_console_mode: QtCore.Signal = QtCore.Signal()
view_leave_console_mode: QtCore.Signal = QtCore.Signal()

Expand Down Expand Up @@ -124,4 +127,4 @@
"""Override __new__ to implement singleton behavior."""
if not hasattr(cls, "instance") or cls.instance is None:
cls.instance = super().__new__(cls)
return cls.instance
return cls.instance

Check failure on line 130 in src/model/broadcaster.py

View workflow job for this annotation

GitHub Actions / test_on_main_pr

ruff (W292)

src/model/broadcaster.py:130:28: W292 No newline at end of file help: Add trailing newline
Empty file added src/model/dmx/__init__.py
Empty file.
227 changes: 227 additions & 0 deletions src/model/dmx/dmx_visualizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
"""Polls live DMX data from Fish and writes it into stage fixtures.

Receives DMX frames via the Broadcaster, maps the raw 8-bit channel
values onto MovingHead properties (pan, tilt, dimmer, beam color) and
emits ``fixtures_updated`` so the 3D widget can repaint.

Channel offsets are auto-detected from the Open Fixture Library naming
convention of the connected fixture profile.
"""

import logging
from typing import Dict, List

Check failure on line 12 in src/model/dmx/dmx_visualizer.py

View workflow job for this annotation

GitHub Actions / test_on_main_pr

ruff (UP035)

src/model/dmx/dmx_visualizer.py:12:1: UP035 `typing.List` is deprecated, use `list` instead

Check failure on line 12 in src/model/dmx/dmx_visualizer.py

View workflow job for this annotation

GitHub Actions / test_on_main_pr

ruff (UP035)

src/model/dmx/dmx_visualizer.py:12:1: UP035 `typing.Dict` is deprecated, use `dict` instead

from PySide6 import QtCore

from model.broadcaster import Broadcaster
from model.stage import StageConfig, MovingHead

Check failure on line 17 in src/model/dmx/dmx_visualizer.py

View workflow job for this annotation

GitHub Actions / test_on_main_pr

ruff (I001)

src/model/dmx/dmx_visualizer.py:11:1: I001 Import block is un-sorted or un-formatted help: Organize imports

logger = logging.getLogger(__file__)

Check failure on line 19 in src/model/dmx/dmx_visualizer.py

View workflow job for this annotation

GitHub Actions / test_on_main_pr

ruff (LOG002)

src/model/dmx/dmx_visualizer.py:19:28: LOG002 Use `__name__` with `logging.getLogger()` help: Replace with `__name__`

# OFL role names we try to detect on each channel.
MOVEMENT_ROLES = [
"pan_coarse", "pan_fine",
"tilt_coarse", "tilt_fine",
"dimmer", "pan_tilt_speed",
]
COLOR_ROLES = ["red", "green", "blue", "white"]
ALL_ROLES = MOVEMENT_ROLES + COLOR_ROLES

# Physical rotation range of typical moving heads.
DEFAULT_PAN_MAX_DEG = 540.0
DEFAULT_TILT_MAX_DEG = 270.0


def _primary(raw_name: str) -> str:
# OFL joins multi-function channels with "___" - keep only the first part.
return raw_name.split("___")[0].strip().lower().replace(" ", "_")

Check failure on line 37 in src/model/dmx/dmx_visualizer.py

View workflow job for this annotation

GitHub Actions / test_on_main_pr

ruff (PLC0207)

src/model/dmx/dmx_visualizer.py:37:12: PLC0207 String is split more times than necessary help: Pass `maxsplit=1` into `str.split()`


def auto_detect_mapping(channel_names: List[str],

Check failure on line 40 in src/model/dmx/dmx_visualizer.py

View workflow job for this annotation

GitHub Actions / test_on_main_pr

ruff (UP006)

src/model/dmx/dmx_visualizer.py:40:40: UP006 Use `list` instead of `List` for type annotation help: Replace with `list`
roles: List[str]) -> Dict[str, int]:

Check failure on line 41 in src/model/dmx/dmx_visualizer.py

View workflow job for this annotation

GitHub Actions / test_on_main_pr

ruff (UP006)

src/model/dmx/dmx_visualizer.py:41:46: UP006 Use `dict` instead of `Dict` for type annotation help: Replace with `dict`

Check failure on line 41 in src/model/dmx/dmx_visualizer.py

View workflow job for this annotation

GitHub Actions / test_on_main_pr

ruff (UP006)

src/model/dmx/dmx_visualizer.py:41:32: UP006 Use `list` instead of `List` for type annotation help: Replace with `list`
"""Return a {role: channel_offset} dict, -1 where no match was found."""
mapping = {r: -1 for r in roles}

Check failure on line 43 in src/model/dmx/dmx_visualizer.py

View workflow job for this annotation

GitHub Actions / test_on_main_pr

ruff (C420)

src/model/dmx/dmx_visualizer.py:43:15: C420 Unnecessary dict comprehension for iterable; use `dict.fromkeys` instead help: Replace with `dict.fromkeys(iterable)`)

for i, raw_name in enumerate(channel_names):
p = _primary(raw_name)

# Pan
if p == "pan_fine" or ("pan" in p and "fine" in p):
if "pan_fine" in roles:
mapping["pan_fine"] = i
elif "pan" in p and "speed" not in p and "tilt" not in p:
if "pan_coarse" in roles:
mapping["pan_coarse"] = i

# Tilt
if p == "tilt_fine" or ("tilt" in p and "fine" in p):
if "tilt_fine" in roles:
mapping["tilt_fine"] = i
elif "tilt" in p and "speed" not in p and "pan" not in p:
if "tilt_coarse" in roles:
mapping["tilt_coarse"] = i

# Dimmer / speed
if p in ("dimmer", "intensity") and "dimmer" in roles:
mapping["dimmer"] = i
if "speed" in p and ("pan" in p or "tilt" in p):
if "pan_tilt_speed" in roles:
mapping["pan_tilt_speed"] = i

# Colors
if "red" in p and "red" in roles:
mapping["red"] = i
if "green" in p and "green" in roles:
mapping["green"] = i
if "blue" in p and "blue" in roles:
mapping["blue"] = i
if p == "white" and "white" in roles:
mapping["white"] = i

return mapping


class DmxVisualizer(QtCore.QObject):
"""Drives the stage fixtures from incoming DMX frames."""

fixtures_updated = QtCore.Signal()

def __init__(self, stage_config: StageConfig,
board_configuration=None, parent=None):
super().__init__(parent)
self._stage_config = stage_config
self._board_config = board_configuration
self._broadcaster = Broadcaster()
self._enabled = True

try:
self._broadcaster.dmx_from_fish.connect(self._on_dmx)
except Exception as e:
logger.warning("Could not connect broadcaster signals: %s", e)

# Request fresh DMX data at ~45 Hz.
self._poll_timer = QtCore.QTimer(self)
self._poll_timer.setInterval(22)
self._poll_timer.timeout.connect(self._request_dmx)
self._poll_timer.start()

@property
def enabled(self) -> bool:
return self._enabled

@enabled.setter
def enabled(self, value: bool):
self._enabled = value
if value:
self._poll_timer.start()
else:
self._poll_timer.stop()

def _request_dmx(self):
if not self._enabled or self._board_config is None:
return
try:
for universe in self._board_config.universes:
self._broadcaster.send_request_dmx_data.emit(universe)
except Exception:
pass

@QtCore.Slot()
def _on_dmx(self, msg) -> None:
if not self._enabled:
return

universe_id = msg.universe_id

# Normalize to exactly 512 channels; Fish sometimes sends a leading zero.
raw = list(msg.channel_data)
if len(raw) == 513:
raw = raw[1:]
raw = (raw + [0] * 512)[:512]

any_updated = False
for obj in self._stage_config.objects:
if not isinstance(obj, MovingHead):
continue
dc = obj.device_config
if not dc:
continue

mv = dc.get("movement")
if mv and mv.get("universe", -1) == universe_id:
self._apply_movement(obj, raw, mv)
any_updated = True

col = dc.get("color")
if col and col.get("universe", -1) == universe_id:
self._apply_color(obj, raw, col)
any_updated = True

if any_updated:
self.fixtures_updated.emit()

def _apply_movement(self, obj, raw, cfg):
"""Map pan/tilt/dimmer channels to the fixture's 2-DOF properties."""
start = cfg.get("start_channel", 0)
m = cfg.get("mapping", {})

def rd(role):
off = m.get(role, -1)
if off < 0 or not (0 <= start + off < 512):
return None
return int(raw[start + off])

# 16-bit pan, centered at zero.
pc, pf = rd("pan_coarse"), rd("pan_fine")
if pc is not None:
v = (pc << 8) | (pf or 0)
obj.pan = (v / 65535.0) * DEFAULT_PAN_MAX_DEG - DEFAULT_PAN_MAX_DEG / 2.0

# 16-bit tilt, centered at zero.
tc, tf = rd("tilt_coarse"), rd("tilt_fine")
if tc is not None:
v = (tc << 8) | (tf or 0)
obj.tilt = (v / 65535.0) * DEFAULT_TILT_MAX_DEG - DEFAULT_TILT_MAX_DEG / 2.0

dim = rd("dimmer")
if dim is not None:
obj.dimmer = dim / 255.0
obj.beam_on = dim > 0

def _apply_color(self, obj, raw, cfg):
"""Map R/G/B/W channels to beam_color."""
start = cfg.get("start_channel", 0)
m = cfg.get("mapping", {})

def rd(role):
off = m.get(role, -1)
if off < 0 or not (0 <= start + off < 512):
return None
return int(raw[start + off])

r, g, b = rd("red"), rd("green"), rd("blue")
if r is None or g is None or b is None:
return

# White LED adds on top of RGB (matches RGBW fixtures).
w = rd("white")
if w is not None and w > 0:
r = min(255, r + w)
g = min(255, g + w)
b = min(255, b + w)

obj.beam_color = (r, g, b)
any_color = (r > 0 or g > 0 or b > 0)
obj.beam_on = any_color

# Use the white channel as dimmer if no dedicated movement dimmer exists.
if w is not None:
obj.dimmer = w / 255.0 if w > 0 else (1.0 if any_color else 0.0)
elif not self._has_movement_dimmer(obj) and any_color:
obj.dimmer = 1.0

def _has_movement_dimmer(self, obj) -> bool:
dc = obj.device_config
if not dc:
return False
return dc.get("movement", {}).get("mapping", {}).get("dimmer", -1) >= 0
1 change: 1 addition & 0 deletions src/model/ofl/fixture.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# coding=utf-8
"""Fixture Definitions from OFL."""

from __future__ import annotations
Expand Down
Loading
Loading