diff --git a/arduino_timetable/gui.py b/arduino_timetable/gui.py new file mode 100644 index 0000000..4cc5abf --- /dev/null +++ b/arduino_timetable/gui.py @@ -0,0 +1,403 @@ +''' A Tkinter Gui Application for running scheduled commands ''' + +# library imports +import asyncio +import os +import serial +import tkinter as tk +import tkinter.ttk as ttk + +from enum import Enum +from datetime import timedelta +from multiprocessing import Process, Lock +from tkinter import filedialog +from tkinter import messagebox + +# local imports +from .timetable import parse_excel_file, find_arduino_port + +# delay for checking if commands are running on the Arduino +CHECK_DELAY = 100 +# delay after connecting to the arduino before a programm can be run +CONNECT_DELAY = 2500 + + +class ArduinoState(Enum): + ''' State of the current Arduino setup ''' + not_initialized = 'not_initialized' + connected = 'connected' + running = 'running' + finished = 'finished' + + +def arduino_subprocess(lock, com_port, scheduled_commands, **kwargs): + ''' connecting to the Arduiono and running the scheduled commands + + lock: + Separaion controll between establishing connection and running + commands. Must be of class multiprocessing.Lock + com_port: + port where the Arduino is connected + scheduled_commands: + list of .timetable.TimedCommands to run + **kwargs: + arguments used to open serial connection + the most importent one is 'baudrate', defaults to 9600 + + To run the scheduled commands a separate process is used. Otherwise the + execution of the gui would stop until the commands finish. This would + lead to an unresponsive gui programm. + + The serial connection is not pickable and therfore the connection to the + arduino and running the commands must be made in the same process. To + have a separation between establishing the connection and running the + commands, a simple lock is used. Before starting the subprocess, the lock + will be acquired by the main programm and releasing the lock will start + the commands. + ''' + # get a custom baudrate or use the default 9600 + baudrate = kwargs.pop('baudrate', 9600) + with serial.Serial(com_port, baudrate=baudrate, **kwargs) as connection: + # use a fresh asyncio event loop every time + loop = asyncio.new_event_loop() + # wait until the lock is released to start the commands + lock.acquire() + try: + # schedule the commands for execution + for scheduled_command in scheduled_commands: + loop.call_later( + scheduled_command.delta.total_seconds(), + connection.write, + scheduled_command.cmd + ) + # loop should stop one second after last command in list + timed_loop_stop = scheduled_command.delta + timedelta(seconds=1) + loop.call_later(timed_loop_stop.total_seconds(), loop.stop) + # run the loop + try: + loop.run_forever() + except KeyboardInterrupt: + loop.stop() + loop.close() + finally: + lock.release() + + +def timedelta_as_str(timedelta): + ''' a nice string output for a timedelta + + shamlessly copied from the original timedelta implementation, but the + original implementation displays the microseconds always with six + characters. + ''' + mm, ss = divmod(timedelta.seconds, 60) + hh, mm = divmod(mm, 60) + s = "%d:%02d:%02d" % (hh, mm, ss) + if timedelta.days: + def plural(n): + return n, abs(n) != 1 and "s" or "" + s = ("%d day%s, " % plural(timedelta.days)) + s + + # microseconds are stored as int between 0 and 999999 inclusive + s = s + ".%d" % (timedelta.microseconds / 10**5) + + return s + + +class FilePanel(tk.Frame): + ''' Tkinter Panel for selecting files ''' + + def __init__(self, parent): + tk.Frame.__init__(self, parent.master) + self._parent = parent + self.btn_files = tk.Button( + self, + text="Select Excel File", + command=self._parent.select_files + ) + self.btn_files.pack(pady=5, padx=5) + sep = ttk.Separator(self, orient=tk.HORIZONTAL) + sep.pack(side=tk.BOTTOM, fill=tk.X) + self.pack(side=tk.TOP, fill=tk.X) + + +class ParsedCommandsPanel(tk.Frame): + ''' Tkinter Panel displaying the scheduled commands to be run ''' + + def __init__(self, parent): + tk.Frame.__init__(self, parent.master) + self._parent = parent + self.label = tk.Label(self) + self.label.pack(fill=tk.X, pady=5, padx=5) + self.messages = tk.Message(self, text='', font=("Courier", 8)) + self.messages.pack(fill=tk.X, pady=5, padx=5) + self.pack(side=tk.TOP, fill=tk.X) + + def set_schedule_text(self, text): + ''' display the scheuled commands to run ''' + self.messages.config(text=text) + self.messages.update_idletasks() + + def set_label_text(self, text): + ''' display the filename of the scheduled commands ''' + self.label.config(text=text) + self.label.update_idletasks() + + def clear_text(self): + ''' clear all text in this panel ''' + self.set_schedule_text('') + self.set_label_text('') + + def show_schedule(self, command_list): + ''' transformes the command list to a textual representation ''' + times = [timedelta_as_str(cmd.delta) for cmd in command_list] + commands = [cmd.command for cmd in command_list] + # search for the longest time text + max_len_limes = max(len(time_str) for time_str in times) + # template for each command line + template = '{delta:<{size}} {cmd}' + # assemble all lines + lines = [ + template.format(delta=delta, size=max_len_limes, cmd=cmd) + for delta,cmd + in zip(times, commands) + ] + # return the text for the command list + self.set_schedule_text('\n'.join(lines)) + + +class ActionPanel(tk.Frame): + ''' Tkinter Panel for the action buttons ''' + + def __init__(self, parent): + tk.Frame.__init__(self, parent.master) + self._parent = parent + # different configurations for the 'run' button + self.btn_config_disabled = { + 'text': 'Run Commands', + 'background': 'SystemButtonFace', + 'activebackground': 'SystemButtonFace', + 'state': 'disabled' + } + self.btn_config_wait = { + 'text': 'Connecting', + 'background': 'SystemButtonFace', + 'activebackground': 'SystemButtonFace', + 'state': 'disabled' + } + self.btn_config_ready = { + 'text': 'Run Comands', + 'background': 'green', + 'activebackground': 'green', + 'state': 'normal' + } + self.btn_config_running = { + 'text': 'Stop Comands', + 'background': 'red', + 'activebackground': 'red', + 'state': 'active' + } + ttk.Separator(self, orient=tk.HORIZONTAL).pack(side=tk.TOP, fill=tk.X) + self.btn_go = tk.Button( + self, + command=self._parent.go_button_clicked, + **self.btn_config_disabled + ) + self.btn_go.pack(side=tk.LEFT, pady=5, padx=5) + self.btn_quit = tk.Button(self, text="Quit", command=self._parent.quit) + self.btn_quit.pack(side=tk.RIGHT, pady=5, padx=5) + self.pack(side=tk.BOTTOM, fill=tk.X) + + def update_button_state(self): + ''' update the button state reflecting the state of the Arduino ''' + if self._parent.arduino_state == ArduinoState.running: + self.btn_go.config(**self.btn_config_running) + elif self._parent.arduino_state == ArduinoState.not_initialized: + self.btn_go.config(**self.btn_config_disabled) + else: + # establishing the connection takes some time, show a 'connecting' + # state and after some time show the 'ready' state + self.btn_go.config(**self.btn_config_wait) + self.after(CONNECT_DELAY, self._enable_button) + self.btn_go.update_idletasks() + + def _enable_button(self): + ''' shows the 'ready' state of the button ''' + self.btn_go.config(**self.btn_config_ready) + self.btn_go.update_idletasks() + + +class Application(tk.Frame): + ''' The Tkinter application containing the buisiness logic ''' + + def __init__(self, master, available_commands): + ''' initialization + + master: + tkinter root object + + available_commands: + list of available commands for the Arduino + ''' + master.minsize(height=330, width=300) + tk.Frame.__init__(self, master) + self._master = master + self.pack(fill=tk.BOTH) + + # the layout sections + self.file_panel = FilePanel(self) + self.program_panel = ParsedCommandsPanel(self) + self.action_panel = ActionPanel(self) + + # properties for the buisiness logic + self.available_commands = available_commands + self.scheduled_commands = [] + self.arduino_process = None + self.arduino_lock = None + self._arduino_state = ArduinoState.not_initialized + self.reset() + + @property + def arduino_state(self): + ''' property for the current state of the arduino ''' + return self._arduino_state + + @arduino_state.setter + def arduino_state(self, new_state): + ''' setter for the arduino state property + + as soon as the property changes, the 'go'-button will be updated + ''' + self._arduino_state = new_state + self.action_panel.update_button_state() + + def select_files(self): + ''' selecting a new excel file with scheduled_commands ''' + # open the file selection dialog + opts = { + 'initialdir': '~/', + 'filetypes': [('Excel Files', '.xlsx')], + 'multiple': False} + excel_file = tk.filedialog.askopenfilename(**opts) + # if no excel file is selected, do nothing + if not excel_file: + return + # reset the application + self.reset() + # try to parse the excel file + self.scheduled_commands = parse_excel_file( + excel_file, + self.available_commands + ) + if self.scheduled_commands: + # if parsing resultet in a schedule, prepare everything to run + self.arduino_prepare_process() + self.program_panel.set_label_text(os.path.basename(excel_file)) + self.program_panel.show_schedule(self.scheduled_commands) + + def reset(self): + ''' reset the application state ''' + # stop any connection to the Arduino + if self.arduino_state in (ArduinoState.running, ArduinoState.connected): + self.arduino_stop_process() + self.action_panel.update_button_state() + self.program_panel.clear_text() + + def go_button_clicked(self): + ''' start scheduled commands or stop the execution ''' + if self.arduino_state == ArduinoState.running: + # 'Stop Commands' clicked. + self.arduino_stop_process() + self.arduino_prepare_process() + else: + # 'Run Commands' clicked. + # the connection is already established, we just need to release + # the lock + self.arduino_lock.release() + self.arduino_state = ArduinoState.running + # regular check if the scheduled commands finished + self.after(CHECK_DELAY, self._check_process) + + def _check_process(self): + ''' checks if the scheduled commands have already finished ''' + if self.arduino_process: + if self.arduino_process.is_alive(): + # still running, recheck after a short delay + self.after(CHECK_DELAY, self._check_process) + else: + # commands finished + self.arduino_stop_process() + self.arduino_prepare_process() + + def arduino_stop_process(self): + ''' stops a connected Arduino ''' + if self.arduino_state != ArduinoState.not_initialized: + # only do this if a connection was established + loop = asyncio.get_event_loop() + loop.stop() + loop.close() + self.arduino_process.terminate() + self.arduino_process = None + self.arduino_lock = None + self.arduino_state = ArduinoState.not_initialized + + def arduino_prepare_process(self): + ''' open a subprocess setting up the arduino ''' + # find the com port of the Arduino + try: + com_port = find_arduino_port() + except IOError as e: + messagebox.showerror('COM Port', 'No Arduino found') + return + except serial.SerialException: + messagebox.showerror('COM Port', 'Could not talk to Arduino') + return + # Create a lock and acquire it. This will put the subprocess on hold + # after establishing the connection. As soon as the lock is released, + # the scheduled commands will be run + self.arduino_lock = Lock() + self.arduino_lock.acquire() + # start the subprocess for the Arduino connection + self.arduino_process = Process( + target=arduino_subprocess, + args = ( + self.arduino_lock, + com_port, + self.scheduled_commands + ) + ) + self.arduino_process.start() + self.arduino_state = ArduinoState.connected + + def quit(self): + ''' stop the programm ''' + self.arduino_stop_process() + self._master.destroy() + + +def run_application(available_commands, title='Arduino Scheduled Commands'): + ''' run the gui application + + available_commands: + list of available commands for the Arduino + + title: + window title bar text + ''' + + # create the Tkinter root and instantiate the application + root = tk.Tk() + root.wm_title(title) + app = Application(master=root, available_commands=available_commands) + + # use app.quit() as callback if the window is closed (with the 'X' button) + # if an Arduino connection is established, the standard callback does not + # stop the subprocess and therfore the application would not quit + root.protocol("WM_DELETE_WINDOW", app.quit) + + # start the application + app.mainloop() + try: + root.destroy() + except tk.TclError: + pass