Threading in MicroPython on Pico

Hi all.

As a learning exercise I’m building a weather station using a Pico W + BME280 + SSD1306 that is readable from an Anvil page and locally on the OLED with the press of a button.

I’ve used threading to break out the push button functionality but doing this seems to lock out or stop the Anvil code (or at least its connection to Anvil). I suspect my dramas are due to (A) poor coding and (B) Anvil’s Pico build of MPython. I’ve tried locks and sleeps in the PB function which seem to work sometimes but not others (timing?).

So as a last resort I have the PB function reset the Pico. Crude I know but it does work. The Anvil page has to be refreshed but that’s ok. I don’t see the local PB being used much as it’s really just there for when I’m doing maintenance on the Stevenson Screen and Pico within.

Any constructive comments on the code would be appreciated.

from PiicoDev_BME280 import PiicoDev_BME280
from PiicoDev_Unified import sleep_ms
from machine import Pin
import ntptime
import time
import machine
import socket
import math
from PiicoDev_SSD1306 import * # The standard code for the OLED module.
import _thread # Required for the button function.

import anvil.pico 
import uasyncio as a
UPLINK_KEY = "server_M2HQFAERLMD4F5HOS2IVUYRH-CVVXOGGKNNRG3HNY"

from do_connect import *
do_connect() # Connect to wifi and print the connection details to the shell. 
print("------------------------------------------------------------") # Make the shell more readable.

from do_connect import signal_strength

display = create_PiicoDev_SSD1306() # Defines the OLED display.
Picoled = Pin("LED", Pin.OUT) # Define the Pico LED.

global button_pressed # Defines the variable for use in the thread.
button_pressed = machine.Pin(14, machine.Pin.IN) # Define the PB for the button _thread.
 

ntptime.settime() # Sync the RTC time using NTP server
rtc = machine.RTC() # Initialise

def heat_index():
    # print("heat_index called") for testing.
    R = humRH
    T = (tempC * 1.8) + 32 # °F.
    c1 = -42.379
    c2 = 2.04901523
    c3 = 10.14333127
    c4 = -0.22475541
    c5 = -6.83783 * (10**-3) # where ** is the exponential operator.  This being 10 to the 3rd.
    c6 = -5.481717 * (10**-2)
    c7 = 1.22874 * (10**-3)
    c8 = 8.5282 * (10**-4)
    c9 = -1.99 * (10**-6)
    HIF = c1 + (c2 * T) + (c3 * R) + (c4 * T * R) + (c5 * T**2) + (c6 * R**2) + (c7 * T**2 * R) + (c8 * T * R**2) + (c9 * T**2 * R**2)
    HIC = (HIF - 32) / 1.8 # Convert to °C.
    return HIC

def bme280_sensor():
    # print("bme280_sensor called") for testing.
    sensor = PiicoDev_BME280(iir=3) # initialise "sensor" as the atmos driver code with noise filtering (iir=3)
    global tempC, presPa, humRH, preshPa # Make the variables available outside this function.
    tempC, presPa, humRH = sensor.values() # Read all data from the sensor.
    preshPa = presPa/100 # Convert Pa to hPa.

def battery_voltage():
    # print("battery_voltage called") for testing.
    voltage = machine.ADC(28) # Assigns the value of GP28 (pin 34) to the variable "Voltage".  Jumpered to 3.3v pin 36.
    voltbits=voltage.read_u16() # Read the value in bits.
    battvolts = voltbits / 65535 * 3.3 * 2 # Convert the AO to volts.  Note "x 2" due to the voltage divider.
    return battvolts

def dewpoint(): # Calculate dewpoint
    # print("dewpoint called") for testing.
    bme280_sensor()
    dp = tempC-((100-humRH)/5)
    return dp

def pressure_msg(): # Forecast based on hPa reading.
    #print("pressure_msg called") for testing.
    bme280_sensor()
    if preshPa > 1012:
        pres_message = "Stable conditions, little rain, clear and calm weather"
    if preshPa < 1013:
        pres_message = "Unsettled conditions, possibe rain or storms"
    return pres_message

def current_time(): # Get the current time from the Pico. rtc.datetime((yyyy, mm, ddd, hh, mm, s, s, s))
    #print("current_time called") for testing.
    current_time = rtc.datetime()
    hours = (current_time[4] + 10) % 24 # Adds 10 to the hours for UTC+10.  "% 24" sets the 24h format.  Setting it as 12 means the +10 fails.
    minutes = current_time[5]
    seconds = current_time[6]
    ctime = "{:02d}:{:02d}:{:02d}".format(hours, minutes, seconds) # Formats each to 2 digits.
    return ctime

