Search code examples
pythontkinterpython-asynciopyserial

Keep reading data while updating GUI tkinter in a non blocking way


I'm new to asyncio, threading, subrocess and I'm trying to build an app that reads data from the serial continuesly, put them inot a queue used by another process/thread/asyncio function to consume them and show into a tkinter GUI.

I was able to make the GUI non blocking while continue reading the data with the code below.

import tkinter as tk
import time
import queue
import logging
import serial
import sys


class SampleApp(tk.Tk):
    def __init__(self, *args, **kwargs):
        tk.Tk.__init__(self, *args, **kwargs)
        self.serial_text_label = tk.Label(self, text="String")
        self.serial_text_label.pack()
        self.serial_text = tk.Text(self, height=1, width=21)
        self.serial_text.pack()

        self.port = 'COM3'
        self.baud = 38400

        self.ser = serial.Serial(self.port, self.baud, timeout=0)
        if self.ser.isOpen():
            self.ser.close()
            self.ser.open()

        self.ser.reset_input_buffer()
        self.ser.reset_output_buffer()

        logging.info("created serial port")

        # start the serial_text_label "ticking"
        self.update_screen()

    def update_screen(self):

        self.serial_text.delete('1.0', tk.END)

        data = ""
        data_raw = self.ser.read(1)
        if data_raw == b'\x02':
            data_raw = self.ser.read(6)
            data = "02-" + str(data_raw.hex('-'))
            self.ser.reset_input_buffer()
            self.ser.reset_output_buffer()

        self.serial_text.insert(tk.END, data)
        # self.serial_text_label.configure(text=data)

        # call this function again when want to refresh
        self.after(500, self.update_screen)


if __name__== "__main__":

    logging.basicConfig(stream=sys.stdout, level=logging.DEBUG,
                        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)', )

    app = SampleApp()

    app.mainloop()

The only issue with my code is that all the reading and the elaboration of thedata coming from the serial port are inside the refresh cycle that update the screen. I would like to detach the function to some sort of thread/subprocess that works concurrently with the refresh of the GUI.

What I've tried is to create an async def do_serial() function inside class SampleApp(tk.Tk) as below:

async def do_serial():
    logging.debug("do serial")

    data = ""
    data_raw = ser.read(1)
    if data_raw == b'\x02':
        data_raw = ser.read(6)
        data = "02-" + str(data_raw.hex('-'))
        ser.reset_input_buffer()
        ser.reset_output_buffer()

    # add data to queue
    if data != "":
        logging.debug('put:' + str(data))
        incoming_serial_queue.put(data)

    await asyncio.sleep(1)

and inside the update_screen function I call asyncio.run(do_serial())

    if not incoming_serial_queue.empty():
        data = incoming_serial_queue.get()

Unfortunately it doesn't work and the code doesn't even show the GUI

Is there a way to process the data from the serial in an asyncronus/parallel way without having to write all the function inside the refresh GUI function?


Solution

  • Try making a blocking calls in a separate thread. Inside update_screen, you should make calls fast enough to not no freeze GUI. That means, you should not read the input there.

    import tkinter as tk
    import time
    import queue
    import logging
    import serial
    import sys
    import threading
    from concurrent.futures import ThreadPoolExecutor
    
    
    class SampleApp(tk.Tk):
        def __init__(self, *args, **kwargs):
            tk.Tk.__init__(self, *args, **kwargs)
            self.serial_text_label = tk.Label(self, text="String")
            self.serial_text_label.pack()
            self.serial_text = tk.Text(self, height=1, width=21)
            self.serial_text.pack()
    
            self.port = 'COM3'
            self.baud = 38400
    
            self.ser = serial.Serial(self.port, self.baud, timeout=0)
            if self.ser.isOpen():
                self.ser.close()
                self.ser.open()
    
            self.ser.reset_input_buffer()
            self.ser.reset_output_buffer()
    
            logging.info("created serial port")
    
            # start the serial_text_label "ticking"
            self._update_scheduled = threading.Condition()
            self._terminating = threading.Event()
            self.update_screen()
        
        def mainloop(self):
            with ThreadPoolExecutor() as executor:
                future = executor.submit(self._do_update_screen_loop)
                try:
                    return super().mainloop()
                finally:
                    # letting the thread to know we're done
                    self._terminating.set()
                    with self._update_scheduled:
                        self._update_scheduled.notify_all()
    
        def update_screen(self):
            with self._update_scheduled:
                self._update_scheduled.notify_all()
            self.after(500, self.update_screen)
    
        def _do_update_screen_loop(self):
            while True:
                with self._update_scheduled:
                    self._update_scheduled.wait()
                if self._terminating.is_set():
                    return
                self._do_update_screen()
    
        def _do_update_screen(self):            
            self.serial_text.delete('1.0', tk.END)
    
            data = ""
            data_raw = self.ser.read(1)
            if data_raw == b'\x02':
                data_raw = self.ser.read(6)
                data = "02-" + str(data_raw.hex('-'))
                self.ser.reset_input_buffer()
                self.ser.reset_output_buffer()
    
            self.serial_text.insert(tk.END, data)
            # self.serial_text_label.configure(text=data)
    
    
    if __name__== "__main__":
    
        logging.basicConfig(stream=sys.stdout, level=logging.DEBUG,
                            format='%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)', )
    
        app = SampleApp()
            
        app.mainloop()