A room display that says 720 ppm can be misleading even when its arithmetic is correct. The number may belong to the previous minute, to a sensor that stopped producing results, or to the first seconds after startup. Build a carbon-dioxide monitor whose output always answers two questions: what did the sensor report, and why is the program willing to call it current?
This project uses an Espressif ESP32-DevKitC V4 with an ESP32-WROOM-32E module, the Adafruit SCD-41 breakout, product 5190, and MicroPython ESP32_GENERIC v1.26.0. Its display is the USB serial console: USB means Universal Serial Bus, which carries the development board's power and console connection. An extra screen is unnecessary for learning the measurement logic.
CO₂ is carbon dioxide. The unit ppm, parts per million, describes its concentration: 1000 ppm corresponds to 0.1% by volume. Sensirion's SCD41 uses a photoacoustic optical method associated with NDIR, non-dispersive infrared sensing: absorbed infrared energy produces a response related to the target gas. It reports a CO₂ measurement; this is different from a volatile-organic-compound sensor's calculated eCO₂, equivalent carbon dioxide. VOC means volatile organic compound. Do not relabel a VOC-derived estimate as this measurement.
The exact breakout is important. Product 5190 includes a Sensirion SCD41, supply components and bus-interface circuitry. A bare sensor, an SCD40 board, an unrelated marketplace module and an ESP32-S3 development board are not the pin-compatible bill of materials for this article. Use the header labels on the delivered boards and the linked manufacturer drawings.
The SCD41's specified CO₂ accuracy range is 400–5000 ppm under its stated conditions; the numerical output format extends further. The program keeps a fresh 6000 ppm result visible but marks co2_in_specified_range: False. It never clips it to 5000 or turns it into zero. That flag describes the documented accuracy range, not a ventilation threshold. This experiment does not classify a room as safe, measure every pollutant, or replace a certified alarm.
The sensor can demand a much larger instantaneous current than its average suggests. Its documented peak at a 3.3 V sensor supply reaches 205 mA. A supply budget based only on its average periodic current is therefore inadequate. We use a separate sensor supply so the experiment does not assume an unspecified spare-current or noise budget on the ESP32 board's 3V3 output.
The capacitors provide local charge and a short high-frequency current path. They cannot repair a weak regulator or long resistive wiring. For example, a 1 Ω round-trip power path would drop 0.205 V at 205 mA. That is a calculated wiring loss, not a measured property of this build. Check the supply at the module during operation; a steady handheld-meter reading cannot prove that short current pulses cause no dips. Sensirion's supply-ripple condition also concerns the unloaded source; a loaded oscilloscope trace alone is not that exact test.

