This project has only been tested with a QT Py ESP32-S3. Other boards may not work as expected.

The Ikea Vindriktning is a small and affordable air quality monitor that measures PM2.5 particulates for an AQI reading. The light bar on the front changes from green, yellow or red depending on the reading. It's a fun little device, but it would be great to make it internet of things (IoT) capable and this guide will show you how to do just that.

You'll add a QT Py ESP32-S3 running CircuitPython to read the data from the PM1006 sensor inside the Vindriktning and log the data to Adafruit IO. You'll also set up an Action alert to know when the air quality becomes unhealthy.

This Ikea hack is non-destructive to the electronics inside the Vindriktning. You'll solder to three already exposed test points. The LEDs on the PCB will continue to light up depending on the value from the PM1006 sensor.

The QT Py is able to be powered by the USB C port in the Vindriktning through its battery pads on the bottom of the board. The PM1006 sends data over UART, which is read through the RX pin on the QT Py.

Parts

Angled shot of small purple microcontroller.
The ESP32-S3 has arrived in QT Py format - and what a great way to get started with this powerful new chip from Espressif! With dual 240 MHz cores, WiFi and BLE support, and native...
$12.50
In Stock
Silicone Cover Stranded-Core Wire - 30AWG in Various Colors laid out beside each other.
Silicone-sheathing wire is super-flexible and soft, and its also strong! Able to handle up to 200°C and up to 600V, it will do when PVC covered wire wimps out. We like this wire...
Out of Stock
Angled shot of a pink/purple woven USB cable plugged into a laptop port and a small dev board.
This cable is not only super-fashionable, with a woven pink and purple Blinka-like pattern, it's also made for USB C for our modernized breakout boards, Feathers and more. 
$3.95
In Stock

Optional - Adafruit IO+

This project can utilize the free tier of Adafruit IO. However, if you would like to set up text alert actions as described later in the guide you will need an IO+ subscription.

Text image that reads "IO+"
The all-in-one Internet of Things service from Adafruit you know and love is now even better with IO+. The 'plus' stands for MORE STUFF! More feeds, dashboards,...
$99.00
In Stock

Connect the QT Py ESP32-S3 to three test points on the air quality monitor PCB.

  • PCB 5V test point to QT Py BAT + (red wire)
  • PCB GND test point to QT Py BAT GND (black wire)
  • PCB REST test point to QT Py RX (green wire)

Adafruit IO is integrated with your adafruit.com account so you don't need to create yet another online account! You need an Adafruit account to use Adafruit IO because we want to make sure the data you upload is available to only you (unless you decide to publish your data).

I have an Adafruit.com Account already

If you already have an Adafruit account, then you already have access to Adafruit IO. It doesn't matter how you signed up, your account will make all three available.

To access Adafruit IO, simply visit https://io.adafruit.com to start streaming, logging, and interacting with your data.

Create an Adafruit Account (for Adafruit IO)

An Adafruit account makes Adafruit content and services available to you in one place. Your account provides access to the Adafruit shop, the Adafruit Learning System, and Adafruit IO. This means only one account, one username, and one password are necessary to engage with the content and services that Adafruit offers.

If you do not have an Adafruit account, signing up for a new Adafruit account only takes a couple of steps.

Begin by visiting https://accounts.adafruit.com.

Click the Sign Up button under the "Need An Adafruit Account?" title, below the Sign In section.

This will take you to the Sign Up page.

Fill in the requested information, and click the Create Account button.

This takes you to your Adafruit Account home page. From here, you can access all the features of your account.

You can also access the Adafruit content and services right from this page. Along the top of the page, you'll see a series of links beginning with "Shop". To access any of these, simply click the link.

For example, to begin working with Adafruit IO, click the IO link to the right of the rest of the links. This is the same for the other links as well.

That's all there is to creating a new Adafruit account, and navigating to Adafruit IO.

CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.

CircuitPython Quickstart

Follow this step-by-step to quickly get CircuitPython running on your board.

There are two versions of this board: one with 8MB Flash/No PSRAM and one with 4MB Flash/2MB PSRAM. Each version has their own UF2 build for CircuitPython. There isn't an easy way to identify which version of the board you have by looking at the board silk. If you aren't sure which version you have, try either build to see which one works.

There are two versions of this board: one with 8MB Flash/No PSRAM and one with 4MB Flash/2MB PSRAM.

Click the link above to download the latest CircuitPython UF2 file.

Save it wherever is convenient for you.

