CO₂ monitor built on the back of a Waveshare ESP32-S3-LCD-1.28" (non-touch) board and using a Sensirion SCD41 sensor.
Built direclty inside of home assistant using the ESPHome Add-on so that the screen displays real-time CO₂ levels on its circular LCD screen in a calm, intuitive way.
I've purposefully not not added any referal purchase links into this git beacuse I just don't believe in Amazon or any other company having one more data point on you when it comes to buying things.
This project started life with a plan to build a full firmware in C++ with data being pushed to HA via MQTT as that is where I felt my skills were in getting this working in the exact way I wanted; with the clean interface and animations I wanted, whilst still being lean on system resources. However! After I was given a significant humbling at just how much by C coding skills have dropped off after too may years of not using them to their fullest, so I pivoted this into being a ESPHome device build.
The only reason I didn't go this way to begin with was I had grander visions for the UI and animations etc and ESPHome does not natively support a lot of the needs I had out of the box (at least just yet anyway). AND I was less assured of my ability to make my preffered logic work in .yaml due to me just being less expereienced at coding in .yaml for this kind of project. SO, it was built in 36 hours (originally I gave myself a 4 day long weekend to get it working the way I wanted and burned the first 2.5 days fighting my loosing battle with C) So by the time I pivoted to ESPHome I had significantly compromised my inital ideas AND I had to rely on AI to help me code the logic just right and functioning. So I am CONFIDENT there are inefficiencies in this code. And I am CONFIDENT it could probably have been written better.
But this was designed for personal use so I will work on it in moments of spare time to see if I can improve this foundation. The reason for calling this out is to try and let you know not expect this project to be maintained in ANY way, or for me to even be able to provide significant support because for some of this template logic, (honestly) I am still coming to grips with the actual layout of it myself. I am happy for you to fork this project and work on it for yourself and feedback your improvements to me so that I can improve my own working version (I would in fact LOVE that). But I just don't have the time to maintain this in an ongoing way or try to promise reliable updates/support.
For now it works close to the way I wanted and I'll address the other pieces I'm still working on, when I can.
I've not yet designed a housing for this setup. So if someone comes up with a cool way to package this (using my exact hardware setup) with a nice 3D print that houses everything in a really tidy way. I would be greatly apreciative of you if you're willing to share anything you come up with. Like my C++ skills my 3D design artistry is not what it used to be so I will probably be approaching the housing of this in a really "function over form" way. But being that I've gone to a reasonable effort to build an aesthetically pleaseing UI I would much prefer a clean "designer" feel for this as an end product. Thanks in advance to anyone who does reach out to provide something.
Hardware Used - read what to search for if you want a 1:1 copy of my own version otherwise choose what suits you.
| Component | Model / Link | What to search for |
|---|---|---|
| Microcontroller + Display | https://www.waveshare.com/wiki/ESP32-S3-LCD-1.28 | My version was supplied by an Australian company (Core Electronics) that offers it with a milled aluminum housing. But I'm sure there are plenty of other suppliers that will be able to offer something similar in your region. |
| CO₂ Sensor | https://www.adafruit.com/product/5190 | I specifically chose the Adafruit version shown here because it was already solderd to a breakout board that offered ease of wiring. You can go with whatever suits your build. |
| Cable | JST-SH (STEMMA QT / Qwiic) for I²C connection | I just chose the best fit I could from what was available to me in this sizing for the Adafruit board. It's in no way a requirement to wire your sensor the way I did |
| Power | USB-C 5 V | Nothing to search for, ease of power delivery to all components and ability to flash easily from my PC was the main reason for choosing this, nothing elese. Power it in the way you want for your project |
The touch variant uses a different pinout and will not work without modifying the YAML configuration to suit its specs. That said this board is running at almost 96% flash once this code is loaded so adding any touch functions might still just be feasible but you'd have to test for yourself to confirm.
All of the documentation for this board and which pins need to be used to address the I2C controller in order to connect the SCD41 senor can be found on the wiki https://www.waveshare.com/wiki/ESP32-S3-LCD-1.28 However like all hardware these things can be prone to update as the manufacturer itterates things over time (and this is a relatively new product on the market) so please confirm for yourself before wiring, if the below setup is still valid before pushing forward. I will not be held acountable for any blue smoke of death you encounter or component death if you haven't confirmed your own wiring.
As of the time of writing this the above diagram was the correct pinout for the waveshare board used in this build. If you're using the same Adafruit SCD41 with the breakout board that contains the JST connectors the witing for each wire colour is as shown in the table below.
If you're choosing to solder to the board's solder terminals double check my wiring desciriptions against the manufacturer's specs but I believe there is no change to this pin to pin layout regardless of JST usage or not. But again DO NOT assume that I am correct. This was true as of the time of writing (and there's no real reason it should change) but I didn't wire in this way so it's always best to check for yourself.
| SCD-41 Wire | Function | ESP32-S3 Pin | Board Label | Header Pin | Notes |
|---|---|---|---|---|---|
| 🔴 Red | VIN (3.3 – 5 V) | — | VSYS | H1-18 | Power from USB-C 5 V rail |
| ⚫ Black | Ground | — | GND | H1-20 | Common ground with MCU |
| 🔵 Blue | I²C SDA | GPIO 6 | IMU_SDA | H2-16 | I²C data line |
| 🟡 Yellow | I²C SCL | GPIO 7 | IMU_SCL | H2-14 | I²C clock line |
✅ Summary
Red (VIN) → VSYS (H1-18) Black (GND) → GND (H1-20) Blue (SDA) → GPIO 6 (H2-16) Yellow (SCL) → GPIO 7 (H2-14)
Before you begin, complete the required assets step. If this step is skipped, the first build will fail.
This project references image and font assets during compile. These folders do not exist by default inside of home assistant and must be created manually inside your Home Assistant configuration directory:
config/esphome/
├── co2_sensor/
├── fonts/
├── images/
Copy the provided font files into the fonts folder and all required image files (background and animation frames) into the images folder before the first install.
If these folders or files are missing, ESPHome cannot package the assets into flash and the build will fail.
-
Open the ESPHome add-on in Home Assistant.
-
Create a new device. If you want to use the prebuilt yaml provided here you'll need to name the device the exact name below as the ESPHome wizzard ties the device name permenantly to the device and (to the best of my knowledge) this can't be changed after the fact without completely reflashing a fresh install. If you choose to use my code in a copy and paste way you'll need to name it exactly:
"co2-sensor"
Alternatively you can alter the yaml name to match what you chose for device name of your ESP board
If you connected your board to the computer you intend to work on this project from and run the setup wizzard ESPHome will pick up all the board settings correctly. Otherwise step through this section manually and enter the matching information for the device you're using. Once the default information is flashed to the device click edit and you should see some information regarding your Home Assistant API key and OTA password. It's not imperative that you use these in your own device but the template code does allow for them and it's easier to just copy and paste these somewhere safe so that you can use them in the template later rather than having to got through the process of comissioning these from home assistant etc.
If you've chosen to go with the same waveshare board I did you can simply open the newly created device and replace all default code with code found in the template .yaml file provided
Update the Wifi SSID with your own network information (if not using a secrets file). And add in the API key and OTA password you copied earlier and place them in the alloted areas if you're going to use them.
Confirm the
fontsandimagesfolders (and their files) are in the correct folders on your home assistant machine as shown above.
Click Install and choose Plug into this computer (or OTA if already flashed).
When the device comes online, you can add it to Home Assistant through the ESPHome integration by inputing the ip address of the device as it appears on your network.
Although the SCD41 also reports temperature and humidity, those values are not shown on the device screen.
This display is designed for CO₂ at a glance from a moderate distance. Adding more on-screen data reduced instant readability.
Temperature and humidity are still published to Home Assistant as entities, so you can use them freely in dashboards and automations.
The sensor translates CO₂ levels into clear visual “moods” represented by a stylised lily that opens or closes depending on air quality.
| Threshold | Name | Approx. CO₂ Range (ppm)* | Visual State |
|---|---|---|---|
| T1 | Soft & Still | 400–600 ppm | Lily fully open / soft pale blue background with blue text |
| T2 | Feeling Fine | 601–900 ppm | Lily gently closing / calm green background with green text |
| T3 | Time to Refresh | 901–1200 ppm | Lily mostly closed / amber background with yellow text |
| T4 | Bring the Outside In | >1200 ppm | Lily tightly closed / deep orange background with purple text |
* Exact ranges can be refined once your individual sensor stabilises and self-calibrates in its final environment. The SCD41 is designed for self calibration right out of the box.
The display is event-driven — it wakes when something meaningful changes, then returns to quiet mode. The plan for this longer term is for it to be able to register when the user has done something to initate a positive downward change in the Co2 reading and have the display only wake again when it crosses into a lower threshold or if the decrease in co2 levels plateaus for a 10 min period and then therefore wakes to let the user know that things haven't improved significantly.
| Event / Condition | Behaviour | Status |
|---|---|---|
| Startup | Wakes with brief animation for ~30s to show current CO₂ ppm and zone with associated background and colours, then sleeps. | |
| Crossing a threshold (up or down) | Wakes with brief animation shows new zone information for ~15s with the new background colour and ppm value displaying after animation, then sleeps. | |
| No improvement for >30 minutes within the same threshold | Brief reminder wake, then sleeps. | this functionality is still not complete but I am hoping to fix this in later edits |
| CO₂ rising steadily (+30 ppm/reading) | Normal 30-minute reminder cadence. | as above I'm still dialing this in correctly so as of right now it's not functional |
| CO₂ falling steadily (−30 ppm/reading) | “Sleep-on-actioned” — stays quiet until the trend flattens (~10 min) or drops to the next lower threshold. | reviewing the logs this seems to be working MOST of the time but still requires fine tuning |
| Flat trend ≥10 min while still in poor air (>T3 minimum) | Gentle reminder wake. | WIP |
| Recovered (<T1 upper limit) | Brief positive wake (current colour + ppm), then sleep. |
Hysteresis/Smoothed oscilation logic: ±20 ppm around each boundary to prevent screen wake/sleep flicker.
Smoothing: 15 s rolling average to ignore brief 1–2 s spikes.
sensor.co2_sensor_co2— CO₂ in ppm (primary signal).sensor.co2_sensor_temperature— ambient temperature (not shown on device screen).sensor.co2_sensor_humidity— relative humidity (not shown on device screen).sensor.co2_sensor_co2_air_quality_stage— shown on the device UI as a baked in element to the background images but also a nice simple way to run automations off the back of in order to create automations like if sensor.co2_sensor_air_quality_stage = "time to refresh" then Open kitchen window. This way you don't have to mess around with specific and exact Co2 reading numbers if you just want a blanket "hey when its this — do this; but when its that — do this"
-
switch.co2_sensor_co2_sensor_display_always_on
Keeps the screen on continuously so the current CO₂ reading is always visible. The lily animation still plays on threshold changes.
Why it’s optional: TFT panels can show image retention with long-term static content. This is a precautionary note — this specific panel has not shown issues in testing, but the option exists to minimise any risk. -
switch.co2_sensor_co2_sensor_display_wake
Manually triggers the “wake” behaviour on demand. Use this when the display is asleep but you want an immediate visual update (current zone + ppm) acn be useful for voice assistant automations that you want to use to trigger a wake up of the display "hey Jarvis what is the air feeling like in here". -
switch.co2_sensor_co2_sensor_sleep_mode
Forces the screen to stay off and disables wake behaviour. Handy for scheduled quiet hours (e.g., evenings in a bedroom).
These switches let you choose between a subtle, ambient experience or a more persistent visual readout depending on room, lighting, and personal preference.
This project runs close to the ESP32-S3’s flash limit — the current firmware uses roughly 96% of available flash (not ideal but fk it for now it works (≧∇≦)ノ ).
If you plan to make changes (e.g., add frames or colour-resolution assets), keep storage in mind. If a compile fails, you may have exceeded flash size.
How it fits: all animation frames are stored as grayscale images for compact storage. At runtime, ESPHome tints them in code (e.g., via pixel draw operations) to achieve colour.
This keeps visuals expressive while dramatically reducing file size so the project builds reliably.
Below is where you can find each of the necessary files needed for the instructions above.
CO2-Sensor-Display/
├── main/
│ ├── fonts/
│ ├── images/
│ ├── device images/
│ ├── Electronics images/
│ ├── Template.yaml
│ └── README.md
Released under the MIT License.