| Connection | Purpose |
|---|---|
| ESP32 IO21, J3 pin 6 → breakout SDA | Serial data. GPIO means general-purpose input/output; 21 is the ESP32 GPIO number, not the twenty-first header position. Optional switch S1 is in this lead only. |
| ESP32 IO22, J3 pin 3 → breakout SCL | Serial clock. The program uses a 100 kHz bus with short local wiring. |
| ESP32 GND, J3 pin 1 → breakout GND and sensor-supply negative | One shared voltage reference. GND means ground; it is not the sensor's positive supply. |
| Separate regulated +3.3 V → breakout VIN | Sensor-board power. Add the two local connector capacitors to ground. |
| ESP32 3V3 and breakout 3Vo | Both are left unconnected in this arrangement. Do not join regulator outputs or feed external power into a pin described as an output. |
| Breakout factory jumpers | Keep both in their delivered default positions. Do not alter them for this 3.3 V experiment. |
I²C, Inter-Integrated Circuit, transfers commands on SDA and SCL. Devices pull a bus wire low or release it; pullup resistors establish high. Adafruit documents 10 kΩ pullups and level translation on this exact board. Do not add another generic pullup set here. The selected logic domain is nominally 3.3 V; measure the delivered board's idle high levels before connecting the ESP32, especially if a previous owner modified a jumper. A 10 kΩ pullup at 3.3 V carries about 0.33 mA while low; its rise speed also depends on wire and input capacitance.
Adafruit's Rev A erratum says the original board silkscreen swapped the labels of its sensor-power and I²C-logic selection jumpers. The current pinout documentation explains their real functions. This is a board-labeling problem, not an instruction to cut jumpers. Match the exact revision and consult the drawing if its history is unknown. The bare SCD41's 2.4–5.5 V operating-supply specification and 6 V absolute maximum do not make an ESP32 input 5 V tolerant.
Wire with both supplies off. Use USB as the ESP32 board's sole power source and the separate supply for the sensor only. Power and depower both sides together; do not leave bus wires between a powered board and an unpowered board, because interface paths can feed unwanted current. Do not disconnect ground to imitate a sensor fault.
The fixed seven-bit I²C address is 0x62. Starting the program waits 50 ms, sends stop-periodic 0x3F86 and waits its 500 ms execution time. This establishes an idle sensor even if only the ESP32 interpreter was restarted. The program reads the sensor serial number and existing calibration/compensation settings, starts ordinary periodic measurement with 0x21B1, and reports the current pressure setting when that optional command is supported. An unavailable pressure-setting read is reported as a warning and an absent setting, rather than assumed to be zero. This mode updates every five seconds. The one-second application loop is a status poll, not a claim of five times faster CO₂ sensing.
For each poll, 0xE4B8 requests readiness. After its one-millisecond command delay, the lower eleven bits determine whether a result is available: status & 0x07FF. An upper status bit alone does not mean ready. If no result is ready, the program does not refresh any measurement timestamp. When ready, it sends 0xEC05, waits one millisecond, and reads nine bytes: three two-byte words, each followed by its own check byte. Reading consumes the available result; repeated display refreshes cannot manufacture another sample.
CRC, cyclic redundancy check, is an error-detecting calculation. This protocol uses an eight-bit CRC with initial value 0xFF and polynomial 0x31. We verify the status word and all three measurement words. A short reply, failed acknowledgement, zero CO₂ value, or invalid operating-temperature result becomes a fault. CRC does not establish calibration or detect every possible corruption.
The word conversions in this implementation follow Sensirion's current embedded-driver arithmetic: CO₂ equals its unsigned word; temperature is −45 + 175 × rawT/65536 °C; relative humidity is 100 × rawRH/65536 percent. Relative humidity describes moisture relative to saturation at the same temperature. The received 16-bit words are unsigned whole numbers from 0 through 65535. Printing many decimal places would not improve the sensor's accuracy.
The April 2025 datasheet, version 1.7, Table 11 prints denominators 2¹⁶−1 for temperature and humidity. Sensirion's current official embedded driver uses power-of-two integer arithmetic equivalent to division by 65536. This article follows that driver for the measurement words and tests the choice explicitly. Across the full raw range, the difference is below 0.003 °C and 0.002 percentage points of humidity. It does not explain a large temperature mismatch. The separately read temperature-offset setting retains the datasheet's own conversion. Both source versions and this discrepancy are recorded in the research evidence.