Plug your board into your computer, using a known-good data-sync cable, directly, or via an adapter if needed.

Click the reset button once (highlighted in red above), and then click it again when you see the RGB status LED(s) (highlighted in green above) turn purple (approximately half a second later). Sometimes it helps to think of it as a "slow double-click" of the reset button.

If you do not see the LED turning purple, you will need to reinstall the UF2 bootloader. See the Factory Reset page in this guide for details.

On some very old versions of the UF2 bootloader, the status LED turns red instead of purple.

For this board, tap reset and wait for the LED to turn purple, and as soon as it turns purple, tap reset again. The second tap needs to happen while the LED is still purple.

Once successful, you will see the RGB status LED(s) turn green (highlighted in green above). If you see red, try another port, or if you're using an adapter or hub, try without the hub, or different adapter or hub.

If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!

A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.

If after several tries, and verifying your USB cable is data-ready, you still cannot get to the bootloader, it is possible that the bootloader is missing or damaged. Check out the Factory Reset page for details on resolving this issue.

You will see a new disk drive appear called QTPYS3BOOT.

Drag the adafruit_circuitpython_etc.uf2 file to QTPYS3BOOT.

The BOOT drive will disappear and a new disk drive called CIRCUITPY will appear.

That's it!

CircuitPython works with WiFi-capable boards to enable you to make projects that have network connectivity. This means working with various passwords and API keys. As of CircuitPython 8, there is support for a settings.toml file. This is a file that is stored on your CIRCUITPY drive, that contains all of your secret network information, such as your SSID, SSID password and any API keys for IoT services. It is designed to separate your sensitive information from your code.py file so you are able to share your code without sharing your credentials.

CircuitPython previously used a secrets.py file for this purpose. The settings.toml file is quite similar.

Your settings.toml file should be stored in the main directory of your CIRCUITPY drive. It should not be in a folder.

CircuitPython settings.toml File

This section will provide a couple of examples of what your settings.toml file should look like, specifically for CircuitPython WiFi projects in general.

The most minimal settings.toml file must contain your WiFi SSID and password, as that is the minimum required to connect to WiFi. Copy this example, paste it into your settings.toml, and update:

  • your_wifi_ssid
  • your_wifi_password
CIRCUITPY_WIFI_SSID = "your_wifi_ssid"
CIRCUITPY_WIFI_PASSWORD = "your_wifi_password"

Many CircuitPython network-connected projects on the Adafruit Learn System involve using Adafruit IO. For these projects, you must also include your Adafruit IO username and key. Copy the following example, paste it into your settings.toml file, and update:

  • your_wifi_ssid
  • your_wifi_password
  • your_aio_username
  • your_aio_key
CIRCUITPY_WIFI_SSID = "your_wifi_ssid"
CIRCUITPY_WIFI_PASSWORD = "your_wifi_password"
ADAFRUIT_AIO_USERNAME = "your_aio_username"
ADAFRUIT_AIO_KEY = "your_aio_key"

Some projects use different variable names for the entries in the settings.toml file. For example, a project might use ADAFRUIT_AIO_ID in the place of ADAFRUIT_AIO_USERNAME. If you run into connectivity issues, one of the first things to check is that the names in the settings.toml file match the names in the code.

Not every project uses the same variable name for each entry in the settings.toml file! Always verify it matches the code.

settings.toml File Tips

Here is an example settings.toml file.

# Comments are supported
CIRCUITPY_WIFI_SSID = "guest wifi"
CIRCUITPY_WIFI_PASSWORD = "guessable"
CIRCUITPY_WEB_API_PORT = 80
CIRCUITPY_WEB_API_PASSWORD = "passw0rd"
test_variable = "this is a test"
thumbs_up = "\U0001f44d"

In a settings.toml file, it's important to keep these factors in mind:

  • Strings are wrapped in double quotes; ex: "your-string-here"
  • Integers are not quoted and may be written in decimal with optional sign (+1, -1, 1000) or hexadecimal (0xabcd).
    • Floats, octal (0o567) and binary (0b11011) are not supported.
  • Use \u escapes for weird characters, \x and \ooo escapes are not available in .toml files
    • Example: \U0001f44d for 👍 (thumbs up emoji) and \u20ac for € (EUR sign)
  • Unicode emoji, and non-ASCII characters, stand for themselves as long as you're careful to save in "UTF-8 without BOM" format

 

 

When your settings.toml file is ready, you can save it in your text editor with the .toml extension.