def oled (var_to_oled): # Display whatever variable called from the function oled_display.
    #print("oled called") for testing.
    display.fill(0) # Clears the display.
    display.text(str(var_to_oled), 0,0, 1) # Display the variable at pixals X & Y on line 1.
    display.show()
    sleep_ms(1000)
    display.fill(0) # Clear the display
    display.show() # Must "show" the cleared display.

def oled_display(): # Function called at startup from below.
    # Each line "oled(xxxx)" below calla the oled function to display the variable.
    bme280_sensor() # Get the values from the atmos sensor for the calcs and ole calls below.
    oled(current_time())
    temp = str('%.1f' % tempC + " C")
    oled(temp)
    HIc = str("Feels like "'%.1f' % heat_index() + " C")
    oled(HIc)
    rh = str('%.0f' % humRH + " % RH")
    oled(rh) 
    pres = str('%.0f' % preshPa + " hPa")
    oled(pres)
    dp = str('%.1f' % dewpoint() + " C Dewpoint")
    bvolts = str('%.1f' % battery_voltage() + " volts")
    oled(bvolts)
    sigs = str('%.0f' % signal_strength() + " dBm")
    oled(sigs)
    
def button_reader():  # Runs as a separate thread to allow the local PB to operate the OLED at any time.
    print("button_reader called") # for testing.
    button_pressed = machine.Pin(14, machine.Pin.IN) # Define the PB for the button _thread.
    while True:
        if button_pressed.value() == 1: # If the button is pressed, do...."
            print("in button_reader, button_presses is True")
            oled_display()
            print("PB Pressed") # Print for testing.
            sleep_ms(1000)
            print("button_reader, machine reset") # for testing.
            machine.reset() # Resets restarts the Pico to re-establish connection to Anvil.  Anvil page must be refreshed.
                   
@anvil.pico.callable(is_async=True) # This decorator makes the function callable by Anvil.
async def pico_fn(n):
    #print("pico_fn called") for testing.
    # Output will go to the Pico W serial port
    print(f"Called local function with argument: {n}")
    # Blink the LED and then double the argument and return it.
    for i in range(10):
        Picoled.toggle()
        await a.sleep_ms(50)
    return n * 2

@anvil.pico.callable(is_async=True) # This decorator makes the function callable by Anvil.
async def publish_data():
    #print("publish_data called") for testing.
    # Blink the LED to indicate an update.
    Picoled.toggle()
    sleep_ms(100)
    Picoled.toggle()
    sleep_ms(100)
    Picoled.toggle()
    sleep_ms(100)
    Picoled.value(0)
    sleep_ms(500)
    # Get the sensor data, format decimal places and add units, then write to variables.
    bme280_sensor()
    ctime = current_time()
    temp = str('%.1f' % tempC + " °C")
    rh = str('%.0f' % humRH + " % RH")
    pres = str('%.0f' % preshPa + " hPa")
    dp = str('%.1f' % dewpoint() + " °C Dewpoint")
    pmsg = pressure_msg()
    bvolts = str('%.1f' % battery_voltage() + " volts")
    sigs = str('%.0f' % signal_strength() + " dBm")
    HIc = str('%.1f' % heat_index() + " °C")
    return (ctime, temp, rh, pres, dp, pmsg, bvolts, sigs, HIc)

_thread.start_new_thread(button_reader, ()) # Start a separate thread for the PB to trigger the OLED.

Picoled.value(0) # Turn off the Pico LED.
oled_display() # Cycle through the variables on the OLED at startup.

print("anvil.pico.connect called") # for testing.
anvil.pico.connect(UPLINK_KEY) # Connect to Anvil via the Anvil connect function.
2 Likes

Hey @Mark285907,

For the most part your code looks pretty good! Though, I think I know why your button_reader() thread is locking the system.

Multi-threading in most languages/processors typically requires each thread to yield control once it has finished performing the necessary tasks. In the _thread low-level API, this is done in several ways:

  • A function running as a thread will automatically yield control once the function returns
  • A thread can be manually exited by calling _thread.exit() within the thread. Though this will raise a SystemExit exception. It is basically the equivalent of shutting down only that thread, so there are other, more elegant, methods.
  • Thread control locks. These are known as “locks” in _thread, but you will also see them called Mutexes and Semaphores

