A CO2 Node That Fails Visibly

Two CO2 nodes, one in a bedroom and one in the family room. Each pairs an Adafruit SCD30 (PID 4867, Sensirion NDIR) with a Seeed XIAO ESP32-C3 on a small carrier board, inside a Hammond 1551V4. ESPHome + Home Assistant. CERN-OHL-S v2 hardware, MIT code, CC BY 4.0 docs. Repo: github.com/wkcollis1-eng/CO2-Monitor

It is 03:00 in a closed bedroom. The SCD30's cable worked loose an hour ago. Nobody is awake to notice, and nobody is coming to fix it tonight.

A CO2 sensor that has stopped answering is dangerous in one specific way: Home Assistant keeps showing the last good number, and a flat, healthy-looking reading is exactly what a dead sensor looks like. The acceptance test is what the node reports at that moment, not whether it is online.

KiCad 3D render of the fit-check model, not a photograph - no node has been built yet. The standoff height is not final, and the model doesn't show how the USB cable leaves the box.


Status - October 2026: Draft, Not Built

The Three Moments The Firmware Is Built Around

  1. Every boot. ESPHome 2026.9.1 turns the SCD30's automatic self-calibration (ASC) on by default and writes it to the sensor at every boot. ASC assumes the sensor regularly sees fresh outdoor air. A closed room in a house with no HRV or ERV never does, so ASC would walk the baseline upward where nobody would see it. It's turned off explicitly.
  2. Power-up. The SCD30's first reading after power-up is 0 ppm, and ESPHome passes it through. A filter drops it. 0 ppm isn't a possible indoor value, so dropping every 0 costs nothing.
  3. The sensor stops answering. After 240 s with no reading, the CO2 entity goes unknown and CO2 Sensor Fault turns on. After 900 s the node reboots to re-run sensor setup, at most three times in a row. The counter is stored in flash, so a dead cable can't become a reboot loop that hides it. After the third reboot the node stays up and keeps reporting the fault.

Hardware

PH1 - sensor header

The SCD30 is cabled from its STEMMA QT socket to PH1, a 1x04 right-angle header. No header is soldered to the SCD30.

PH1 padSignalQT wire
1GNDblack
2+3V3red
3SDAblue
4SCLyellow

Pad 2 is the square pad, not pad 1. Count from the square pad and you'll put 3V3 on the wrong wire. Place each wire by colour.

Feed the breakout from 3V3, never 5 V. Its 10K SDA/SCL pull-ups go to VIN, so VIN sets the high level the XIAO's 3.3 V pins see on the bus.

Fit in the enclosure - the open question

The 1551V4 is 15.30 mm deep inside. Both boards sit on standoffs on the enclosure floor.

Fit-check model with the cover lifted. Render, not a photo.

Firmware

All behaviour lives in one file, esphome/co2-node-common.yaml. Each node file (bedroom-co2-node.yaml, family-room-co2-node.yaml) holds only its name, its own API key and the packages: include, so a fix made once reaches both.

SettingValueWhy
automatic_self_calibrationfalseMoment 1
filters: filter_out0.0Moment 2
altitude_compensation157 mhouse at about 515 ft
update_interval60 sthe watchdog's 240 s and 900 s count these reads

No SCD30 temperature or humidity entity, on purpose. The SCD30 heats itself, which skews both, and the rooms already have their own T/RH sensors.

Calibration: outdoors, by hand

With ASC off, forced recalibration (FRC) is the only calibration path. The node exposes an API action, forced_recalibration(reference_ppm): take the node outdoors or to an open window in steady air, let it read for several minutes, then call the action with the outdoor reference (about 420 ppm - a local outdoor reading wins). It refuses a reference outside 400-2000 ppm. Never call it from an automation: a reference given indoors bakes the room's excess CO2 into every later reading.

The Watchdog Harness

harness/ runs the watchdog's real lambdas on a PC against simulated time.

What it doesn't prove: anything about the SCD30 driver, the I2C bus, or ESPHome's own flash storage. It shows that the lambdas, given the stubbed behaviour, do what they claim.

What's Next

  1. Settle the SCD30 standoff height and how the USB cable leaves the box.
  2. Build the first node, flash over USB, and confirm a boot with a working sensor never logs "no SCD30 reading".
  3. Pull the sensor cable on a running node and watch the real fault path: unknown at 240 s, three capped reboots, then a node that stays up reporting the fault.
  4. Outdoor FRC, then deploy to the bedroom and family room.

Progress will land here as project logs.

Licenses

Vendor files (Adafruit, Seeed, Hammond, Sensirion) keep their own terms - see THIRD_PARTY_NOTICES.md. One USB-C plug model's author and licence weren't recovered; delete it if that matters to you.

Source: github.com/wkcollis1-eng/CO2-Monitor

Copyright (C) 2026 William K. Collis

If the watchdog design or its harness saved you time, the sponsor link on the repo funds instrumentation + docs.