Accessing Your settings.toml Information in code.py

In your code.py file, you'll need to import the os library to access the settings.toml file. Your settings are accessed with the os.getenv() function. You'll pass your settings entry to the function to import it into the code.py file.

import os

print(os.getenv("test_variable"))

In the upcoming CircuitPython WiFi examples, you'll see how the settings.toml file is used for connecting to your SSID and accessing your API keys.

Once you've finished setting up your QT Py ESP32-S3 with CircuitPython, you can access the code and necessary libraries by downloading the Project Bundle.

To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.

# SPDX-FileCopyrightText: 2023 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT

import os
import ssl
import time
import wifi
import socketpool
import board
import busio
import microcontroller
import adafruit_requests
import neopixel
from adafruit_ticks import ticks_ms, ticks_add, ticks_diff
from adafruit_io.adafruit_io import IO_HTTP

pixel_pin = board.NEOPIXEL
num_pixels = 1

pixels = neopixel.NeoPixel(pixel_pin, num_pixels, brightness=0.05, auto_write=False)
pixels.fill((255, 255, 0))
pixels.show()

try:
    wifi.radio.connect(os.getenv('CIRCUITPY_WIFI_SSID'), os.getenv('CIRCUITPY_WIFI_PASSWORD'))
except Exception as e: # pylint: disable=broad-except
    pixels.fill((100, 100, 100))
    pixels.show()
    print("Error:\n", str(e))
    print("Resetting microcontroller in 5 seconds")
    time.sleep(5)
    microcontroller.reset()

aio_username = os.getenv("ADAFRUIT_IO_USERNAME")
aio_key = os.getenv("ADAFRUIT_IO_KEY")

try:
    pool = socketpool.SocketPool(wifi.radio)
    requests = adafruit_requests.Session(pool, ssl.create_default_context())
    # Initialize an Adafruit IO HTTP API object
    io = IO_HTTP(aio_username, aio_key, requests)
    print("connected to io")
except Exception as e: # pylint: disable=broad-except
    pixels.fill((100, 100, 100))
    pixels.show()
    print("Error:\n", str(e))
    print("Resetting microcontroller in 5 seconds")
    time.sleep(5)
    microcontroller.reset()

try:
# get feed
    ikea_pm25 = io.get_feed("ikeapm25")
except Exception: #  pylint: disable=broad-except
# if no feed exists, create one
    ikea_pm25 = io.create_new_feed("ikeapm25")

uart = busio.UART(board.TX, board.RX, baudrate=9600)

measurements = [0, 0, 0, 0, 0]
measurement_idx = 0

def valid_header(d):
    headerValid = (d[0] == 0x16 and d[1] == 0x11 and d[2] == 0x0B)
    # debug
    # if not headerValid:
        # print("msg without header")
    return headerValid

start_read = False
clock = ticks_ms()
pixels.fill((0, 255, 0))
pixels.show()

io_time = 60000

while True:
    try:
        data = uart.read(32)  # read up to 32 bytes
        #print(data)  # this is a bytearray type
        time.sleep(0.01)
        if ticks_diff(ticks_ms(), clock) >= io_time:
            pixels.fill((0, 0, 255))
            pixels.show()
            io_data = measurements[0]
            if io_data != 0:
                io.send_data(ikea_pm25["key"], io_data)
                print(f"sent {io_data} to {ikea_pm25['key']} feed")
            time.sleep(1)
            clock = ticks_add(clock, io_time)
            pixels.fill((0, 0, 0))
            pixels.show()
        if data is not None:
            v = valid_header(data)
            if v is True:
                measurement_idx = 0
                start_read = True
            if start_read is True:
                pixels.fill((255, 0, 0))
                pixels.show()
                pm25 = (data[5] << 8) | data[6]
                measurements[measurement_idx] = pm25
                if measurement_idx == 4:
                    start_read = False
                measurement_idx = (measurement_idx + 1) % 5
                print(pm25)
                print(measurements)
        else:
            pixels.fill((0, 255, 0))
            pixels.show()
    except Exception as e: # pylint: disable=broad-except
        print("Error:\n", str(e))
        print("Resetting microcontroller in 5 seconds")
        time.sleep(5)
        microcontroller.reset()

Upload the Code and Libraries to the QT Py ESP32-S3

After downloading the Project Bundle, plug your QT Py ESP32-S3 into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the QT Py ESP32-S3's CIRCUITPY drive. 

  • lib folder
  • code.py

