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
- 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.
- 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.
- 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 pad | Signal | QT wire |
|---|---|---|
| 1 | GND | black |
| 2 | +3V3 | red |
| 3 | SDA | blue |
| 4 | SCL | yellow |
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.

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.
| Setting | Value | Why |
|---|---|---|
| automatic_self_calibration | false | Moment 1 |
| filters: filter_out | 0.0 | Moment 2 |
| altitude_compensation | 157 m | house at about 515 ft |
| update_interval | 60 s | the 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.
gen.pyextracts theon_boot,on_valueandintervallambdas verbatim from the firmware YAML and wraps them in a C++ simulator. The lambda text is never retyped, so the test runs exactly what the firmware runs.- The simulator stubs
millis()(including the 32-bit wrap), restored globals,App.safe_reboot()and the entities, and runs eight scenarios: healthy, late first read, dies and never returns, dies and returns at 50 min, 100 s glitch, 600 s glitch, healthy across the 49-day wrap, dead from power-up. faults.pyproves the test can fail: the clean config must PASS, then five single-span faults must each FAIL - NaN guard removed, reboot cap raised to 4, stale threshold cut to 24 s, wrap-unsafe age arithmetic, and a recovery boot that clears the fault.
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
- Settle the SCD30 standoff height and how the USB cable leaves the box.
- Build the first node, flash over USB, and confirm a boot with a working sensor never logs "no SCD30 reading".
- 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.
- Outdoor FRC, then deploy to the bedroom and family room.
Progress will land here as project logs.
Licenses
- Hardware (
hardware/): CERN-OHL-S v2 - Code (
esphome/,harness/): MIT - Docs (README): CC BY 4.0
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.
Bill Collis