Source code for rcp.dmx

"""Objects related to DMX lighting control.
"""
################################################################
# Written in 2019 by Garth Zeglin <garthz@cmu.edu>

# To the extent possible under law, the author has dedicated all copyright
# and related and neighboring rights to this software to the public domain
# worldwide. This software is distributed without any warranty.

# You should have received a copy of the CC0 Public Domain Dedication along with this software.
# If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.

################################################################
# standard Python libraries
import math, logging
import numpy as np

# for documentation on the PyQt5 API, see http://pyqt.sourceforge.net/Docs/PyQt5/index.html
from PyQt5 import QtCore, QtSerialPort

# set up logger for module
log = logging.getLogger('dmx')

# filter out most logging; the default is NOTSET which passes along everything
# log.setLevel(logging.INFO)

################################################################
[docs]class QtDMXUSBPro(object): """Class to manage a serial connection to an ENTTEC DMXUSB PRO for DMX lighting control output. Uses the Qt QSerialPort object for data transport. This currently only supports output, although the device is capable of receiving DMX. For details on the device, please see https://www.enttec.com/range/controls/dmx-usb-interfaces/ """ def __init__(self): self._portname = None self._port = None # Initialize a default DMX 'universe', i.e. an addressable space of # 8-bit registers. The Enttec requires a minimum universe size of 25. self._universe = np.zeros((25), dtype=np.uint8) return
[docs] def set_size(self, channels): """Set the size of the DMX 'universe'. The value is limited to the valid range of [25,512]. Each channel is an 8-bit value transmitted on the DMX bus; typically each fixture occupies several sequential channels at a start address configured on the fixture. """ new_size = min(max(channels, 25), 512) self._universe = np.resize(self._universe, new_size) log.info("Resized DMX universe to %d channels." % new_size) return
[docs] def available_ports(self): """Return a list of names of available serial ports.""" return [port.portName() for port in QtSerialPort.QSerialPortInfo.availablePorts()] return
def set_port(self, name): if name == "<no selection>": log.debug("User picked the null serial port entry.") self._portname = None else: self._portname = name
[docs] def open(self): """Open the serial port and initialize communications. If the port is already open, this will close it first. If the current name is None, this will not open anything. Returns True if the port is open, else False.""" if self._port is not None: self.close() if self._portname is None: log.debug("No port name provided so not opening port.") return False return self._port = QtSerialPort.QSerialPort() self._port.setBaudRate(115200) self._port.setPortName(self._portname) # open the serial port if self._port.open(QtCore.QIODevice.ReadWrite): log.info("Opened DMX serial port %s", self._port.portName()) # always process data as it becomes available self._port.readyRead.connect(self._read_input) return True else: # Error codes: https://doc.qt.io/qt-5/qserialport.html#SerialPortError-enum errcode = self._port.error() if errcode == QtSerialPort.QSerialPort.PermissionError: log.warning("Failed to open DMX serial port %s with a QSerialPort PermissionError, which could involve an already running control process, a stale lock file, or dialout group permissions.", self._port.portName()) else: log.warning("Failed to open DMX serial port %s with a QSerialPort error code %d.", self._port.portName(), errcode) self._port = None return False
def set_and_open_port(self, name): self.set_port(name) self.open()
[docs] def close(self): """Shut down the serial connection to the DMX device.""" if self._port is not None: log.info("Closing DMX serial port %s", self._port.portName()) self._port.close() self._port = None return
# === internals ======================================================= def _read_input(self): # Read as much input as available; callback from Qt event loop. data = self._port.readAll() if len(data) > 0: log.debug("Received %d bytes from DMX interface." % len(data)) return def _send_universe(self): """Issue a DMX universe update.""" if self._port is None: log.debug("DMX port not open for output during send.") else: universe_size = min(512, self._universe.size) message = np.ndarray((6 + universe_size), dtype=np.uint8) message[0:2] = [126, 6] # Send DMX Packet header message[2] = (universe_size+1) % 256 # data length LSB message[3] = (universe_size+1) >> 8 # data length MSB message[4] = 0 # zero 'start code' in first universe position message[5:5+universe_size] = self._universe[0:universe_size] message[-1] = 231 # end of message delimiter # log.debug("Sending to DMX: '%s'", message) self._port.write(message.tobytes()) # ================================================================
[docs] def set_channel(self, channel, value): """Set a single channel value and update the hardware. :param start: zero-based index of channel to update :param value: 8-bit integer value """ self._universe[channel] = value self._send_universe() return
[docs] def set_channels(self, start, values): """Set a range of channels and update the hardware. :param start: zero-based index of first channel to update :param values: list or numpy array of 8-bit integer values """ size = min(self._universe.size - start, len(values)) self._universe[start:start+size] = values[0:size] self._send_universe() return
################################################################ class ColorInterpolator(object): def __init__(self, fixtures, channels_per_fixture): # opacity of cue updates self.alpha = 1.0 # transition time constant in seconds self.duration = 1.0 # array sizes self.fixtures = fixtures self.channels_per_fixture = channels_per_fixture # initialize the color vectors self.target_colors = np.zeros((self.fixtures, self.channels_per_fixture)) self.current_colors = np.zeros((self.fixtures, self.channels_per_fixture)) self.color_velocity = np.zeros((self.fixtures, self.channels_per_fixture)) # flag for culling null outputs self.colors_changed = False return def current_dmx_values(self): """Return a universe of 8-bit integer DMX values with the current colors mapped to the specific fixture channel configuration.""" return np.round(self.current_colors).astype(np.uint8).flatten() def current_rgb_values(self): """Return a list of 8-bit integer (red, green, blue) values with the current color for each fixture.""" return np.round(self.current_colors[:,0:3]).astype(np.uint8) def update_for_interval(self, interval): """Polling function to update internal state. Returns true if the DMX outputs should be updated.""" # compute any difference between actual and target colors errors = self.target_colors - self.current_colors if errors.any(): # calculate the maximum possible change, bound it to the actual error, then apply it delta = interval * self.color_velocity abserror = np.abs(errors) bounded_delta = np.minimum(abserror, np.maximum(-abserror, delta)) self.current_colors += bounded_delta self.colors_changed = True value = self.colors_changed self.colors_changed = False return value def set_color_target(self, fixture, color): """Update the color target for the given fixture using the current opacity. The output will change to the new targets over the current transition interval. Note that the opacity blends between the current color and the given color, not the current target and the given color. """ if fixture < self.fixtures: channels = min(len(color), self.channels_per_fixture) newcolor = np.array(color[0:channels]) blended = (self.alpha * newcolor) + ((1 - self.alpha) * self.current_colors[fixture,0:channels]) self.target_colors[fixture,0:channels] = blended difference = self.target_colors[fixture] - self.current_colors[fixture] duration = max(self.duration, 0.020) self.color_velocity[fixture] = difference / duration return def set_channel_target(self, fixture, channel, value): """Update a specific color channel target for the given fixture using the current opacity. The output will change to the new target over the current transition interval. Note that the opacity blends between the current color and the given color, not the current target and the given color. """ if fixture < self.fixtures and channel < self.channels_per_fixture: blended = (self.alpha * value) + ((1 - self.alpha) * self.current_colors[fixture, channel]) self.target_colors[fixture,channel] = blended difference = blended - self.current_colors[fixture, channel] duration = max(self.duration, 0.020) self.color_velocity[fixture, channel] = difference / duration return def set_current_color(self, fixture, color): """Set a fixture to the given color without a transition. The next update cycle will output the color.""" if fixture < self.fixtures: channels = min(len(color), self.channels_per_fixture) self.target_colors [fixture, 0:channels] = color[0:channels] self.current_colors[fixture, 0:channels] = color[0:channels] self.color_velocity[fixture,:] = 0.0 self.colors_changed = True return def set_dmx_value(self, channel, value): """Set a single DMX channel to the given value without a transition. The next update cycle will output the color. This function performs the inverse of the fixture->DMX mapping in current_dmx_values(). """ fixture = channel // self.channels_per_fixture color = channel % self.channels_per_fixture if fixture < self.fixtures and color < self.channels_per_fixture: self.current_colors[fixture,color] = value self.target_colors[fixture,color] = value self.colors_changed = True return ################################################################