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

  • Firmware: Rev 1.0-draft, 2026-10-18.
  • Compile: both nodes compile on ESPHome 2026.9.1 with 0 warnings and 0 errors.
  • Watchdog: host-simulated. The clean config passes all 35 checks, and each of 5 injected faults is caught (see the harness below for what that does and doesn't prove).
  • Hardware: no new PCB. The carrier is an existing ESP32-C3 sensor-node board; the KiCad file is that board unchanged, with the SCD30 and enclosure placed on it for a 3D fit check.
  • Deployment: not deployed. The firmware isn't in the Home Assistant config yet.

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

  • Carrier: 23.0 x 38.0 mm, 2-layer, 1.6 mm
  • MCU: Seeed XIAO ESP32-C3
  • Sensor: Adafruit SCD30 breakout, I2C 0x61, the only device on the bus
  • I2C: SDA GPIO6 (D4), SCL GPIO7 (D5), 50 kHz
  • Status LED: GPIO20 (D7), kept dark - the box is opaque
  • Enclosure: Hammond 1551V4, 80 x 80 x 20.3 mm ABS

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.

  • The SCD30 is the tight part. On 6 mm standoffs it clears the cover by 0.735 mm nominal. Its height tolerance is ±0.70 mm and the cover's moulded lettering stands 0.20 mm, so the worst case is a clash under the lettering. On 5 mm standoffs the worst case is 1.035 mm, or 0.835 mm under the lettering. The SCD30's standoff height is still open.
  • The carrier on 6 mm standoffs leaves 0.95 mm with a XIAO soldered on pin headers. A socketed XIAO doesn't fit, and the screw under the XIAO has to go in first.
  • The floor has a 1.0 mm recess at its centre (±7.53 mm). Keep standoffs off it....
Read more »