Search code examples
pythonmultithreadingperformanceraspberry-pi3

python Threads on raspberry pi 3 - speed optimization


I'm trying to get data from two sensors, one every 1 second and one every 10 seconds.

I have two functions which update a small OLED display with the values from the sensors. I want to have both functions run in perpetuity to always display the latest values. After doing my research, I thought I had found what I needed with Ray but it doesn't seem to work on the Pi 3. I then looked into Threads which I implemented like such:

from threading import Thread

def update_temp():
    ## get the values of the thermometer paint new one to OLED every second

def update_speed():
    ## get the values from the GPS and paint every 10 seconds

if __name__ == '__main__':
    temp_thread = Thread(target = update_temp)
    speed_thread = Thread(target = update_speed)
    
    temp_thread.start()
    speed_thread.start()

Now when I run this, both the functions update nicely, but fairly slowly. I guess the painting of the OLED with custom fonts, reading the sensor, talking to the GPS etc is kinda harsh on it but still: is there a way I could speed things up in the way I've setup Threads? What's the fuss with join()? As you can tell I'm fairly new!


Edit: here's the full code including what happens in the functions. I've removed some of the stuff (LED, other temp sensor) but left everything that runs at the moment. Thanks!

from os import system
from threading import Thread
import glob
import serial
import subprocess
import urllib
import urllib.request
import urllib.parse
import array
import requests
from time import sleep
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from luma.oled.device import sh1106
from PIL import ImageFont, Image, ImageDraw
import time
import board
import busio
import adafruit_bmp280
import subprocess
import RPi.GPIO as GPIO

################## config display ##################
device = sh1106(i2c(port=1, address=0x3C), rotate=0)
device.clear()

### setup different fonts
FA_solid = ImageFont.truetype('/home/pi/Desktop/fonts/fa-solid-900.ttf', 16)
text_large = ImageFont.truetype('/home/pi/Desktop/fonts/digital-7.ttf', 64)
text_medium = ImageFont.truetype('/home/pi/Desktop/fonts/digital-7.ttf', 28)
text_small = ImageFont.truetype('/home/pi/Desktop/fonts/digital-7.ttf', 12)
 
### Initialize drawing zone (aka entire screen)
output = Image.new("1", (128,64))
add_to_image = ImageDraw.Draw(output)

### coordinates always: padding-left, padding-top. the first pair of zone is always = start
# speed
speed_zone = [(0,0), (100,64)]
speed_start = (0,0)

# temp
temp_zone = [(100,48), (128,64)]
temp_start = (100,48)

# GPS status
icon_zone = [(108,0), (128,16)]
icon_start = (108,0)

# load icon
add_to_image.text(icon_start, "\uf252", font=FA_solid, fill="white")
device.display(output)

# usage
#add_to_image.rectangle(speed_zone, fill="black", outline = "black")
#add_to_image.text(speed_start, "\uf00c", font=FA_solid, fill="white")
#device.display(output)




################## config GPS and GPRS via FONA ##################
SECONDS_BTW_READS = 5
READINGS_PER_UPLOAD = 5
TARGET_URL = "https://some_url"




################## config external thermometer ##################
base_dir = '/sys/bus/w1/devices/'
device_folder = glob.glob(base_dir + '28*')[0]
device_file = device_folder + '/w1_slave'
 
def update_temp():
    while True:
        f = open(device_file, 'r')
        lines = f.readlines()
        f.close()
        equals_pos = lines[1].find('t=')
        if equals_pos != -1:
            temp_string = lines[1][equals_pos+2:]
            temp_c = round(float(temp_string) / 1000.0)
            add_to_image.rectangle(temp_zone, fill="black", outline = "black")
            add_to_image.text(temp_start, str(temp_c), font=text_medium, fill="white")
            device.display(output)
            time.sleep(30)



############################################
############################################
##########      Program start       ########
############################################
############################################



# Start PPPD
def openPPPD():
    print("Opening PPPD")
    # Check if PPPD is already running by looking at syslog output
    output1 = subprocess.check_output("cat /var/log/syslog | grep pppd | tail -1", shell=True)
    if b"secondary DNS address" not in output1 and b"locked" not in output1:
        while True:
            # Start the "fona" process
            subprocess.call("sudo pon fona", shell=True)
            sleep(2)
            output2 = subprocess.check_output("cat /var/log/syslog | grep pppd | tail -1", shell=True)
            if b"script failed" not in output2:
                break
    # Make sure the connection is working
    while True:
        output2 = subprocess.check_output("cat /var/log/syslog | grep pppd | tail -1", shell=True)
        output3 = subprocess.check_output("cat /var/log/syslog | grep pppd | tail -3", shell=True)
        if b"secondary DNS address" in output2 or b"secondary DNS address" in output3:
            return True
            print("PPPD opened successfully")

# Stop PPPD
def closePPPD():
    print("turning off cell connection")
    # Stop the "fona" process
    subprocess.call("sudo poff fona", shell=True)
    # Make sure connection was actually terminated
    while True:
        output = subprocess.check_output("cat /var/log/syslog | grep pppd | tail -1", shell=True)
        if b"Exit" in output:
            return True