STARTUP means communication has not been established. WARMUP withholds current values during the first 60 seconds after a successful start. That is our observation policy; it is not a manufacturer promise that every enclosure or sensor becomes accurate at second 60. Gas response, thermal settling, installation and field calibration have their own behavior. A CRC-valid provisional result can be retained as labelled history while the current fields remain None, Python's explicit absence value.
VALID means the sample passed this program's format, CRC, operating-temperature, age and startup rules. It does not mean a reference instrument confirmed the concentration. STALE means no new accepted result has arrived for at least twelve seconds, including a sensor that responds to status requests but never produces its first measurement. The timeout allows more than two ordinary five-second update periods. It is application tolerance, not a datasheet error limit.
FAULT hides current values immediately after a bus, CRC or range error. RECOVERING requires two subsequent accepted updates before clearing the fault; an intervening error or another twelve-second gap resets that streak. Finishing recovery during the initial minute returns to WARMUP. The program retains last_received_ppm and received_age_ms for diagnosis, keeping them distinct from co2_ppm, which is populated only in VALID.
Age is calculated from a newly read, checked measurement frame, not from a screen refresh or a readiness reply. It is receipt age: the sensor does not transmit the age of the physical air or a wall-clock timestamp. A paused interpreter might find an old unread buffer waiting when it resumes. A loop gap longer than three seconds therefore triggers a fault and requires recovery rather than presenting that first buffered frame as trusted current data.
Install the named firmware, save the listing as scd41_freshness.py, then run import scd41_freshness; scd41_freshness.main() from the console. The REPL, read-evaluate-print loop, is the interactive Python prompt. Copying a file is not proof that the program executed successfully on the target. A downloadable copy and a blank worksheet are included for the experiment.
Download the complete program · Download the blank observation sheet
"""ESP32_GENERIC MicroPython 1.26.0; Adafruit SCD41 5190.
Host-tested protocol/state logic; no target execution or physical test claimed.
"""
ADDRESS = 0x62
WARMUP_MS = 60000 # Application observation policy, not sensor accuracy guarantee.
STALE_MS = 12000
MAX_POLL_GAP_MS = 3000
def crc8(data):
crc = 0xff
for byte in data:
crc ^= byte
for _ in range(8):
crc = ((crc << 1) ^ 0x31) & 0xff if crc & 0x80 else (crc << 1) & 0xff
return crc
def decode_words(frame, count):
if len(frame) != count * 3:
raise ValueError('short sensor frame')
result = []
for offset in range(0, len(frame), 3):
pair = frame[offset:offset + 2]
if crc8(pair) != frame[offset + 2]:
raise ValueError('CRC mismatch')
result.append((pair[0] << 8) | pair[1])
return result
def measurement(frame):
co2, raw_t, raw_rh = decode_words(frame, 3)
temp = -45.0 + 175.0 * raw_t / 65536.0
rh = 100.0 * raw_rh / 65536.0
if not 1 <= co2 <= 40000:
raise ValueError('CO2 output outside 1..40000 ppm')
if not -10 <= temp <= 60:
raise ValueError('temperature outside sensor operating range')
return co2, temp, rh
class SCD41:
def __init__(self, bus, sleep_ms):
self.bus, self.sleep_ms = bus, sleep_ms
def send(self, command, wait_ms=1):
data = bytes([command >> 8, command & 255])
if self.bus.writeto(ADDRESS, data) != len(data):
raise OSError('command not fully acknowledged')
self.sleep_ms(wait_ms)
def words(self, command, count):
self.send(command)
return decode_words(self.bus.readfrom(ADDRESS, count * 3), count)
def start(self):
# Full cold power-up assumed. A soft MCU reset may leave sensor running.
self.sleep_ms(50)
self.stop() # Establish idle; do not ignore a bus failure here.
serial = self.words(0x3682, 3)
asc = self.words(0x2313, 1)[0]
altitude = self.words(0x2322, 1)[0]
offset = self.words(0x2318, 1)[0] * 175.0 / 65535.0
self.send(0x21b1) # Standard periodic mode: one new result per 5 seconds.
pressure, warning = None, None
try:
pressure = self.words(0xe000, 1)[0]
except (OSError, ValueError) as exc:
# Older firmware may not support this optional metadata command.
warning = 'pressure setting unavailable: ' + str(exc)
return {'serial_words': serial, 'asc_enabled': asc,
'altitude_m': altitude, 'temperature_offset_C': offset,
'ambient_pressure_hPa': pressure, 'warning': warning}
def poll(self):
status = self.words(0xe4b8, 1)[0]
if (status & 0x07ff) == 0:
return None # A valid status reply is not a new measurement.
self.send(0xec05)
return measurement(self.bus.readfrom(ADDRESS, 9))
def stop(self):
self.send(0x3f86, 500)
class Monitor:
def __init__(self, warmup_ms=WARMUP_MS, stale_ms=STALE_MS):
if warmup_ms < 0 or stale_ms <= 0:
raise ValueError('invalid observation windows')
self.warmup_ms, self.stale_ms = warmup_ms, stale_ms
self.started = self.received = self.last = None
self.fault = None
self.recovering = False
self.streak = 0
def begin(self, now):
self.started = now
def fail(self, reason):
self.fault = str(reason)
self.recovering, self.streak = True, 0
def observe(self, now, sample=None):
# Classify missing data BEFORE accepting a newly obtained buffer.
anchor = self.received if self.received is not None else self.started
if anchor is not None and now - anchor >= self.stale_ms:
self.recovering, self.streak = True, 0
if sample is not None:
self.last, self.received = sample, now
if self.recovering:
self.streak += 1
if self.streak >= 2:
self.recovering, self.fault = False, None
return self.snapshot(now)
def snapshot(self, now):
age = None if self.received is None else now - self.received
anchor = self.received if self.received is not None else self.started
old = anchor is not None and now - anchor >= self.stale_ms
if self.fault is not None and self.streak == 0:
phase = 'FAULT'
elif old:
phase = 'STALE'
elif self.recovering:
phase = 'RECOVERING'
elif self.started is None:
phase = 'STARTUP'
elif self.last is None or now - self.started < self.warmup_ms:
phase = 'WARMUP'
else:
phase = 'VALID'
current = self.last if phase == 'VALID' else None
return {'state': phase,
'co2_ppm': None if current is None else current[0],
'temperature_C': None if current is None else current[1],
'relative_humidity_pct': None if current is None else current[2],
'received_age_ms': age,
'last_received_ppm': None if self.last is None else self.last[0],
'co2_in_specified_range': None if current is None else 400 <= current[0] <= 5000,
'fault': self.fault}
class TickClock:
"""Accumulate short ticks_diff intervals; call regularly, never after days asleep."""
def __init__(self, ticks_ms, ticks_diff):
self.ticks_ms, self.ticks_diff = ticks_ms, ticks_diff
self.previous, self.elapsed = ticks_ms(), 0
def __call__(self):
now = self.ticks_ms()
delta = self.ticks_diff(now, self.previous)
if delta < 0:
raise ValueError('ambiguous clock interval; restart observation')
self.previous = now
self.elapsed += delta
return self.elapsed
def run(sensor, clock, sleep_ms, emit=print, cycles=None):
monitor = Monitor()
last_clock, clock_failed = 0, False
def read_clock():
nonlocal last_clock, clock_failed
try:
last_clock = clock()
except ValueError:
clock_failed = True
raise
return last_clock
try:
emit(monitor.snapshot(read_clock()))
emit({'settings': sensor.start()})
monitor.begin(read_clock())
previous = read_clock()
iteration = 0
while cycles is None or iteration < cycles:
now = read_clock()
if now - previous > MAX_POLL_GAP_MS:
monitor.fail('POLL GAP: an old unread buffer may exist')
previous = now
try:
sample = sensor.poll()
except (OSError, ValueError) as exc:
monitor.fail(exc)
sample = None
record = monitor.observe(read_clock(), sample)
emit(record)
iteration += 1
sleep_ms(1000)
except (OSError, ValueError) as exc:
monitor.fail(exc)
record = monitor.snapshot(last_clock)
if clock_failed:
record['received_age_ms'] = None
record['clock_valid'] = False
emit(record)
finally:
try:
sensor.stop()
except OSError as exc:
emit({'stop_error': str(exc)})
return monitor
def main():
from machine import Pin, SoftI2C
from time import ticks_ms, ticks_diff, sleep_ms
bus = SoftI2C(sda=Pin(21), scl=Pin(22), freq=100000, timeout=20000)
clock = TickClock(ticks_ms, ticks_diff)
try:
run(SCD41(bus, sleep_ms), clock, sleep_ms)
except KeyboardInterrupt:
print('STOPPED: observation ended; no current measurement claimed')
if __name__ == '__main__':
main()
test_scd41_freshness.py from the research package under CPython. Its bus fake returns a valid “not ready” status, corrupted frames and disconnect errors to the actual driver. Its clock advances across the twelve-second boundary and an interpreter gap. These are deterministic software injections, not electrical measurements or a physical bus-glitch generator.scd41_freshness.main(); it does not silently recalibrate or endlessly issue reset commands.The blank CSV, comma-separated values, sheet has no invented measurements. Record receipt ages, state transitions and the retained history separately from current values. The host tests exercise the actual loop as well as helper functions; a successful test run does not establish that your supply stayed within limits or your gas reading is accurate.
ASC, automatic self-calibration, adjusts the sensor baseline using its operating history and suitable exposure conditions. The factory default is enabled. The October 2026 operation guide explains its reference-exposure requirements and recommends ordinary five-second periodic operation unless the application needs another mode. The program reports the existing ASC setting; it does not switch it, invent a baseline or assume that waiting sixty seconds completes calibration.
Altitude and ambient pressure compensation also matter; an existing nonzero pressure setting can override altitude compensation. The printed settings make that visible. The temperature offset concerns the installed sensor's thermal surroundings and its temperature/humidity outputs; it is not a knob for correcting an arbitrary CO₂ discrepancy. Establish the real installation conditions and a trustworthy reference before changing anything. This lesson sends neither forced recalibration nor factory reset nor persist_settings, which writes configuration to finite-endurance nonvolatile memory.
No. Display activity is independent of sensor progress. A status reply or repeated rendering must not update the receipt time. The previous number remains useful history with its real age; recovery requires new checked measurement frames. If an ordinary five-second stream is healthy, twelve seconds without an accepted update is already a reason to investigate it.
The clock accumulates short MicroPython ticks_diff intervals across counter wrap. That requires regular calls with intervals inside the API's unambiguous range; it is not a clock for a program blocked or asleep for days. An explicitly detected clock error ends observation with FAULT, no current value and unknown receipt age; reporting the fault does not read the suspect clock again. There is no battery backup, network transmission, certified enclosure or remote freshness protocol in this build. If you later publish samples, transmit their quality and age alongside the value and reject stale data at the receiver too.
Local tests check the CRC vector and every single-bit corruption of the sample frame, status-bit masking, command ordering, exact timeout boundaries, recovery interruptions, preserved history, actual loop fault paths and modular clock handling. The next evidence must come from the owner's bench: electrical supply checks, exact firmware execution and measurements compared under controlled conditions. Until then, the assembly and all claimed physical performance remain untested.
Review why a voltage source changes under load and the interactive electronics lessons before adding another sensor or a physical display.