Python Programming/GUI/Oscilloscope

This tutorial guides you to develop the Oscilloscope-like desktop application with Tkinter, Matplotlib and 3rd libary pySerial.

Features

Oscilloscope features: Plot the value receives from MCU with Play, Pause, Stop button.

Import csv file to plot static chart

  • Import csv file included raw data sent from MCU
  • Y-axis: MCU raw data, X-axis: line number from csv file
  • Give alert if invalid csv file is imported

Pick your own drawChartIcon.png, oscilloscopeIcon.png, pause.png, play.png and stop.png for this program.

Existed issues

  • Button stop doesn't work
  • Serial port and baudrate must be initially hardcode

Source code

serialhandler.py

# serialhandler.py
from threading import Thread
import serial
import time
import collections
import struct 
import numpy as np
import os
import math

import csv
from datetime import datetime

from tkinter import filedialog
from tkinter import messagebox

class SerialPlot:
    def __init__(self, serial_port = '/dev/ttyUSB0', serial_baudrate = 38400, plot_length = 100, data_num_bytes = 2, window = None):
        self.port = serial_port
        self.baud = serial_baudrate
        self.plot_max_length = plot_length
        self.data_num_bytes = data_num_bytes
        self.raw_data = bytearray(data_num_bytes)
        self.data = collections.deque([0] * plot_length, maxlen=plot_length)
        self.is_run = True
        self.is_receiving = False

        #Thread set up
        self.serial_thread = None

        self.plot_timer = 0
        self.previous_timer = 0

        print('Trying to connect to: ' + str(serial_port) + ' at ' + str(serial_baudrate) + ' BAUD.')
        try:
            self.serial_connection = serial.Serial(serial_port, serial_baudrate, timeout=4)
            print('Connected to ' + str(serial_port) + ' at ' + str(serial_baudrate) + ' BAUD.')
        except:
            print("Failed to connect with " + str(serial_port) + ' at ' + str(serial_baudrate) + ' BAUD.')
 
    def read_serial_start(self):
        if self.serial_thread == None:
            self.serial_thread = Thread(target=self.background_thread)
            self.serial_thread.start()
            # Block till we start receiving values
            while self.is_receiving != True:
                time.sleep(0.1)  

    def get_serial_data(self, frame, oscilloscope_lines, line_value_text, line_label, time_text):
        currentTimer = time.perf_counter()
        self.plot_timer = int((currentTimer - self.previous_timer) * 1000)     # the first reading will be erroneous
        self.previous_timer = currentTimer
        time_text.set_text('Plot Interval = ' + str(self.plot_timer) + 'ms')

        oscilloscope_lines.set_data(range(self.plot_max_length), self.data)
 
    def background_thread(self):    # retrieve data
        time.sleep(1.0)  # give some buffer time for retrieving data
        self.serial_connection.reset_input_buffer()
        while (self.is_run):
            self.serial_connection.readinto(self.raw_data)
            value,  = struct.unpack('f', self.raw_data)    # use 'h' for a 2 byte integer

            if value > 100 or value < -100:
                value = 0
                self.serial_connection.reset_input_buffer()

            self.data.append(value)
            
            self.is_receiving = True      
 
    def close(self):
        self.is_run = False
        self.serial_thread.join()
        self.serial_connection.close()
        print('Disconnected...')

filehandler.py

from tkinter import filedialog
from tkinter import messagebox
from tkinter import *
import os, csv

class FileHandler():
	def open_csv_file_to_load(self, window, static_chart_value):
		static_chart_value['x_values'] = []
		static_chart_value['y_values'] = []
		currentDirectory = os.getcwd()
		window.filename = filedialog.askopenfilename(initialdir = currentDirectory, title = "Select csv file", filetypes = [("csv files","*.csv")])
		with open(window.filename, 'r') as csv_file:
			csv_reader = csv.reader(csv_file)

			for line in csv_reader:
				if len(line) == 0: pass
				else:
					try:
						static_chart_value['x_values'].append(int(line[0]))
						# self.x_val.append(datetime.strptime(line[0], '%Y-%m-%d %H:%M:%S.%f')) # convert to datetime
						static_chart_value['y_values'].append(float(line[1])) # convert to float
					except:
						messagebox.showinfo("Invalid value in csv file", " Invalid data type or character was found in csv file. Draw chart mode only accept csv file include datetime and float value")

guisetup.py

from tkinter import *
from tkinter.ttk import *
from tkinter import filedialog
from datetime import datetime
from tkinter import messagebox
from filehandler import FileHandler

import tkinter.ttk as comboBox
import os, csv, sys
import serial.tools.list_ports