If you harken back to your button_release() function, you’ll notice that none of the above conditions are met, because the while True loop effectively locks your program to stay within a loop until forcefully exited, such as when you restart the Pico.

(P.S. Using a while True inside a function is not best practice for this exact reason. My typical method would be to set a flag that causes the loop to run, if the loop detects a button press it executes the code once, and then sets the flag to zero to exit the loop.)

The following video is a good resource on MicroPython Multithreading:

The video should be set to start at the section titled “Using Locks or Semaphores”. They are realistically the most ideal solution if you plan to expand the multithreading at all.

Hope this helps! Let us know if you have any other questions.

4 Likes

Zach, thanks for your detailed reply. Much appreciated.

I’ll check out that vid!

Cheers,

Mark

3 Likes

Looked into locks and without implementing it, I think it’ll still be a work around, albeit maybe more elegant than my machine.reset!

For Anvil to work it needs the connection to be current. This connection is maintained via a anvil.pico library function and not main.py itself which complicates things somewhat. There is also no way for the Pico to know when an Anvil call will come in.

This has been a good learning exercise but I think in the long run I’d want to keep things local using the pico as a web server (which I have working with a basic text page) while I put some development time into a home automation platform.

Cheers.

1 Like

I’ve had a go at locks but get this error.

Traceback (most recent call last):
File “”, line 191, in button_reader
NameError: name ‘lock’ isn’t defined

I think I have defined lock correctly but obviously not? Line 191 is the lock.acquire() line in button_reader.


from PiicoDev_BME280 import PiicoDev_BME280
from PiicoDev_Unified import sleep_ms
from machine import Pin
import ntptime
import time
import machine
import socket
import math
from PiicoDev_SSD1306 import * # The standard code for the OLED module.
import _thread # Required for the button function.

# Connect to the internet via do_connect.py, which with secrets.py must be on the pico.
from do_connect import *
do_connect() # Connect to wifi and print the connection details to the shell. 
print("------------------------------------------------------------")

from do_connect import signal_strength

display = create_PiicoDev_SSD1306() # Defines the OLED display.
Picoled = Pin("LED", Pin.OUT) # Define the Pico LED.
Picoled.value(1) # Turns on the Pico LEDfor 2s to show life.
sleep_ms(2000)

global button_pressed # Defines the variable for use in the thread.
button_pressed = machine.Pin(14, machine.Pin.IN) # Define the PB for the button _thread.

# create a global lock
global lock
lock = _thread.allocate_lock()
 
# Real time clock
ntptime.settime() # Sync the RTC time using NTP server
rtc = machine.RTC() # Initialise
    
def heat_index():
    R = humRH
    T = (tempC * 1.8) + 32 # °F.
    c1 = -42.379
    c2 = 2.04901523
    c3 = 10.14333127
    c4 = -0.22475541
    c5 = -6.83783 * (10**-3) # where ** is the exponential operator.  This being 10 to the 3rd.
    c6 = -5.481717 * (10**-2)
    c7 = 1.22874 * (10**-3)
    c8 = 8.5282 * (10**-4)
    c9 = -1.99 * (10**-6)
    HIF = c1 + (c2 * T) + (c3 * R) + (c4 * T * R) + (c5 * T**2) + (c6 * R**2) + (c7 * T**2 * R) + (c8 * T * R**2) + (c9 * T**2 * R**2)
    HIC = (HIF - 32) / 1.8 # Convert to °C.
    return HIC

def bme280_sensor():
    sensor = PiicoDev_BME280(iir=3) # initialise "sensor" as the atmos driver code with noise filtering (iir=3)
    global tempC, presPa, humRH, preshPa # Make the variables available outside this function.
    tempC, presPa, humRH = sensor.values() # Read all data from the sensor.
    preshPa = presPa/100 # Convert Pa to hPa.

def battery_voltage():
    voltage = machine.ADC(28) # Assigns the value of GP28 (pin 34) to the variable "Voltage".  Jumpered to 3.3v pin 36.
    voltbits=voltage.read_u16() # Read the value in bits.
    battvolts = voltbits / 65535 * 3.3 * 2 # Convert the AO to volts.  Note x 2 due to the divider.
    print(battvolts)
    return battvolts