# Check for a GPS fix
def checkForFix():
    print ("checking for fix")
    add_to_image.rectangle(icon_zone, fill="black", outline = "black")
    add_to_image.text(icon_start, "\uf124", font=FA_solid, fill="white") #location icon
    device.display(output)
        
    # Start the serial connection
    ser=serial.Serial('/dev/serial0', 115200, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=1)
    # Turn on the GPS
    ser.write(b"AT+CGNSPWR=1\r")
    ser.write(b"AT+CGNSPWR?\r")
    while True:
        response = ser.readline()
        if b" 1" in response:
            break
    # Ask for the navigation info parsed from NMEA sentences
    ser.write(b"AT+CGNSINF\r")
    while True:
            response = ser.readline()
            # Check if a fix was found
            if b"+CGNSINF: 1,1," in response:
                print ("fix found")
                print (response)
                add_to_image.rectangle(icon_zone, fill="black", outline = "black")
                device.display(output)
                return True
            
            # If a fix wasn't found, wait and try again
            if b"+CGNSINF: 1,0," in response:
                sleep(5)
                ser.write(b"AT+CGNSINF\r")
                print ("still looking for fix")
                add_to_image.rectangle(icon_zone, fill="black", outline = "black")
                add_to_image.text(icon_start, "\uf00d", font=FA_solid, fill="white") #X
                device.display(output)
            else:
                ser.write(b"AT+CGNSINF\r")

# Read the GPS data for Latitude and Longitude
def getCoord():
    # Start the serial connection
    ser=serial.Serial('/dev/serial0', 115200, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=1)
    ser.write(b"AT+CGNSINF\r")
    while True:
        response = ser.readline()
        if b"+CGNSINF: 1," in response:
            # Split the reading by commas and return the parts referencing lat and long
            array = response.split(b",")
            lat = array[3]
            lon = array[4]
            time = array[2]
            speed = array[6]
            return (lat,lon,time,speed)

# Start the program by opening the cellular connection and creating a bucket for our data
def update_speed():
    if openPPPD():    
        GPS_DATA = {}
        while True:
            # Close the cellular connection
            if closePPPD():
                print ("closing connection")
                sleep(1)
            # The range is how many data points we'll collect before streaming
            for i in range(READINGS_PER_UPLOAD):
                # Make sure there's a GPS fix
                if checkForFix():
                    # Get lat and long
                    if getCoord():
                        latitude, longitude, time, speed = getCoord()
                        coord = str(latitude) + "," + str(longitude)
                        print ("Coordinates:", coord)
                        print ("Time:", time)
                        print ("Step", i+1, "out of",READINGS_PER_UPLOAD)
                        
                        add_to_image.rectangle(speed_zone, fill="black", outline = "black")
                        add_to_image.text(speed_start, str(round(float(speed))), font=text_large, fill="white")
                        device.display(output)
                        
                        GPS_DATA[i] = {'lat': latitude, 'long' : longitude, 'time' : time, 'speed' : speed}
                        sleep(SECONDS_BTW_READS)
                        
                # Turn the cellular connection on every READINGS_PER_UPLOAD reads
                if i == (READINGS_PER_UPLOAD-1):
                    print ("opening connection")
                    add_to_image.rectangle(icon_zone, fill="black", outline = "black")
                    add_to_image.text(icon_start, "\uf7c0", font=FA_solid, fill="white") #sat dish
                    device.display(output)

                    if openPPPD():
                        print ("streaming")                    
                        add_to_image.rectangle(icon_zone, fill="black", outline = "black")
                        add_to_image.text(icon_start, "\uf382", font=FA_solid, fill="white") #upload
                        device.display(output)
                        
                        url_values = urllib.parse.urlencode(GPS_DATA)
                        #print(url_values)

                        full_url = TARGET_URL + '?' + url_values
                        with urllib.request.urlopen(full_url) as response:
                            print(response)
                           
                        print ("streaming complete")
                        GPS_DATA = {}  
                        add_to_image.rectangle(icon_zone, fill="black", outline = "black")
                        add_to_image.text(icon_start, "\uf00c", font=FA_solid, fill="white") #check
                        device.display(output)


if __name__ == '__main__':
    temp_thread = Thread(target = update_temp)
    speed_thread = Thread(target = update_speed)
    
    temp_thread.start()
    speed_thread.start()
    
    speed_thread.join()