Your QT Py ESP32-S3 CIRCUITPY drive should look like this after copying the lib folder and the code.py file.

CIRCUITPY

Add Your settings.toml File

As of CircuitPython 8, there is support for Environment Variables. These Environmental Variables are stored in a settings.toml file. Similar to secrets.py, the settings.toml file separates your sensitive information from your main code.py file. Add your settings.toml file as described in the Create Your settings.toml File page earlier in this guide. You'll need to include your CIRCUITPY_WIFI_SSID, CIRCUITPY_WIFI_PASSWORD, ADAFRUIT_IO_USERNAME and ADAFRUIT_IO_KEY in the file.

CIRCUITPY_WIFI_SSID = "your-wifi-ssid-here"
CIRCUITPY_WIFI_PASSWORD = "your-wifi-password-here"

ADAFRUIT_IO_USERNAME = "your-Adafruit-IO-username-here"
ADAFRUIT_IO_KEY = "your-Adafruit-IO-key-here"

How the CircuitPython Code Works

First, the onboard NeoPixel is set up. Since you won't be able to see the serial monitor while the QT Py is inside the Vindriktning, the NeoPixel color will act as a status indicator.

pixel_pin = board.NEOPIXEL
num_pixels = 1

pixels = neopixel.NeoPixel(pixel_pin, num_pixels, brightness=0.05, auto_write=False)
pixels.fill((255, 255, 0))
pixels.show()

Connections

Then, the QT Py connects to WiFi and Adafruit IO. Both of these processes are wrapped in try/except statements to retry if any connection errors occur.

try:
    wifi.radio.connect(os.getenv('CIRCUITPY_WIFI_SSID'), os.getenv('CIRCUITPY_WIFI_PASSWORD'))
except Exception as e:
    pixels.fill((100, 100, 100))
    pixels.show()
    print("Error:\n", str(e))
    print("Resetting microcontroller in 5 seconds")
    time.sleep(5)
    microcontroller.reset()

aio_username = os.getenv('ADAFRUIT_IO_USERNAME')
aio_key = os.getenv('ADAFRUIT_IO_KEY')

try:
    pool = socketpool.SocketPool(wifi.radio)
    requests = adafruit_requests.Session(pool, ssl.create_default_context())
    # Initialize an Adafruit IO HTTP API object
    io = IO_HTTP(aio_username, aio_key, requests)
    print("connected to io")
except Exception as e:
    pixels.fill((100, 100, 100))
    pixels.show()
    print("Error:\n", str(e))
    print("Resetting microcontroller in 5 seconds")
    time.sleep(5)
    microcontroller.reset()

PM1006 Setup

The PM1006 sensor communicates over UART. Each packet of data from the PM1006 contains five measurements. You can tell the beginning of a packet based on the header values at the beginning of the UART data: 0x16, 0x11 and 0x0B. The valid_header function is used in the loop to determine if a new reading is coming in from the sensor.

uart = busio.UART(board.TX, board.RX, baudrate=9600)

measurements = [0, 0, 0, 0, 0]
measurement_idx = 0

def valid_header(d):
    headerValid = (d[0] == 0x16 and d[1] == 0x11 and d[2] == 0x0B)
    # debug
    # if not headerValid:
        # print("msg without header")
    return headerValid

The Loop

In the loop, every minute, the last PM2.5 data reading from the PM1006 sensor is logged to Adafruit IO. While data is being sent to Adafruit IO, the NeoPixel is blue.

...
        data = uart.read(32)  # read up to 32 bytes
        #print(data)  # this is a bytearray type
        time.sleep(0.01)
        if ticks_diff(ticks_ms(), clock) >= io_time:
            pixels.fill((0, 0, 255))
            pixels.show()
            io_data = measurements[0]
            if io_data != 0:
                io.send_data(ikea_pm25["key"], io_data)
                print(f"sent {io_data} to {ikea_pm25["key"]} feed")
            time.sleep(1)
            clock = ticks_add(clock, io_time)
            pixels.fill((0, 0, 0))
            pixels.show()

Parsing Data

If data is read over UART, then the valid_header function determines if its the start of a new sensor reading array from the PM1006. If a new data series is being read, the NeoPixel turns red and the data is logged to the measurements array.