def dewpoint(): # Calculate dewpoint
    bme280_sensor()
    dp = tempC-((100-humRH)/5)
    return dp

def pressure_msg(): # Forecast based on hPa reading.
    bme280_sensor()
    if preshPa > 1012:
        pres_message = "stable conditions, little rain, clear and calm weather"
    if preshPa < 1013:
        pres_message = "unsettled conditions, possibe rain or storms"
    return pres_message

def current_time(): # Get the current time from the Pico. rtc.datetime((yyyy, mm, ddd, hh, mm, s, s, s))
    current_time = rtc.datetime()
    hours = (current_time[4] + 10) % 24 # Adds 10 to the hours for UTC+10.  "% 24" sets the 24h format.  Setting it as 12 means the +10 fails.
    minutes = current_time[5]
    seconds = current_time[6]
    ctime = "{:02d}:{:02d}:{:02d}".format(hours, minutes, seconds) # Formats each to 2 digits.
    return ctime

def webpage(tempC, preshPa, humRH, dp, Ctime, Pmsg, bvolts, sigs, HIc):
    html = f"""
<head>
<title>Mark's Weather Station</title>
</head>
<body>
<h1>Mark's Weather Station</h1>
<p>Current time: {Ctime} </p>
<br>
<p>Temperature: {tempC:.1f} C</p>
<p>Feels like: {HIc:.1f} C</p>
<p>Humidity: {humRH:.1f} %</p>
<p>Dewpoint: {dp:.1f} C</p>
<br>
<p>Pressure: {preshPa:.0f} hPa</p>
<p>Forecast: {Pmsg} </p>
<br>
<p>Battery Voltage: {bvolts:.1f} v</p>
<p>Signal Strength: {sigs:.0f} dBm</p>
</body>
</html>""".format(tempC, preshPa, humRH, dp, Ctime, Pmsg, bvolts, sigs, HIc)
    return html

def serve(connection): # Whenever the page is refreshed, this script handles the request and regenerates the webpage content.
    global lock
    while True:
        # try to acquire lock - wait if in use
        lock.acquire()
        
        client = connection.accept()[0] # continuously waits for incoming client connection.
        request = client.recv(1024) # Once a client is connected, it receives the request from the client
        request = str(request) # Converts the request to a string and attempts to split it to extract the requested path.
        try:
            request = request.split()[1] # Split the request string into a list of substrings, using whitespace as the delimiter, and then access the second element (at index 1) of this list to extract the URL path from an HTTP request.
        except IndexError:
            pass # If there's an error in the request string or in splitting it, ignore the error.
        print(request) # Print to shell for dugging.
        
        # Define the values.
        bme280_sensor()
        Ctime = current_time()
        temp = tempC
        rh = humRH
        pres = preshPa
        dp = dewpoint()
        Pmsg = pressure_msg()
        bvolts = battery_voltage()
        sigs = signal_strength()
        HIc = heat_index()
        # Send to the page.
        html=webpage(tempC, preshPa, humRH, dp, Ctime, Pmsg, bvolts, sigs, HIc) # Gets the data from the webpage function.
        client.send(html) # sends the generated HTML back to the client.
        client.close() # closes the client connection.
        print("serve function") # Option for understading the code.  This code section runs each refresh of the page.
        
        # Cycle the Pico LED each refresh.  In v8 chnaged to blink on once with a refresh.
        Picoled.value(1) 
        sleep_ms(500)
        Picoled.value(0)
        
        # release lock
        lock.release()
        
def open_socket(ip):
    # Open a socket
    address = (ip, 80)
    connection = socket.socket()
    connection.bind(address)
    connection.listen(1)
    print(connection)
    return(connection)

def oled (var_to_oled): # Display whatever variable called from the function oled_display.
    #print("oled called") for testing.
    display.fill(0) # Clears the display.
    display.text(str(var_to_oled), 0,0, 1) # Display the variable at pixals X & Y on line 1.
    display.show()
    sleep_ms(1000)
    display.fill(0) # Clear the display
    display.show() # Must "show" the cleared display.

