How to Build Your Own CO2 Meter With an Adafruit Feather and E-Ink Display

Carbon dioxide levels have been a hot topic and in this tutorial I'll teach you how I made one from an Adafruit Feather, E-Ink display, CO2 sensor, and CircuitPython to help measure your air quality.

How to Build Your Own CO2 Meter With an Adafruit Feather and E-Ink Display

Overview

In this post you'll find my entire journey to get from components to hello world to a working project. It's a fun and amazingly simple little battery powered widget. Getting straight to the juicy bits, here's the final product:

Working example of version 1.2 while charging.

With each area on screen:

All the code is on the GitHub repo:

GitHub - nikouu/Adafruit-Feather-CO2-Meter: 👩‍🔬 A carbon dioxide 💨 meter with an Adafruit⭐ RP2040 , a 2.9″ eInk display 🖥 , a SCD-40 CO2 sensor, and CircuitPython 7 🐍.
👩‍🔬 A carbon dioxide 💨 meter with an Adafruit⭐ RP2040 , a 2.9" eInk display 🖥 , a SCD-40 CO2 sensor, and CircuitPython 7 🐍. - GitHub - nikouu/Adafruit-Feather-CO2-Meter: 👩‍🔬 A carbon dioxide 💨...

Basic Plan

2021 onwards saw a surge of people using CO2 meters to help gauge indoor air quality. The Aranet4 seemed popular but I also saw people making their own and thought "that's a fun project, I'll do it too!"

On the 7th of February 2022 I penned up a quick sketch of an air quality sensor. Originally I wanted to use a Raspberry Pi Pico but found the eInk (I LOVE eInk) display I was looking at just instantly plugs into a Feather board.

The Raspberry Pi idea ultimately became another project around a motion detection wildlife camera with a Pi Zero 2 W, solar panel, and battery.

Sketched notes for this CO2 meter project.

The original design was exactly what I ended up doing so let's jump into the components used.

Components

All components grabbed from Adafruit.

Item Cost (USD)
Feather RP2040 $11.95
Adafruit 2.9" Grayscale eInk $22.50
Lithium Ion Polymer Battery 400mAh $6.95
SCD-40 - True CO2, Temperature and Humidity Sensor $49.50
STEMMA QT / Qwiic JST SH 4-pin Cable $0.95
Brass M2.5 Standoffs 16mm tall $1.25
Total $93.10

The only soldering needed is to attach the given headers onto the RP2040.

Development

Jump on board to scoot through the entire development cycle from "hello world" to "hel- oh no, I need to open a window".

👀
All of these milestones are in the Tutorial section of the GitHub repo.

Hello World

The Blink tutorial got me set up. Installing the Mu Editor, going over basics of setting up the Feather, and of course the first "hello world" code (blinking an LED). FYI that the LED blinking isn't the fancy RGB NeoPixel.

Blinking LED

Running the Display

Adafruit have a great tutorial specifically for the 2.9 inch greyscale eInk display:

Adafruit 2.9″ eInk Display Breakouts and FeatherWings
Easy eInk display usage comes to microcontrollers with breakouts designed to make it a breeze to add a tri-color or grayscale eInk display.
Yes the colour eInk display is shown, but it also has the greyscale tutorial too.

First up is soldering the header pins included with the RP2040 so I can connect it to the eInk FeatherWing. It had been a hot minute since I last soldered, and the wonk-factor made this painfully obvious. Since there's no evidence, here's a dirty edit of what the pins looked like before correcting:

A whole row of janky pins on the left.

I jammed in the Feather into the back of the display and excitedly executed the tutorial code aaaaand...

Display not looking good.

That's on me 🤚 since I misread the tutorial and copied the code for a different eInk display 🤦‍♀️

Once I sorted out the very basics of reading comprehension, the default tutorial image displays!

The tutorial image.

For drawing shapes I stole took inspiration from a CircuitPython file relating to the MagTag eInk product:

Different shapes on screen with varying colours and outline thicknesses.