Solution

  • There is a lot of stuff to improve here. There is no way I can address them all, just few suggestions:

    Firstly, device.display() is blocking. Instead of redrawing on every change, make batch updates when necessary:

    pending_redraw = False
    def update_display():
        while True:
            # there is a potential race condition here, not critical
            if pending_redraw:
                pending_redraw = False
                device.display()
            time.sleep(0.1)
    
     # somewhere near the bottom:
     display_thread = Thread(target=update_display)
     display_thread.start()
    

    Temperature thread - see inline comments:

    # avoid magic constants, even as simple as 't='
    def update_temp(temp_signature='t=', update_interval=30):
        # there is no need to open/close the file handler every time.
        # Moving open/close out of the loop:
        f = open(device_file, 'r')
        
        while True:
            # previously: lines[1] will fail if only one line was read 
            line = f.readline()
            # protip: instead of wrapping positive case in a huge IF,
            # return/continue early
            if temp_signature not in line:
                continue
            temp_string = line.split(temp_signature, 1)[-1]
            temp_c = round(float(temp_string) / 1000.0)
            add_to_image.rectangle(temp_zone, fill="black", outline = "black")
            add_to_image.text(temp_start, str(temp_c), font=text_medium, fill="white")
            pending_redraw = True
    
            # previously, if 't=' signature wasn't found, the thread 
            # immediately went to open/close the device handle
            # adding delay before that will save resources
            time.sleep(update_interval)
    
        lines = f.readlines()
    

    openPPPD and closePPPD have a ton of blocking calls, but .. leaving their optimization to you this time

    working with GPS:

    # Move out of checkForFix - opening/closign the port is blocking and expensive
    ser=serial.Serial('/dev/serial0', 115200, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=1)
    # Turn on the GPS
    ser.write(b"AT+CGNSPWR=1\r")
    ser.write(b"AT+CGNSPWR?\r")
    # slightly optimized version. Note the added sleep
    while b' 1' not in ser.readline(): time.sleep(0.1)
    
    def checkForFix():
        # checkForFix() is called only called from update_speed(), 
        # which immediately redraws the screen after. Safe to skip redraw
        print ("checking for fix")
        add_to_image.rectangle(icon_zone, fill="black", outline = "black")
        add_to_image.text(icon_start, "\uf124", font=FA_solid, fill="white") #location icon
    
        while True:  # simplified the logic a little bit, saving few blocking writes
            ser.write(b"AT+CGNSINF\r")
            add_to_image.rectangle(icon_zone, fill="black", outline = "black")
            response = ser.readline()
            # Check if a fix was found
            if b"+CGNSINF: 1,1," in response:
                print ("fix found")
                print (response)
                pending_redraw = True
                return True
            
            # If a fix wasn't found, wait and try again
            if b"+CGNSINF: 1,0," in response:
                print("still looking for fix")
                add_to_image.rectangle(icon_zone, fill="black", outline = "black")
                add_to_image.text(icon_start, "\uf00d", font=FA_solid, fill="white") #X
    

    Speed thread:

    # Start the program by opening the cellular connection and creating a bucket for our data
    def update_speed():
        if not openPPPD():
            return  # again, return early 
        GPS_DATA = {}
        while True:
            # Close the cellular connection
            if closePPPD():  # 
                print ("closing connection")
                sleep(1)
            # The range is how many data points we'll collect before streaming
            for i in range(READINGS_PER_UPLOAD):
                # Make sure there's a GPS fix
                # two chained IFs - just use `and`
                if not (checkForFix() and getCoord()):
                    continue
                # Get lat and long
                latitude, longitude, time, speed = getCoord()
                coord = str(latitude) + "," + str(longitude)
                print ("Coordinates:", coord)
                print ("Time:", time)
                print ("Step", i+1, "out of",READINGS_PER_UPLOAD)
                    
                add_to_image.rectangle(speed_zone, fill="black", outline="black")
                add_to_image.text(speed_start, str(round(float(speed))), font=text_large, fill="white")
                pending_redraw = True
                        
                GPS_DATA[i] = {'lat': latitude, 'long' : longitude, 'time' : time, 'speed' : speed}
                sleep(SECONDS_BTW_READS)
    
            # Instead of checking for last iteration, just do it AFTER the loop 
            # Turn the cellular connection on every READINGS_PER_UPLOAD reads
            print ("opening connection")
            add_to_image.rectangle(icon_zone, fill="black", outline = "black")
            add_to_image.text(icon_start, "\uf7c0", font=FA_solid, fill="white") #sat dish
            pending_redraw = True
    
            if not openPPPD():
                continue  # return/continue early
    
            print ("streaming")                    
            add_to_image.rectangle(icon_zone, fill="black", outline = "black")
            add_to_image.text(icon_start, "\uf382", font=FA_solid, fill="white") #upload
            pending_redraw = True
                        
            url_values = urllib.parse.urlencode(GPS_DATA)
            #print(url_values)
    
            full_url = TARGET_URL + '?' + url_values
            with urllib.request.urlopen(full_url) as response:
                print(response)
                           
            print ("streaming complete")
            GPS_DATA = {}  
            add_to_image.rectangle(icon_zone, fill="black", outline = "black")
            add_to_image.text(icon_start, "\uf00c", font=FA_solid, fill="white") #check
            pending_redraw = True
    

    Main optimizations:

    • save some display redraws
    • skip unnecessary device open/close in update_temp
    • avoid unnecessary reopening of serial in checkForFix

    Changes in update_speed are mostly cosmetic

    As a general rule, it is better to post such things to https://codereview.stackexchange.com/ instead of StackOverflow