Raspberry Pi Pico Home Assistant Motion & Temperature Sensor
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 recommend the following to ensure the Pico never gets stuck in a frozen state:
- Use the built-in Watchdog Timer, backed by hardware
- Ensure that the main program loop never blocks for more than a few seconds at a time (my original implementation did that sometimes when connecting to WiFi)
My original conclusions, using a custom Watchdog Thread are included below - but I no longer believe this to be a good approach because of the instability of the threading library in Micropython. See Raspberry Pi Pico W WiFi Resiliency for more context.
Original Custom Watchdog Thread Writeup
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 pico connect code wifi watchdog assistant temperature timer raspberry pi micropython timeout data
Please click here to load comments.