class GUI:
	def __init__(self):
		self.window = Tk()
		# Main GUI handler for main window
		width_window = self.window.winfo_screenwidth()
		height_window = self.window.winfo_screenheight()

		# full screen window, appear at x=0, y=0
		self.window.geometry("%dx%d+0+0"%(width_window, height_window))

		self.note = Notebook(self.window)

		self.draw_static_chart_tab = Frame(self.note)
		self.draw_static_chart_tab.pack(side=TOP, fill=BOTH)
		self.oscilloscope_tab = Frame(self.note)

		self.file_handler = FileHandler()

		self.static_chart_value = {
			"x_values": [],
			"y_values": []
		}

	def menu_setup(self):
		self.menu_bar = Menu(self.window)
		self.window.config(menu=self.menu_bar)

		self.file_submenu = Menu(self.menu_bar) # File SubMenu to ask user to import csv file

		self.tools_submenu = Menu(self.menu_bar) # Tool Submenu will display the connected PORT

		# submenu File to import CSV file
		self.menu_bar.add_cascade(label="File", menu=self.file_submenu)

		# submenu 
		self.menu_bar.add_cascade(label="Tools", menu=self.tools_submenu)

		# Import CSV file to draw static chart
		self.file_submenu.add_command(
			label="Open file",
			command = lambda: self.file_handler.open_csv_file_to_load(
				self.window,
				self.static_chart_value
			)
		)

	def tools_sub_menu_setup(self):
		# List connected PORT
		self.connected_port_menu = Menu(self.tools_submenu) # , postcommand= lambda: self.scanForExistedPorts(self.connectedPORTMenu))

		# Tools sub menu
		# Display connected COM Port
		self.tools_submenu.add_cascade(label="Ports", menu=self.connected_port_menu)

	# Load all existed images
	def image_init(self):
		draw_chart_icon = PhotoImage(file="icon/drawChartIcon.png", master=self.window)
		self.draw_chart_icon_resize = draw_chart_icon.subsample(10, 10)

		oscilloscope_icon = PhotoImage(file="icon/oscilloscopeIcon.png", master=self.window)
		self.oscilloscope_icon_resize = oscilloscope_icon.subsample(16, 16)

		play_icon = PhotoImage(file = "icon/play.png", master=self.window)
		self.play_icon_resize = play_icon.subsample(20, 20)

		pause_icon = PhotoImage(file = "icon/pause.png", master=self.window)
		self.pause_icon_resize = pause_icon.subsample(20, 20)

		stop_icon = PhotoImage(file = "icon/stop.png", master=self.window)
		self.stop_icon_resize = stop_icon.subsample(20, 20)

	def notebook_gui_setup(self):
		self.note.add(self.draw_static_chart_tab, text = "Draw chart", image=self.draw_chart_icon_resize, compound=LEFT)
		self.note.add(self.oscilloscope_tab, text= "Oscilloscope", image=self.oscilloscope_icon_resize, compound=LEFT)
		self.note.pack(fill=BOTH, expand=True)

	def widget_setup(self):
		self.button_start_plotting = Button(self.oscilloscope_tab, image = self.play_icon_resize)
		self.button_start_plotting.place(x=5, y = 5)

		self.button_pause_plotting = Button(self.oscilloscope_tab, image = self.pause_icon_resize)
		self.button_pause_plotting.place(x=40, y = 5)

		self.button_stop_plotting = Button(self.oscilloscope_tab, image = self.stop_icon_resize)
		self.button_stop_plotting.place(x=75, y = 5)

		self.button_plot_static_chart = Button(self.draw_static_chart_tab, image = self.play_icon_resize)
		self.button_plot_static_chart.place(x=5, y = 5)

	def figure_setup(self, firgure_plot_static_chart, canvas_static_chart):
		self.firgure_plot_static_chart = firgure_plot_static_chart
		self.canvas_static_chart = canvas_static_chart

	def buttonFunctionSetup(self, animation_object, animation):
		self.button_start_plotting['command'] = lambda: animation_object.oscilloscope_start_mode(animation_object, animation)
		self.button_pause_plotting['command'] = lambda: animation_object.oscilloscope_pause_mode(animation_object, animation)
		self.button_plot_static_chart['command'] = lambda: animation_object.plot_static_chart(
				self.static_chart_value["x_values"], 
				self.static_chart_value["y_values"], 
				self.firgure_plot_static_chart, 
				self.canvas_static_chart
			)

	def tkinter_mainloop(self):
		self.window.mainloop()

animationplot.py

import matplotlib.pyplot as plt
import matplotlib.animation as animation

from tkinter import *
from tkinter import messagebox

from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk

class AnimationPlot:
	def __init__(self):
		# plotting starts below
		self.plot_interval = 1 # Period at which the plot animation updates [ms]
		self.xmin = 0
		self.max_plot_length = 6000
		self.xmax = self.max_plot_length
		self.ymin = -30
		self.ymax = 30

	def figure_setup(self, static_chart_tab, oscilloscope_tab):
		#Figure set up in static chart tab
		figure_static_chart = plt.Figure(figsize=(20,8), dpi=100)
		self.firgure_plot_static_chart = figure_static_chart.add_subplot(111)
		self.canvas_static_chart = FigureCanvasTkAgg(figure_static_chart, static_chart_tab)
		NavigationToolbar2Tk(self.canvas_static_chart, static_chart_tab)
		self.canvas_static_chart.get_tk_widget().place(x=0, y = 50)

		# Figure set up in Oscilloscope mode
		self.oscilloscope_figure = plt.figure(figsize=(20, 8))
		oscilloscope_axes = plt.axes(xlim=(self.xmin, self.xmax), ylim=(float(self.ymin - (self.ymax - self.ymin) / 10), float(self.ymax + (self.ymax - self.ymin) / 10)))
		oscilloscope_axes.set_title('Smart Energy tracking Oscilloscope')
		oscilloscope_axes.set_xlabel("Time (ms)")
		oscilloscope_axes.set_ylabel("Current value")

		self.line_label = 'Current value'
		self.time_text = oscilloscope_axes.text(0.70, 0.95, '', transform=oscilloscope_axes.transAxes)
		self.oscilloscope_lines = oscilloscope_axes.plot([], [], label=self.line_label)[0]
		self.line_value_text = oscilloscope_axes.text(0.50, 0.90, '', transform=oscilloscope_axes.transAxes)

		canvas = FigureCanvasTkAgg(self.oscilloscope_figure, master=oscilloscope_tab)
		canvas.get_tk_widget().place(x=0, y = 50)

		oscilloscope_toolbar = NavigationToolbar2Tk(canvas, oscilloscope_tab)
	
	def start_oscilloscope(self, getDataFunction):
		return animation.FuncAnimation(self.oscilloscope_figure, getDataFunction, fargs=(self.oscilloscope_lines, self.line_value_text, self.line_label, self.time_text), interval=self.plot_interval)

	"""
	"""
	def oscilloscope_start_mode(self, animation_object, animation):
		animation.event_source.start()
		animation_object.anim_running = True

	"""
	"""
	def oscilloscope_pause_mode(self, animation_object, animation):
		animation.event_source.stop()
		animation_object.anim_running = False

	"""
	This function will be called in GUISetup.py

	Usecase: Alert user when no csv is imported, else: start drawing
	Attributes
	----------
	static_chart_tab: Tab to store the static chart with imported csv data
	canvas_static_chart: canvas_static_chart to handle drawing
	"""
	def plot_static_chart(self, x_val, y_val, static_chart_tab, canvas_static_chart):
		if len(x_val) == 0:
			messagebox.showinfo("No csv file has been imported", "You haven't imported a csv file or the csv file has been corrupted")
		else:
			static_chart_tab.cla()
			static_chart_tab.plot(x_val,y_val)
			canvas_static_chart.draw()

main.py

from serialhandler import SerialPlot
from guisetup import GUI
from animationplot import AnimationPlot 

draw_static_chart_tab, oscilloscope_tab = None, None

#Set up UI for App
def gui_init(gui_setup_object):
    global draw_static_chart_tab, oscilloscope_tab
    gui_setup_object.image_init()
    gui_setup_object.menu_setup()
    gui_setup_object.tools_sub_menu_setup()
    gui_setup_object.notebook_gui_setup()
    draw_static_chart_tab = gui_setup_object.draw_static_chart_tab
    oscilloscope_tab = gui_setup_object.oscilloscope_tab

def main():
    # portName = 'COM4'     # for windows users
    portName = 'COM5'     # for windows users

    # portName = '/dev/ttyUSB0'
    # portName = '/dev/ttyACM0'
    # portName = '/dev/ttyACM1'

    baud_rate = 115200
    max_plot_length = 6000
    data_num_bytes = 4        # number of bytes of 1 data point

    gui_object = GUI()
    gui_init(gui_object)

    gui_object.window.title("Smart energy tracking system")

    animation_object = AnimationPlot()
    
    #Realtime is in oscilloscopeTab so it is put in gui_setup_object.oscilloscopeTab
    animation_object.figure_setup(draw_static_chart_tab, oscilloscope_tab)

    gui_object.widget_setup()
    gui_object.figure_setup(animation_object.firgure_plot_static_chart, animation_object.canvas_static_chart)

    s = SerialPlot(portName, baud_rate, max_plot_length, data_num_bytes, gui_object.window)   # initializes all required variables
    s.read_serial_start()                                               # starts background thread
    
    oscilloscope_animate = animation_object.start_oscilloscope(s.get_serial_data) 

    gui_object.buttonFunctionSetup(animation_object, oscilloscope_animate)

    # gui_setup_object.exitSetup(animation_object)

    gui_object.tkinter_mainloop()
    s.close()

if __name__ == '__main__':
    main()
Category:Python Category:Completed resources
Category:Completed resources Category:Python