...
            if data is not None:
            v = valid_header(data)
            if v is True:
                measurement_idx = 0
                start_read = True
            if start_read is True:
                pixels.fill((255, 0, 0))
                pixels.show()
                pm25 = (data[5] << 8) | data[6]
                measurements[measurement_idx] = pm25
                if measurement_idx == 4:
                    start_read = False
                measurement_idx = (measurement_idx + 1) % 5
                print(pm25)
                print(measurements)

Try, Try Again

When the QT Py is idle, the onboard NeoPixel is green. The entire loop is wrapped in a try/except statement so that the QT Py will reset in case of any errors.

try:

	...
    else:
      pixels.fill((0, 255, 0))
      pixels.show()
except Exception as e:
    print("Error:\n", str(e))
    print("Resetting microcontroller in 5 seconds")
    time.sleep(5)
    microcontroller.reset()

Using a long and thin screwdriver, remove the four screws from the back of the air quality monitor.

Slide out the front panel of the air quality monitor. You'll see the PM1006 module and fan plugged into the PCB.

Unplug the PM1006 and fan connectors from the PCB.

Remove the three screws mounting the PCB to the front panel.

Lift the PCB out of the front panel. Now you're ready for soldering.

You'll be soldering to three test points on the PCB: 5V, GND and REST. They are located next to the plug for the PM1006 sensor and below the large capacitor on the PCB.

Solder a piece of wire to each of the following test points: 5V (red wire), GND (black wire) and REST (green wire).

Solder the 5V connection to the BAT+ pad on the back of the QT Py (red wire). Then solder the GND connection to the - (GND) pad on the back of the QT Py (black wire).

Solder the REST connection to the RX pin on the QT Py (green wire).

Place the PCB back onto the front plate of the air quality monitor. Secure it with the three screws.

Plug the PM1006 and fan connectors back into the PCB.

Add a small dot of hot glue to the top of the inside of the case, above the PM1006 sensor. Press the QT Py into the hot glue to secure it with the USB port facing out. This way if you need to adjust the code, you can still access the USB port.

If you ever want to remove the QT Py, you can use some isopropyl alcohol to remove the hot glue.

Close up the case with the four screws.

Now you're ready to start logging the readings from the Vindriktning to Adafruit IO! 

You can create a Dashboard in Adafruit IO to organize and display your data.

On Adafruit IO, click on the Dashboards tab and then + New Dashboard.

This opens the new Dashboard window. Enter the name of your Dashboard in the Name box and then click Create.

Click on the Dashboard you just created to view it.

On the Dashboard, click the cog wheel and click + Create New Block.

Click on the raw data feed block and then select the feed from your feed list. Finally, you'll be brought to the block settings window, where you can name the block and adjust various settings.

Then, create a second new block, this time selecting the line graph block.

Now your Dashboard will show the raw data from the PM1006 and a line graph displaying the changes in the data over time.

SMS message alerts are only available for IO+/IO Plus (Paid) accounts. To upgrade your Adafruit IO Free account to Adafruit IO Plus, visit io.adafruit.com/plus!

You can set up Actions in Adafruit IO to alert you when certain values are logged. This can be especially handy for this project to let you know if your air quality is becoming unhealthy.

To receive an SMS message Action alert, you'll need to add your phone number to your Adafruit account. Note that SMS message alerts are only available for IO+ accounts. Other alert types are available for free tier accounts, such as email and web hooks.

In Adafruit IO, go to the Actions tab and click on + New Action.

Then, you'll choose an Action type. Click on Reactive.

This lets you set up the Action, which is done through a logic tree with dropdown menus:

  1. Select the feed that you're monitoring
  2. Select the greater than or equal dropdown and enter the value that you want to monitor for the threshold
  3. Select the action notification type (SMS message, email, etc)
  4. Select the feed again and customize the message that you'll receive when the action is triggered

Further down the page, you can select the frequency for notifications and if you'd like to be notified when the value returns below the threshold that you're monitoring. Then you'll click Submit to save the Action.

This returns you to the Action page, where you can see the Action you just set up.

When the Action is triggered, you'll receive the notification with the message that you entered.

This project has only been tested with a QT Py ESP32-S3. Other boards may not work as expected.

Plug in the Vindriktning to a USB C cable and power supply. The QT Py ESP32-S3 will receive power from the PCB and run the CircuitPython code.

Over time, you'll see the PM2.5 data from the PM1006 sensor inside the Vindriktning be logged to Adafruit IO.

If the air quality rises above your defined threshold, the Action you set up will be triggered and you'll receive an alert.

This guide was first published on Jul 05, 2023. It was last updated on Jul 23, 2024.