Raspberry Pi Pico Home Assistant Motion & Temperature Sensor

, updated 24 March 2024 🔖 iot ⏲️ 6 minutes to read

As an learning opportunity I wanted to see if I could create a temperature and motion sensor, running my own code on a hard-wired Raspberry Pi Pico, sending data via WiFi to Home Assistant.

The Raspberry Pi Pico W is a cheap board with two CPU cores, WiFi, 2MB of flash storage, and 264KB of RAM. It is capable of running Python code via MicroPython, which is what I wanted to use (rather than something like C).

Selecting the Components

I wanted a motion sensor, and a temperature sensor. I ended up ordering this PIR Motion Sensor Module, and this BME280 Breakout.

I picked up two Picos, two motion sensors, and one of the temperature sensors - I wanted to set up one Pico dedicated for motion sensing, and one with both types of sensor.

Figuring out the GPIO

The motion sensor needs three connections: 5v, ground, and an output. I connected it to pins 38 (VSYS), 39 (Ground) and 29 (GP22). Reading GP22 determines whether motion was detected or not.

The BME280 needs four connections: 3v, ground, SDA, and SCL. I connected it to pins 1 (SDA), 2 (SCL), 3 (Ground) and 36 (3v3 Out).

I hadn't done anything with the Raspberry Pi GPIO before aside from plugging HATs in, and I found picow.pinout.xyz very useful for understanding what each pin is for.

Planning the Logic

