#!/usr/bin/env python3
"""Graphical user interface to a set of Arduino-controlled pneumatic valves."""
################################################################
# Written in 2018-2020 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 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.QtMPD218
import kf.QtConfig
import kf.QtLog
import kf.app
import kf.valve
import kf.midi
# set up logger for module
log = logging.getLogger(__file__)
################################################################
[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.
"""
# connect the simulated MIDI input device to the MIDI logic
self.valve_MIDI_controller.connect_midi_processor(self.main.valve_midi_logic)
# connect the MIDI configuration to the external MIDI input
self.valveMidiInputCombo.callback = self.main.valve_midi_listener.open_MIDI_input
self.valveMidiInputCombo.set_items(self.main.valve_midi_listener.get_midi_port_names())
# connect the valve serial port configuration to the hardware interfaces
for valve, selector in zip(self.main.valves, self.valveSelects):
selector.callback = valve.set_and_open_port
selector.set_items(valve.available_ports())
return
# ------------------------------------------------------------------------------------------------
def _setupUi(self):
# basic window setup
self.setWindowTitle("KF: Valves1")
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 valve MIDI input simulator tab
self.mainTab = QtWidgets.QWidget(self)
self.mainLayout = QtWidgets.QVBoxLayout(self.mainTab)
self.valve_MIDI_controller = kf.QtMPD218.QtMPD218() # generate a simulated MPD218 controller
self.mainLayout.addWidget(self.valve_MIDI_controller)
self.tabs.addTab(self.mainTab, 'MIDI')
# 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.valveMidiInputCombo = kf.QtConfig.QtConfigComboBox()
self.configForm.addField("Valve MIDI input", self.valveMidiInputCombo)
self.valveSelects = list()
for i in range(self.main.num_valve_sets):
valveSelect = kf.QtConfig.QtConfigComboBox()
self.configForm.addField("Valve %d output serial port" % (i+1), valveSelect)
self.valveSelects.append(valveSelect)
# 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
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'))
#
# Other GUI object with persistent state should be updated from the configuration here.
#
# MIDI
self.valveMidiInputCombo.select_item(config['midi'].get('valve_midi_input', fallback='<no selection>'))
# valves
for i, valveSelect in enumerate(self.valveSelects):
key = "valve_%d_output_serial_port" % (i+1)
valveSelect.select_item(config['valves'].get(key, fallback = '<no selection>'))
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()
#
# Other GUI object with persistent state should update the configuration object here.
#
# MIDI
config['midi']['valve_midi_input'] = self.valveMidiInputCombo.current_item()
# valves
for i, valveSelect in enumerate(self.valveSelects):
key = "valve_%d_output_serial_port" % (i+1)
config['valves'][key] = valveSelect.current_item()
return
# --------------------------------------------------------------------------------------------------
################################################################
[docs]class ValveMIDILogic(kf.midi.MIDIProcessor):
"""Core performance logic for processing MIDI input into valve commands."""
def __init__(self, main):
super().__init__()
self.main = main
return
#---- methods for distributing valve events across multiple valve sets ---------
[docs] def set_speed(self, valve_index, speed):
"""Map a given valve index to the particular valve set."""
set_index = valve_index // 4
valve_id = valve_index % 4
if set_index < self.main.num_valve_sets:
self.main.valves[set_index].set_speed(valve_id, speed)
return
#---- methods to process MIDI messages -------------------------------------
[docs] def note_on(self, channel, key, velocity):
"""Process a MIDI Note On event."""
log.debug("ValveMIDILogic received note on: %d, %d", key, velocity)
row, col, bank = self.decode_mpd218_key(key)
# Each pair of pads maps to a single valve. Each bank can address up to 8 valves (two sets).
valve_index = 8*bank + 4*(row // 2) + col
# Scale the MIDI velocity to a [-100, 100] range.
delta = int(velocity * 100.0 / 127)
if row == 1 or row == 3:
delta = -delta
self.set_speed(valve_index, delta)
[docs] def note_off(self, channel, key, velocity):
"""Process a MIDI Note Off event."""
log.debug("ValveMIDILogic received note off: %d, %d", key, velocity)
row, col, bank = self.decode_mpd218_key(key)
return
[docs] def control_change(self, channel, cc, value):
"""Process a MIDI Control Change event."""
knob, bank = self.decode_mpd218_cc(cc)
log.debug("Valve control change %d on knob %d bank %d", cc, knob, bank)
[docs] def channel_pressure(self, channel, pressure):
"""Process a MIDI Channel Pressure event."""
log.debug("channel aftertouch: %d", pressure)
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__")
# just hard-coded for now
self.num_valve_sets = 2
# 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()
# create the interface window
self.window = AppWindow(self)
#
# Create and initialize top-level objects here.
#
# Initialize the MIDI input system for controlling valves.
self.valve_midi_logic = ValveMIDILogic(self)
self.valve_midi_listener = kf.midi.QtMIDIListener()
self.valve_midi_listener.connect_midi_processor(self.valve_midi_logic)
# Initialize the hardware valve system.
self.valves = [kf.valve.QtSerialValve() for i in range(self.num_valve_sets)]
# Finish connecting the window callbacks.
self.window.connect_callbacks()
# start the graphics animation timer
self.frame_interval = 0.040
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['midi'] = { }
self.config['valves'] = { }
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)
# ---- application event handlers -----------------------------------------------
[docs] def app_has_started(self):
super().app_has_started()
self.apply_configuration()
[docs] def app_is_exiting(self):
#
# hardware and network connections should be closed here
#
for valve in self.valves:
valve.close()
super().app_is_exiting()
#--- generate graphics animation updates ---------------------------------------------------
[docs] def frame_timer_tick(self):
# Method called at intervals by the animation timer to update the model and graphics.
pass
################################################################
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()