#!/opt/local/bin/python3.5
# This is specific Python interpreter we use for the current IDeATe cluster MBP configuration.
"""A show control system with GUI."""
################################################################
# Written in 2018-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
from __future__ import print_function
import os, sys, logging
import numpy as np
# for documentation on the PyQt5 API, see http://pyqt.sourceforge.net/Docs/PyQt5/index.html
from PyQt5 import QtCore, QtWidgets, QtGui
# This uses several modules from the course library.
import kf.QtWinch
import kf.QtMPD218
import kf.QtDMX
import kf.QtConfig
import kf.QtLog
import kf.QtLightCues
import kf.app
import kf.dmx
import kf.midi
import kf.osc
import kf.path
import kf.winch
import kf.sim
# Import performance-specific patches and logic.
import patch.logic
import patch.worms
import patch.channel
import patch.reef
import patch.balloons
# set up logger for module
log = logging.getLogger('show2')
################################################################
################################################################
[docs]class AppWindow(QtWidgets.QMainWindow):
"""A custom main window which provides all GUI controls. This generally follows
a model-view-controller convention in which this window provides the views,
passing events to the application controller via callbacks.
"""
def __init__(self, main):
super().__init__()
# This GUI controller assumes it has access to an application controller with the methods of kf.app.MainApp.
self.main = main
# create the GUI elements
self._setupUi()
# finish initialization
self.show()
return
[docs] def connect_callbacks(self):
"""Finish initializing the GUI by connecting all callbacks from GUI elements to
application controller methods. This allows the log window to be set up
early to capture any messages from the initialization of other object.
"""
# self.winch_MIDI_controller.connect_midi_processor(self.main.winch_midi_logic)
# self.lighting_MIDI_controller.connect_midi_processor(self.main.lighting_midi_logic)
self.DMX_controller.connect_callback(self.main.dmx_slider_change)
self.dmxSelect.callback = self.main.dmx.set_and_open_port
self.dmxSelect.set_items(self.main.dmx.available_ports())
self.winchMidiInputCombo.callback = self.main.winch_midi_listener.open_MIDI_input
self.winchMidiInputCombo.set_items(self.main.winch_midi_listener.get_midi_port_names())
self.lightingMidiInputCombo.callback = self.main.lighting_midi_listener.open_MIDI_input
self.lightingMidiInputCombo.set_items(self.main.lighting_midi_listener.get_midi_port_names())
self.midiOutputCombo.callback = self.main.midi_sender.open_MIDI_output
self.midiOutputCombo.set_items(self.main.midi_sender.get_midi_port_names())
self.oscListenerConfig.callback = self.main.osc_listener.set_OSC_port
self.oscSenderConfig.callback = self.main.osc_sender.set_OSC_port
self.lightingCuePanel.cueChanged.connect(self.main.lighting_cue_changed)
for winch, selector in zip(self.main.winches, self.winchSelects):
selector.callback = winch.set_and_open_port
selector.set_items(winch.available_ports())
self.patchCombo.activated['QString'].connect(self.main.activate_patch)
return
# ------------------------------------------------------------------------------------------------
def _setupUi(self):
# basic window setup
self.setWindowTitle("KF System Controller: Show 2")
self.statusbar = QtWidgets.QStatusBar(self)
self.setStatusBar(self.statusbar)
# set up tabbed page structure
self.tabs = QtWidgets.QTabWidget()
self.setCentralWidget(self.tabs)
self.tabs.currentChanged.connect(self._tab_changed)
# set up the top-level status/control page
self.statusTab = QtWidgets.QWidget(self)
self.statusLayout = QtWidgets.QVBoxLayout(self.statusTab)
self.tabs.addTab(self.statusTab, 'Status')
self.patchCombo = QtWidgets.QComboBox()
self.statusLayout.addLayout(make_labeled_widget_layout("Current Performance Patch:", self.patchCombo, stretch=1))
helpgrid = QtWidgets.QGridLayout()
self.winchHelp = QtWidgets.QLabel()
self.lightingHelp = QtWidgets.QLabel()
self.winchHelp.setWordWrap(True)
self.lightingHelp.setWordWrap(True)
helpgrid.addWidget(QtWidgets.QLabel("Winch MIDI help:"), 0, 0, 1, 1)
helpgrid.addWidget(self.winchHelp, 0, 1, 1, 3)
helpgrid.addWidget(QtWidgets.QLabel("Lighting MIDI help:"), 1, 0, 1, 1)
helpgrid.addWidget(self.lightingHelp, 1, 1, 1, 3)
self.statusLayout.addLayout(helpgrid)
# generate a schematic representation of the stage
self.stageLayout = QtWidgets.QVBoxLayout()
self.backLights = kf.QtDMX.QtDMXColors(fixtures=4)
self.stageLayout.addWidget(self.backLights)
# generate an array of winch cartoons
self.winchSets = list()
for i in range(self.main.num_winch_sets):
winchSet = kf.QtWinch.QtWinchSet()
self.winchSets.append(winchSet)
# arrange them according to stage layout
winchgrid = QtWidgets.QGridLayout()
winchgrid.addWidget(self.winchSets[0], 1, 0, 1, 1)
winchgrid.addWidget(self.winchSets[1], 1, 1, 1, 1)
winchgrid.addWidget(self.winchSets[2], 0, 0, 1, 1)
winchgrid.addWidget(self.winchSets[3], 0, 1, 1, 1)
self.stageLayout.addLayout(winchgrid)
self.footLights = kf.QtDMX.QtDMXColors(fixtures=6)
self.stageLayout.addWidget(self.footLights)
self.pitLights = kf.QtDMX.QtDMXColors(fixtures=6)
self.stageLayout.addWidget(self.pitLights)
self.statusLayout.addLayout(self.stageLayout)
# text indicators for various values
statusgrid = QtWidgets.QGridLayout()
self.fanIndicators = [QtWidgets.QLabel() for i in range(4)]
self.lightingTempoIndicator = QtWidgets.QLabel()
self.lightingOpacityIndicator = QtWidgets.QLabel()
self.winchDampingIndicator = QtWidgets.QLabel()
self.winchFrequencyIndicator = QtWidgets.QLabel()
self.winchStatusIndicators = [QtWidgets.QLabel("<winch %d status>" % (i+1)) for i in range(4)]
statusgrid.addWidget(QtWidgets.QLabel("Winch Freq:"), 0, 0, 1, 1)
statusgrid.addWidget(self.winchFrequencyIndicator, 0, 1, 1, 1)
statusgrid.addWidget(QtWidgets.QLabel("Winch Damping Ratio:"), 0, 2, 1, 1)
statusgrid.addWidget(self.winchDampingIndicator, 0, 3, 1, 1)
statusgrid.addWidget(QtWidgets.QLabel("Lighting Tempo:"), 0, 4, 1, 1)
statusgrid.addWidget(self.lightingTempoIndicator, 0, 5, 1, 1)
statusgrid.addWidget(QtWidgets.QLabel("Lighting Opacity:"), 0, 6, 1, 1)
statusgrid.addWidget(self.lightingOpacityIndicator, 0, 7, 1, 1)
for i, w in enumerate(self.winchStatusIndicators):
statusgrid.addWidget(QtWidgets.QLabel("Winch %d:" % (i+1)), 2-(i//2), 4*(i%2), 1, 1)
statusgrid.addWidget(w, 2-(i//2), 1+4*(i%2), 1, 3)
for i, w in enumerate(self.fanIndicators):
statusgrid.addWidget(QtWidgets.QLabel("Fan %d:" % (i+1)), 3, i*2, 1, 1)
statusgrid.addWidget(w, 3, 1+2*i, 1, 1)
self.statusLayout.addLayout(statusgrid)
# set up the winch MIDI input simulator tab
self.mainTab = QtWidgets.QWidget(self)
self.mainLayout = QtWidgets.QVBoxLayout(self.mainTab)
self.winch_MIDI_controller = kf.QtMPD218.QtMPD218() # generate a simulated MPD218 controller
self.mainLayout.addWidget(self.winch_MIDI_controller)
self.tabs.addTab(self.mainTab, 'W MIDI')
# set up the lighting MIDI input simulator tab
self.lightingMIDITab = QtWidgets.QWidget(self)
self.lightingMIDILayout = QtWidgets.QVBoxLayout(self.lightingMIDITab)
self.lighting_MIDI_controller = kf.QtMPD218.QtMPD218() # generate a simulated MPD218 controller
self.lightingMIDILayout.addWidget(self.lighting_MIDI_controller)
self.tabs.addTab(self.lightingMIDITab, 'L MIDI')
# generate a DMX controller tab
self.dmxTab = QtWidgets.QWidget(self)
self.dmxLayout = QtWidgets.QVBoxLayout(self.dmxTab)
self.DMX_colors = kf.QtDMX.QtDMXColors(fixtures=16)
self.dmxIndicators = QtWidgets.QHBoxLayout()
self.dmxOpacityIndicator = QtWidgets.QLabel()
self.dmxTempoIndicator = QtWidgets.QLabel()
labelFont = QtGui.QFont("Sans Serif", 14)
self.dmxOpacityIndicator.setFont(labelFont)
self.dmxTempoIndicator.setFont(labelFont)
self.dmxIndicators.addWidget(self.dmxTempoIndicator)
self.dmxIndicators.addWidget(self.dmxOpacityIndicator)
self.dmxIndicators.addWidget(QtWidgets.QWidget()) # to fill empty space on right
self.dmxLayout.addLayout(self.dmxIndicators)
self.dmxLayout.addWidget(self.DMX_colors)
self.DMX_controller = kf.QtDMX.QtDMXControls(channels = self.main.config['dmx'].getint('channels'))
self.dmxLayout.addWidget(self.DMX_controller)
self.tabs.addTab(self.dmxTab, 'DMX')
# set up the lighting cue panel on a tab
self.lightingCuePanel = kf.QtLightCues.QtLightingCuePanel()
base_path, ext = os.path.splitext(self.main.configuration_file_path)
self.lightingCuePanel.set_cue_file_path(base_path + ".csv")
self.tabs.addTab(self.lightingCuePanel, 'Cues')
# set up the configuration tab
self.configForm = kf.QtConfig.QtConfigForm()
self.tabs.addTab(self.configForm, 'Config')
self.configFileButtons = kf.QtConfig.QtConfigFileButtons(delegate=self.main, path=self.main.configuration_file_path)
self.configForm.addField("Configuration file:", self.configFileButtons)
self.oscListenerConfig = kf.QtConfig.QtConfigOSCPort()
self.configForm.addField("OSC message listener address:port", self.oscListenerConfig)
self.winchMidiInputCombo = kf.QtConfig.QtConfigComboBox()
self.configForm.addField("Winch MIDI input", self.winchMidiInputCombo)
self.lightingMidiInputCombo = kf.QtConfig.QtConfigComboBox()
self.configForm.addField("Lighting MIDI input", self.lightingMidiInputCombo)
self.winchSelects = list()
for i in range(self.main.num_winch_sets):
winchSelect = kf.QtConfig.QtConfigComboBox()
self.configForm.addField("Winch %d output serial port" % (i+1), winchSelect)
self.winchSelects.append(winchSelect)
self.midiOutputCombo = kf.QtConfig.QtConfigComboBox()
self.configForm.addField("MIDI output", self.midiOutputCombo)
self.dmxSelect = kf.QtConfig.QtConfigComboBox()
self.configForm.addField("DMX output serial port", self.dmxSelect)
self.oscSenderConfig = kf.QtConfig.QtConfigOSCPort()
self.configForm.addField("OSC destination adddress:port", self.oscSenderConfig)
# set up the logging tab
self.logDisplay = kf.QtLog.QtLog(level=logging.INFO)
self.tabs.addTab(self.logDisplay, 'Log')
return
# --- window and Qt event processing -------------------------------------------------------------
[docs] def set_status(self, string):
"""Update the status bar at the bottom of the display to show the provided string."""
self.statusbar.showMessage(string)
return
[docs] def update_frequency_indicator(self, frequency):
self.winchFrequencyIndicator.setText("%5.2f Hz" % frequency)
return
[docs] def update_damping_indicator(self, damping):
self.winchDampingIndicator.setText("%5.2f" % damping)
return
[docs] def update_opacity_indicator(self, opacity):
self.dmxOpacityIndicator.setText("Opacity = %5.1f%%" % (100*opacity))
self.lightingOpacityIndicator.setText("%5.1f%%" % (100*opacity))
return
[docs] def update_tempo_indicator(self, tempo):
self.dmxTempoIndicator.setText("Tempo = %d" % tempo)
self.lightingTempoIndicator.setText("%d" % tempo)
return
def _tab_changed(self, index):
log.debug("Tab changed to %d", index)
return
[docs] def closeEvent(self, event):
"""Qt callback received before windows closes."""
log.info("Received window close event.")
self.main.app_is_exiting()
super().closeEvent(event)
return
# ---- configuration management --------------------------------------------------------------------
[docs] def apply_user_configuration(self, config):
"""Apply the persistent configuration values from a configparser section proxy object."""
self.logDisplay.set_logging_level(config['log'].get('logging_level', fallback='Verbose'))
# MIDI
self.winchMidiInputCombo.select_item(config['midi'].get('winch_midi_input', fallback='<no selection>'))
self.lightingMidiInputCombo.select_item(config['midi'].get('lighting_midi_input', fallback='<no selection>'))
self.midiOutputCombo.select_item(config['midi'].get('midi_output', fallback='<no selection>'))
# OSC
oscdef = config['osc']
self.oscListenerConfig.set_OSC_port(oscdef.get('listener_addr', fallback='localhost'),
oscdef.getint('listener_port', fallback=3761))
self.oscSenderConfig.set_OSC_port(oscdef.get('sender_addr', fallback='localhost'),
oscdef.getint('sender_port', fallback=3762))
# DMX
self.dmxSelect.select_item(config['dmx'].get('dmx_output_serial_port', fallback='<no selection>'))
# winches
for i, winchSelect in enumerate(self.winchSelects):
key = "winch_%d_output_serial_port" % (i+1)
winchSelect.select_item(config['winches'].get(key, fallback = '<no selection>'))
# load the default cue table
self.lightingCuePanel.load_configuration()
return
[docs] def gather_configuration(self, config):
"""Update the persistent configuration values in a configparser section proxy object."""
config['log']['logging_level'] = self.logDisplay.get_logging_level()
# MIDI
config['midi']['winch_midi_input'] = self.winchMidiInputCombo.current_item()
config['midi']['lighting_midi_input'] = self.lightingMidiInputCombo.current_item()
config['midi']['midi_output'] = self.midiOutputCombo.current_item()
# OSC
addr, port = self.oscListenerConfig.get_OSC_port()
config['osc']['listener_addr'] = addr
config['osc']['listener_port'] = str(port)
addr, port = self.oscSenderConfig.get_OSC_port()
config['osc']['sender_addr'] = addr
config['osc']['sender_port'] = str(port)
# DMX
config['dmx']['dmx_output_serial_port'] = self.dmxSelect.current_item()
# winches
for i, winchSelect in enumerate(self.winchSelects):
key = "winch_%d_output_serial_port" % (i+1)
config['winches'][key] = winchSelect.current_item()
return
# --------------------------------------------------------------------------------------------------
################################################################
[docs]class MainApp(kf.app.MainApp):
"""Main application controller object holding any non-GUI related state."""
def __init__(self):
log.debug("Entering MainApp.__init__")
# kf.app.MainApp initialization, including the creation of the self.config object
super().__init__()
# load the configuration if available; this allows basic window setup to be specified
self.load_configuration()
self.num_winch_sets = self.config['winches'].getint('winch_sets', fallback=4)
# create the interface window
self.window = AppWindow(self)
# initialize the winch set simulators
self.sims = [kf.sim.SimWinch() for i in range(self.num_winch_sets)]
# Initialize the MIDI input system for controlling lighting.
# self.lighting_midi_logic = patch.logic.LightingMIDILogic(self)
self.lighting_midi_listener = kf.midi.QtMIDIListener()
# self.lighting_midi_listener.connect_midi_processor(self.lighting_midi_logic)
# Initialize the MIDI input system for controlling winches.
# self.winch_midi_logic = patch.logic.WinchMIDILogic(self)
self.winch_midi_listener = kf.midi.QtMIDIListener()
# self.winch_midi_listener.connect_midi_processor(self.winch_midi_logic)
# Initialize the OSC message listener and dispatch system.
self.osc_listener = kf.osc.QtOSCListener()
# Add endpoints to the OSC listener dispatch system.
self.osc_listener.map_handler("/midi", self._received_remote_midi)
self.osc_listener.map_handler("/dmx/*", self._received_remote_dmx)
# Initialize the MIDI output system.
self.midi_sender = kf.midi.QtMIDISender()
# Initialize the OSC message sender.
self.osc_sender = kf.osc.QtOSCSender()
# Initialize the hardware winch system.
self.winches = [kf.winch.QtSerialWinch() for i in range(self.num_winch_sets)]
# Initialize the DMX lighting output system.
self.dmx = kf.dmx.QtDMXUSBPro()
self.dmx.set_size(self.config['dmx'].getint('channels'))
# Initialize a color interpolator for generating lighting trajectories.
# There are fourteen RGBA fixtures plus a dimmer pack.
self.interpolator = kf.dmx.ColorInterpolator(fixtures=17, channels_per_fixture=4)
# Finish connecting the window callbacks.
self.window.connect_callbacks()
# Create the set of patch objects and activate the default.
self.patches = list()
self.patches_by_name = dict()
self.patches.append(patch.logic.Patch()) # default patch
self.patches.append(patch.worms.Patch())
self.patches.append(patch.channel.Patch())
self.patches.append(patch.reef.Patch())
self.patches.append(patch.balloons.Patch())
for p in self.patches:
self.window.patchCombo.addItem(p.name)
self.patches_by_name[p.name] = p
self.patch = None
self.switch_to_patch(self.patches[0])
# start the DMX animation timer
self.lighting_interval = 0.020
self.lighting_timer = QtCore.QTimer()
self.lighting_timer.start(1000*self.lighting_interval) # units are milliseconds
self.lighting_timer.timeout.connect(self.lighting_timer_tick)
# start the graphics animation timer
self.frame_interval = 0.125
self.frame_timer = QtCore.QTimer()
self.frame_timer.start(1000*self.frame_interval) # units are milliseconds
self.frame_timer.timeout.connect(self.frame_timer_tick)
return
# ---- configuration management -------------------------------------------------
[docs] def initialize_default_configuration(self):
# Extend the default implementation to add application-specific defaults.
super().initialize_default_configuration()
self.config['log'] = {}
self.config['osc'] = {}
self.config['midi'] = { }
self.config['dmx'] = {'channels' : 68}
self.config['winches'] = {}
return
[docs] def save_configuration(self, path=None):
# Extend the default implementation to gather up configuration values.
self.window.gather_configuration(self.config)
try:
super().save_configuration(path)
except PermissionError:
log.warning("Unable to write configuration to %s", self.configuration_file_path)
[docs] def apply_configuration(self):
self.window.apply_user_configuration(self.config)
self.window.update_tempo_indicator(60.0 / self.interpolator.duration)
self.window.update_opacity_indicator(self.interpolator.alpha)
# ---- application event handlers -----------------------------------------------
[docs] def app_has_started(self):
super().app_has_started()
self.apply_configuration()
self.osc_listener.open_receiver()
self.osc_sender.open_sender()
[docs] def app_is_exiting(self):
self.dmx.close()
for winch in self.winches:
winch.close()
super().app_is_exiting()
# ---- process OSC network messages ---------------------------------------
# The midi_osc_bridge.py tool can tunnel MIDI messages over the network as
# OSC packets. This routes any bridged MIDI data into the same performance
# logic.
def _received_remote_midi(self, msgaddr, *args):
log.debug("remote midi: %s", " ".join([str(arg) for arg in args]))
self.decode_message(args)
return
def _received_remote_dmx(self, msgaddr, *args):
# This receives OSC messages prefixed with /dmx intended for direct
# application to the DMX output, bypassing the control logic.
log.debug("remote DMX: %s %s", msgaddr, " ".join([str(arg) for arg in args]))
if msgaddr=='/dmx/fixture' and len(args) == 4:
fixture = args[0]
self.dmx_remote_update(fixture, args[1:])
#---- methods related to the lighting cues ------------------------
[docs] def lighting_cue_changed(self, values):
colors, name, index = values
log.debug("Received lighting cue '%s': %s", name, colors)
for fixture, col in enumerate(colors):
self.interpolator.set_current_color(fixture, col)
#---- methods related to DMX -------------------------------------
[docs] def dmx_slider_change(self, channel, value):
"""Callback invoked when a DMX channel strip slider is moved."""
self.interpolator.set_dmx_value(channel, value)
return
[docs] def dmx_remote_update(self, fixture, color):
"""Callback invoked when an OSC message is received with a /dmx/fixture address."""
# Send a color update to the hardware.
self.interpolator.set_current_color(fixture, color)
#--- generate graphics animation updates ---------------------------------------------------
[docs] def frame_timer_tick(self):
# Method called at intervals by the animation timer to update the model and graphics.
for winchset, sim in zip(self.window.winchSets, self.sims):
sim.update_for_interval(self.frame_interval)
positions = sim.positions()
for pos, cartoon in zip(positions, winchset.winches()):
cartoon.update_position(pos)
for winch, indicator in zip(self.winches, self.window.winchStatusIndicators):
indicator.setText(winch.status_message())
#--- generate DMX updates ---------------------------------------------------
# All color changes are routed through the interpolator, which either
# applies a step change or creates a smooth interpolated color trajectory.
# The interpolator only updates at fixed intervals, so this
# also safely rate-limits the data throughput to the serial port.
[docs] def lighting_timer_tick(self):
changed = self.interpolator.update_for_interval(self.frame_interval)
if changed:
universe = self.interpolator.current_dmx_values()
# update the physical DMX hardware, if connected
self.dmx.set_channels(0, universe)
# update the sliders in the DMX channel strips in the DMX panel
self.window.DMX_controller.set_channels(0, universe)
# update the color well displays in the DMX panel and status display
all_lights = self.interpolator.current_rgb_values()
self.window.DMX_colors.set_colors(all_lights)
self.window.footLights.set_colors(all_lights[0:6])
self.window.backLights.set_colors(all_lights[6:10])
self.window.pitLights.set_colors(all_lights[10:16])
# update the fan level indicators
# N.B. the fan DMX channels are hard-coded below
first_fan_index = 64
for i, indicator in enumerate(self.window.fanIndicators):
indicator.setText("%5.2f%%" % (universe[first_fan_index+i]/2.55))
#--- manage patch switching ------------------------------------------------------
[docs] def activate_patch(self, name):
log.debug("Patch %s requested.", name)
self.switch_to_patch(self.patches_by_name[name])
[docs] def switch_to_patch(self, patch):
if self.patch is not None:
self.patch.detach_from_main()
self.patch = patch
self.patch.attach_to_main(self)
# apply any configuration presets
self.patch.apply_configuration(self.config)
# reload the lighting cue table
self.window.lightingCuePanel.set_cue_file_path(self.patch.lighting_cue_path)
self.window.lightingCuePanel.load_configuration()
# attach the external MIDI inputs to the patch
self.lighting_midi_listener.connect_midi_processor(self.patch.lighting_midi_logic)
self.winch_midi_listener.connect_midi_processor(self.patch.winch_midi_logic)
# attach the simulated MIDI inputs to the patch
self.window.winch_MIDI_controller.connect_midi_processor(self.patch.winch_midi_logic)
self.window.lighting_MIDI_controller.connect_midi_processor(self.patch.lighting_midi_logic)
# update the visible help text
self.window.winchHelp.setText(self.patch.winch_midi_logic.help_text)
self.window.lightingHelp.setText(self.patch.lighting_midi_logic.help_text)
return
################################################################
def _main():
# temporary increase in debugging output
# kf.app.add_console_log_handler()
# capture log messages generated before the window opens
mem_log_handler = kf.app.add_memory_log_handler()
# initialize the Qt system itself
app = QtWidgets.QApplication(sys.argv)
# create the main application controller
main = MainApp()
# finish the memory handler
main.window.logDisplay.flush_and_remove_memory_handler(mem_log_handler)
# Send a signal to be received after the application event loop starts.
QtCore.QTimer.singleShot(0, main.app_has_started)
# run the event loop until the user is done
log.info("Starting event loop.")
sys.exit(app.exec_())
################################################################
# Main script follows. This sequence is executed when the script is initiated from the command line.
if __name__ == "__main__":
_main()