def oled_display(): # Function called at startup from below.
    # Each line "oled(xxxx)" below calla the oled function to display the variable.
    bme280_sensor() # Get the values from the atmos sensor for the calcs and ole calls below.
    oled(current_time())
    temp = str('%.1f' % tempC + " C")
    oled(temp)
    HIc = str("Feels like "'%.1f' % heat_index() + " C")
    oled(HIc)
    rh = str('%.0f' % humRH + " % RH")
    oled(rh) 
    pres = str('%.0f' % preshPa + " hPa")
    oled(pres)
    dp = str('%.1f' % dewpoint() + " C Dewpoint")
    bvolts = str('%.1f' % battery_voltage() + " volts")
    oled(bvolts)
    sigs = str('%.0f' % signal_strength() + " dBm")
    oled(sigs)
    
def button_reader():  # Runs as a separate thread to allow the local PB to operate the OLED at any time.
    global lock
    print("button_reader called") # for testing.
    button_pressed = machine.Pin(14, machine.Pin.IN) # Define the PB for the button _thread.
    while True:
        # try to acquire lock - wait if in use
        lock.acquire()
        if button_pressed.value() == 1: # If the button is pressed, do...."
            print("in button_reader, button_presses is True")
            oled_display()
            print("PB Pressed") # Print for testing.
            sleep_ms(1000)
            #print("button_reader, machine reset") # for testing.
            #machine.reset() # Resets restarts the Pico to re-establish connection to Anvil.  Anvil page must be refreshed.
        # release lock
        lock.release()
            
Picoled.value(0) # Turn off the Pico LED to save power.
oled_display() # Cycle through the variables on the OLED at startup.

_thread.start_new_thread(button_reader, ()) # Start a separate thread for the local PB to trigger the OLED.
# Run core0_thread in the main thread
#core0_thread()

try:
    ip=do_connect()
    if ip is not None:
        connection=open_socket(ip)
        # print("if....not") # Option for understading the code.  This code section runs once only.
        print("Online and waiting")
        # Cycle the Pico LED long then short to indicate online and waiting.
        Picoled.value(1) 
        sleep_ms(1000)
        Picoled.value(0)
        sleep_ms(500)
        Picoled.value(1) 
        sleep_ms(100)
        Picoled.value(0) 
        serve(connection) # Call the serve function.       
except KeyboardInterrupt:
    machine.reset()

1 Like

Hi Mark,

It would be worth reading up on interrupts and timers.

I like to think of multitasking (on microcontrollers especially) like this:

  • Does this feature rely on hardware? If yes, use an interupt
  • Does this feature need to happen after a delay, or periodically? Consider a timer, or threading
  • Does this microcontroller have a second core? Could I load a task that always need to be running onto that? i.e. a connection like MQTT or a webserver

But generally threading is harder than timers and interrupts to get working well.
Jaryd covers them here in the PIco workshop: https://core-electronics.com.au/courses/raspberry-pi-pico-workshop/#6.1-micropython-you-need-to-know

Let me know if you have any more questions!
Liam

1 Like

While I am not a python person, I am wondering if the top lock creation needs the “global” keyword.
i.e.

# create a global lock
global lock
lock = _thread.allocate_lock()

could be

# create a global lock
lock = _thread.allocate_lock()

My understanding is that the global keyword is used to access a “global variable” inside a function AND you want to change it. As such the top level global, to me implies that the actual variable “lock” is stored somewhere else; thus in your code not actually defined.
e.g.

To change the value of a global variable inside a function, refer to the variable by using the global keyword:

x = "awesome"

def myfunc():
  global x
  x = "fantastic"

myfunc()

print("Python is " + x)

Note no global where its defined, and only used in a function where you want to change its value.

On a different note: when using locks, try to ensure things in a locked block are only locked for the shortest amount of time.

e.g.
temp_var = get something from slow remote server.
aquire lock
real_val = temp_var
release lock

This will only lock while the real/shared variable is being assigned a new value and not for the extended time while the code acquires the values from something slower.

Just some thoughts.

1 Like

Thanks Michael.

Agree that the line “global lock” is not required. Defining lock alone means it should be global by default. Deleted but get the same error.

1 Like

While I am not a python person, I am wondering if the problem is that the variable lock is not defined with self.lock. Lock is a class within _thread, and if you want to use its objects and methods it must be defined as a class. So the message is really saying “'lock isn’t defined as an object”.

2 Likes

In the end I gave up on threads and locks. I could get it to work but not consistently. Implemented an IRQ on the button and after sorting out the bounce, all works as required.

Interestingly I found a consistent 7 second bounce on the input. Not the button I’m sure, perhaps the breadboard connections.