Threading reading a serial port in Python (with a GUI)

I want to trigger an event whenever there is data to be read from a serial port while running a GUI. The pySerial module apparently has experimental functionality for that, but it isn't particularly well documented (I couldn't find any useful examples in the API).

This question appears to deal with the same or at least very similar task, but doesn't provide instructions to replicate it or working code examples.

I came up with this code:

import tkinter as tk
import serial
import threading

# Create GUI window
window = tk.Tk()

# Initialize the port
myPort = serial.Serial('/dev/ttyUSB0')

# Function to call whenever there is data to be read
def readFunc(port):
    port.readline()
    print('Line read')

# Configure threading
t1 = threading.Thread(target = readFunc, args=[myPort])
t1.start()

# Main loop of the window
window.mainloop()

Running it does indeed trigger the event, but only once. Why is that? Is there a "recommended" way to do this as by using the functionality of pySerial itself?

Alternatively, I would also run the function to read and process data on an event like you can with GUI elements. If that is the better solution, how would that be done?

Related question (unanswered), probably makes this question a duplicate

Edit: Here is a minimal example derived from the answer below that changes the text of a label whenever data is read to the incoming data:

import tkinter as tk

from serial import Serial
from serial.threaded import ReaderThread, Protocol

app = tk.Tk()
label = tk.Label(text="A Label")
label.pack()

class SerialReaderProtocolRaw(Protocol):
    port = None

    def connection_made(self, transport):
        """Called when reader thread is started"""
        print("Connected, ready to receive data...")

    def data_received(self, data):
        """Called with snippets received from the serial port"""
        updateLabelData(data)

def updateLabelData(data):
    data = data.decode("utf-8")
    label['text']=data
    app.update_idletasks()

# Initiate serial port
serial_port = Serial("/dev/ttyACM0")

# Initiate ReaderThread
reader = ReaderThread(serial_port, SerialReaderProtocolRaw)
# Start reader
reader.start()

app.mainloop()

Your main concern is to be thread safe, when You are updating GUI from another running Thread.
To achieve this, we can use .after() method, which executes callback for any given tk widget.

Another part of Your request is to use Threaded serial reader.
This can be achieved by using ReaderThread accompanied with Protocol.

You can pick two protocols:

  • raw data reader protocol, which reads data as they come
  • line reader protocol, which enables us to read lines of data

Here is working code example, with two protocols mentioned above, so You can pick which one suits You. Just remember, that all data coming from serial port are just raw bytes.

import tkinter as tk

from serial import Serial
from serial.threaded import ReaderThread, Protocol, LineReader


class SerialReaderProtocolRaw(Protocol):
    tk_listener = None

    def connection_made(self, transport):
        """Called when reader thread is started"""
        if self.tk_listener is None:
            raise Exception("tk_listener must be set before connecting to the socket!")
        print("Connected, ready to receive data...")

    def data_received(self, data):
        """Called with snippets received from the serial port"""
        self.tk_listener.after(0, self.tk_listener.on_data, data.decode())


class SerialReaderProtocolLine(LineReader):
    tk_listener = None
    TERMINATOR = b'\n\r'

    def connection_made(self, transport):
        """Called when reader thread is started"""
        if self.tk_listener is None:
            raise Exception("tk_listener must be set before connecting to the socket!")
        super().connection_made(transport)
        print("Connected, ready to receive data...")

    def handle_line(self, line):
        """New line waiting to be processed"""
        # Execute our callback in tk
        self.tk_listener.after(0, self.tk_listener.on_data, line)


class MainFrame(tk.Frame):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.listbox = tk.Listbox(self)
        self.listbox.pack()
        self.pack()

    def on_data(self, data):
        print("Called from tk Thread:", data)
        self.listbox.insert(tk.END, data)


if __name__ == '__main__':
    app = tk.Tk()

    main_frame = MainFrame()
    # Set listener to our reader
    SerialReaderProtocolLine.tk_listener = main_frame
    # Initiate serial port
    serial_port = Serial("/dev/ttyUSB0")
    # Initiate ReaderThread
    reader = ReaderThread(serial_port, SerialReaderProtocolLine)
    # Start reader
    reader.start()

    app.mainloop()