I decided I wanted a few features from my sensor:

  • Resiliency - If something interrupts power, WiFi, Home Assistant etc, the sensor must recover on its own (it can't soft hang). I want to write the code and forget about the sensors.
  • Data Efficiency - Only update Home Assistant when something significant changes.
  • Responsiveness - The sensor must update live, when a significant change happens. Motion detection should update immediately upon detection, but have a reasonable configurable timeout to stop detection.

Anti-Goals

The code didn't need to be efficient enough to allow powering the Pico from a battery. In fact I explicitly wanted the sensors to have a permanent 5V connection via a wall adapter.

Detecting Motion

The motion sensor doesn't require any dependencies, it's a direct read from the GPIO pin it's attached to (in my case 22).

import machine

pir = machine.Pin(22, machine.Pin.IN)
motion_detected = pir.value() == 1
# True if motion detected, False otherwise

There are two potentiometers on the PIR breakout to adjust the sensitivity, but I didn't need to adjust them from the factory state.

Measuring Temperature

The BME280 chip uses an IC2 interface, which is a serial communication bus. In order to read this, I used the mpy_bme280_esp8266 library.

import machine
import bme280

i2c = machine.I2C(0, scl=1, sda=0)
bme = bme280.BME280(i2c=i2c)
print(bme.values)
# (23.45C, 1001.24hPa, 45.67%)

I added a really lazy method to this library to return float values by parsing the output string. This works but wastes cycles - see the README for details around how to read the data properly.

Connecting to WiFi

I started with the sensors, but we forgot a fundamental part - connecting to the network to allow the sensor to update Home Assistant. WiFi management is built into MicroPython, and looks like this:

import network

wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.config(pm = 0xa11140, hostname = "sensorpico")
# Turn off WiFi power management with pm = 0xa11140

wlan.connect("my SSID", "my password")
while not wlan.isconnected():
  time.sleep(1)

The method wlan.connect returns immediately, I think it probably just hands off to the SoC to do the work, meaning you need to poll in a loop until the driver reports it's connected. This code works, but has some problems. I'll return to this later when I talk about a Watchdog timer.

Sending Updates to Home Assistant

Now we've got a WiFi connection up, it's time to figure out how to pipe data into Home Assistant. My first instinct was to use MQTT, since it is designed exactly for this purpose.

However, I tried a few libraries and didn't get along with them. They're all stateful, maintaining a persistent connection, and actually I thought it would be simpler to use a fire and forget HTTP request instead. I may revisit this decision later, but for now that's where I landed.

import urequests
import ujson

data = {
  "state": 23.43,
  "attributes": {
    "device_class": "temperature",
    "friendly_name": "Test Temperature Sensor",
    "unit_of_measurement": "°C",
    "state_class": "measurement"
  }
}

headers = {
  "Authorization": "Bearer <long lived token for admin user from Home Assistant>",
  "Content-Type": "application/json; charset=utf-8"
}

json = ujson.dumps(data).encode('utf-8')
url = "http://assistant.home/api/states/sensor.test_temperature_sensor"

response = urequests.post(url, data=json, headers=headers)

It is possible to avoid encoding the JSON yourself and pass it as a json parameter to urequests.post: however I found I couldn't encode the degree symbol correctly if I didn't explicitly encode the payload as UTF-8.

Watchdog Thread

I found that sometimes, when I booted up the Pico, the following function call would hang:

wlan.connect("my SSID", "my password")
while not wlan.isconnected(): # <===========
  time.sleep(1)

I don't know why - I did some Googling, found some people confirming the same a while ago, but it sounded like it was fixed. I decided I didn't really care though - there's a pattern for dealing with things like this - the Watchdog timer.

In my case, I wanted to start a thread on the other CPU core which checked on the main execution core. If there was no sign of life from the main execution, then reboot the Pico.

It turns out, there is a Watchdog timer implementation in MicroPython and it is available for the Pico: class WDT – watchdog timer. I actually found this after I had implemented this myself - however I found that the MicroPython implementation allows a maximum timeout of 8 seconds, whereas I wanted 30 seconds.

I decided to implement my own - which works fine, but it's obviously more code than if you used the stock implementation.

import _thread
import time
import machine

last_tick_ms = time.ticks_ms()
def watchdog_thread():
  global last_tick_ms
    
  while True:
    time_since_last_tick_ms = time.ticks_diff(time.ticks_ms(), last_tick_ms)
    if time_since_last_tick_ms > 30_000:
      machine.reset()
    time.sleep(1)

_thread.start_new_thread(watchdog_thread, ())

def main():
  while True:
    last_tick_ms = time.ticks_ms()
    update()

The 30_000 value is the timeout of 30 seconds (in milliseconds). This is more than enough for slow WiFi connection attempts, or even slow responses from Home Assistant without requring a reboot.

Putting it all Together

There are a few things I didn't cover here, such as the motion timeout, only sending updates when things change, error handling, configuration and flashing the on-board LED.

Motion Timeout

For the motion update, I use a similar mechanism to the Watchdog timer to figure out if motion was detected less than 15 minutes ago - compare the time now to the time motion was last detected.

Efficient Temperature Updates

To only send updates when the temperature changes, I store the value last sent to Home Assistant, and only send an update if the current value is significantly different to that previously sent value.

Error Handling

Error handling is partly covered by the Watchdog timer, but for "local" error handling, I use try/except and also a WiFi connection check inside the main loop of the program:

def main_loop():
  if not wlan.isconnected():
    connect_to_wifi()

  update_motion_sensor()
  update_bme280_sensor()

while True:
  try:
    main_loop()
    time.sleep(0.1)
  except Exception as e:
    time.sleep(2)

Configuration

I want to use this same code on two Pico boards for now, and maybe more in the future. Rather than using constants and changing main.py every time, I opted to spin up a separate config.py file to hold the dynamic configuration:

# config.py
wifi = dict(
    ssid = "ssid",
    key = "key",
    host = "hostname"
)

# main.py
import config

wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.config(pm = 0xa11140, hostname = config.wifi['host'])
wlan.connect(config.wifi['ssid'], config.wifi['key'])

Flashing the LED

The Pico has an LED, and it's easy to flash it to communicate different messages from the code (for debugging what's happening).

import machine
import time

led = machine.Pin("LED", machine.Pin.OUT)

def flash_led(flashes):
  duration = 0.2
  time.sleep(duration)
  for flash in range(0, flashes):
    led.on()
    time.sleep(duration)
    led.off()
    time.sleep(duration)

flash_led(4) # Flash the LED 4 times

Final Code

For the final code, see my GitHub repository. All 3 files for the sensor are deployed to the devices - I use Thonny for development as it's really simple to use and debug code on the device via USB.

🏷️ sensor motion connect pico code assistant wifi temperature watchdog timer timeout raspberry pi data micropython

⬅️ Previous post: Racing Tasks in C♯

➡️ Next post: 10 Year Anniversary of Estranged

🎲 Random post: Three Approaches to Readable Materials in First Person Games

Comments

Please click here to load comments.