With an understanding of how to manipulate the display, it's time to do the CO2 readings.

Displaying Text

Displays "Hello World!" on the display. Nothing too much here.

A simple hello world.

Displaying Text with a Different Font

But what if we used a different font 🤔

Hello world here is a bit too big, but at least it proved the font's different.

Getting the CO2 Values

I'm loving how the tutorials are easy to find and the tutorial for the SCD-40 is no exception. The sample code is easy and it showed up exactly as expected in the Mu editor serial output window.

Readings from the SCD-40 in the Mu Editor serial window.

Proof of Concept

Bringing together most of the above, we can get the sensor information on screen!

SCD-40 information on the eInk display.

Version 1.0 - First Stable Release

Taking the previous milestones and making it presentable. This now pulls together all the previous work.

It runs as intended with a 🎵simple and clean🎵 UI. Slightly cryptic if you didn't know what the SCD-40 reads, but good enough for now. It updates every 4.5 minutes. Note the display should only be refreshed every 3+ minutes to prevent damage.

The code itself is pretty easy and reads something closer to a Bash or Powershell script. I could've probably made it more OO but I think there's some romance (sure, let's go with that) in having it a simple to understand, cobbled together script. Makes me nostalgic for the old days.

The ratings you see on screen are based off the first few results when searching for CO2 levels. Some articles seemed very rooted in science, and others felt more general. The most definitive piece I saw was that less than 800ppm was pretty good.

Worth repeating: The rating scale I've written isn't strictly scientific - it's taken from various sources and is "good enough" for a fun little project like this. The cool part of an open source project is you can write your own ratings based on your research or knowledge.

Below is a gallery of what the little device looks like:

There is no case and sensor itself is free to hang with just one corner bolted. Actually that single spacer to hold the sensor to the RP2040 together being used as a makeshift kickstand works pretty well!

Since we haven't seen them yet - a preview of other CO2 ratings (with expertly consistent photography):

Other CO2 PPM ratings.

From the pictures above it's obvious that nothing is properly centered on the display. No pixel counting nor proper usage of a centered anchor for the text is in play.

With the stable release out of the way, it was time to begin working on improvements on power consumption. Having worked a little bit with Raspberry Pi Zero 2 W boards and looking at power usage, I figured this microcontroller without a proper OS must get a lot out of the 400mAh battery but it didn't... At first.

Oh side note: about 3 hours for a full charge. I charge it using either a regular phone charger or from my computer.

Version 1.2 - Improved Power Efficiency

✍️
Version 1.1 was only around briefly before moving right on to 1.2. Which is why it'll be tucked under the 1.2 section.

The 400mAh battery lasts (very) approximately 12 hours with the first stable release and I think it can do far better. But first, let's understand the power usage patterns. I'll be using a Multifunctional USB Digital Tester - USB A and C to take the readings, with several baselines:

Scenario Description Reading Notes
Baseline 1 Blinking LED, nothing attached 0.21W-0.23W Our absolute baseline
Baseline 2 Blinking LED, display attached (nothing displaying) 0.21W-0.23W
Baseline 3 Blinking LED, display attached (nothing displaying), SCD-40 (nothing reading) 0.21W-0.25W
Baseline 4 Stable release code v1.0 0.23W-0.74W Big spikes (more on that below), otherwise about the same as previous scenarios
Example of taking readings.

Now that we have baseline measurements, it's time to optimise!

✍️
From here, the display is set to refresh every 5 minutes instead of 4.5 minutes.

Version 1.1 - Deep Sleep

Let's deal with the easy stuff: Busy waits. I love optimising things (self plug: Shrinking a Self-Contained .NET 6 Wordle-Clone Executable) and there's a whole heap of ways for programs to sleep across different languages. I suspect that in CircuitPython that the time.sleep() call might be a bit busy in the background.

Doing some digging, it turns out we can use alarms instead of sleeps. I picked up on this fantastic tutorial called Deep Sleep with CircuitPython which explained the different types of sleep in CircuitPython.

I ran some tests (which are in the Tutorials/DeepSleep folder) for sleep types. The test case was a blink based on the tutorial and here are the results:

Scenario Description Reading Notes
Sleep 1 time.sleep() 0.21W-0.23W This one is in the Blink folder
Sleep 2 alarm.light_sleep_until_alarms() 0.17W-0.25W But more often around 0.17W-0.23W
Sleep 3 alarm.exit_and_deep_sleep_until_alarms() 0.11W-0.23W Both:
  1. I did see it hit 0W a couple of times
  2. The RGB NeoPixel also fires due to it booting up

Note: Test when connected to a power supply, and not PC as the board will not actively sleep when connected to a host computer.

The deep sleep looks like what we want. So let's apply it to the stable release:

Scenario Description Reading Notes
Efficiency 1 Stable release code with improved power efficiency 0.13W-0.64W A good improvement but with the same big spikes (see version 1.2)

A ~0.1W drop and the spikes remain.

This version, version 1.1, the battery lasts about 21 hours. Or 1.75 time longer than version 1.0.

Version 1.2 - Turning off the Sensor

Spike time. Every 3-5 seconds as it seems the SCD-40 sensor does a reading regardless of whether the values are read. Below is a quick look at the meter, note the red wattage reading on the lower left and how it spikes:

Spikes showing up in the red wattage area.

Note: The display on the reader presents averages between updates. It may not show the proper spike on each display update due to this.

Notice that these spikes did not happen in scenario Baseline 3 (from further up on this page) even with the sensor connected - it only started happening in Baseline 4 when the sensor has been activated. We can prove this by using the same blink code as Baseline 1, but added in the start measurement code for the sensor from Baseline 4:

import time
import alarm
import board
import digitalio
import adafruit_scd4x

led = digitalio.DigitalInOut(board.LED)
led.direction = digitalio.Direction.OUTPUT

i2c = board.I2C()
scd4x = adafruit_scd4x.SCD4X(i2c)
scd4x.start_periodic_measurement()

while True:
    led.value = True
    time.sleep(1)
    led.value = False
    time.sleep(3)
Scenario Description Reading Notes
Baseline 5 Blinking LED, no display attached, SCD-40 activated 0.23W-0.71W

Nailed it. Confirmed that the sensor measurements need to be kicked off before we see the power usage spikes - not just having the sensor connected to the board.

In theory if there is a start then there should be a stop. And there is! stop_periodic_measurement() is the exact call we're looking for. So the code will now:

  1. Only start a measurement just before needing the value
  2. Read and store the result
  3. Immediately stop the measurement

Let's see what that looks like:

Scenario Description Reading Notes
Efficiency 2 Efficiency 1 + power spike removal 0.11W-0.15W Then with the spike to 0.71W at the 5 minute mark to do a single read

After these improvements, the battery now lasts 54 hours, or 4.5 times longer than the version 1.0.

Future Ideas

Maybe these would be neat to implement, maybe they won't. Maybe they've been implemented far after this post's been written 🔮

  1. Use the eInk display buttons to switch to a graph mode
  2. Add symbols to help define what each reading is
  3. Properly center all the elements
  4. Make use of the four greyscale colours
  5. Take readings every x amount of time between display refreshes to get an average
  6. Have a button push to refresh asap (as soon as it's been 3 mins since the last display refresh)
  7. Battery indicator
  8. A way to tell whether the battery has run out on the display (currently the green LED on the sensor is the only indicator)

Conclusion

This was a friendly project to do - I thought it was going to be much more difficult. The mixture of (mostly) no soldering, easy to grasp examples, and CircuitPython reduced the faff and got me right into a good mood for programming.

It's also changed my behaviour around opening windows at home and given me an idea of my workplace air quality. A practical project feels really fulfilling, yaknow?

I hope you found this useful and this post maybe even gave you a smidge of inspiration to make your own air quality sensor or other Feather project.

Thanks for reading!