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.

Verification: the delivered program has deterministic host tests against its actual protocol, state and application-loop code. The diagrams are original design documents. No ESP32 execution, firmware binary build, physical CO₂ measurement, calibration, or supply-performance test is claimed. Perform the experiments below on your own hardware and record actual results separately.

What this sensor measures

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.

Parts and an intentionally separate power path

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.

ESP32-DevKitC V4 IO21 and IO22 connect to Adafruit SCD41 SDA and SCL; a separate quiet 3.3 V supply powers VIN, grounds join, and both 3Vo outputs remain unconnected. Optional SDA switch creates a data fault.
Original functional connection diagram for the exact breakout. The two positive supply outputs are separate; ground is common. The breakout's own pullups, bypass and level translation are identified rather than replaced with an assumed bare-sensor circuit. Open the full-size diagram.

Every connection and the jumper erratum

ConnectionPurpose
ESP32 IO21, J3 pin 6 → breakout SDASerial 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 SCLSerial clock. The program uses a 100 kHz bus with short local wiring.
ESP32 GND, J3 pin 1 → breakout GND and sensor-supply negativeOne shared voltage reference. GND means ground; it is not the sensor's positive supply.
Separate regulated +3.3 V → breakout VINSensor-board power. Add the two local connector capacitors to ground.
ESP32 3V3 and breakout 3VoBoth are left unconnected in this arrangement. Do not join regulator outputs or feed external power into a pin described as an output.
Breakout factory jumpersKeep 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.

A new measurement is a specific transaction

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.

Source detail: why explicitly name the conversion convention?

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.

Give the states different meanings

Synthetic SCD41 display states distinguish STARTUP, a 60-second application WARMUP, VALID, immediate FAULT, two-frame RECOVERING, and STALE after twelve seconds without a new accepted frame.
Original software-state illustration. Timings are chosen application rules, not measured response, calibration results or health thresholds. Open the full-size state diagram.

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.

Complete MicroPython program

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()

A reproducible set of observations

  1. Inspect first. Record the exact board revision, sensor marking, jumper condition and supply. Verify power polarity and bus idle levels with the ESP32 signal wires disconnected. Turn both supplies off, make the final connections, close S1, and enable both supplies. Restart the program only after both supplies are present.
  2. Observe startup. Record the reported serial and settings. Expect STARTUP followed by WARMUP. New checked values should appear only in the labelled history during the first minute. If no sample ever arrives, expect STALE after twelve seconds rather than an endless reassuring warm-up message.
  3. Observe a steady interval. After VALID appears, note that receipt age rises between new measurements and drops when another measurement is accepted. Several consecutive equal CO₂ numbers can still be legitimate new samples: equality of value is not the definition of staleness. Keep the sensor away from your face, warm electronics and obstructed vents. Do not use exhaled breath as a calibration gas.
  4. Open the data path. With the optional switch installed beforehand and both supplies continuously present, open S1 for several seconds. Expect FAULT and no current CO₂ field. Close S1. A recoverable bus path should produce RECOVERING on the first accepted update and return to VALID after a second timely update. Record actual behavior; an interface fault can require a full restart. Stop and repair rather than repeatedly resetting a damaged circuit.
  5. Reproduce missing data and CRC errors on the host. Run the accompanying 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.
  6. Restart deliberately. Stop the program with Ctrl+C. Its cleanup requests stop-periodic and reports a failed stop separately. A new run begins a new observation window. If someone power-cycles only the sensor, it may return to idle and stop producing results: the display becomes stale. Stop the program, switch both supplies off, check the wiring, power both sides again and rerun 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.

Settings worth preserving before improving accuracy

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.

Challenge: can you cure STALE by printing the previous value every second?

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.

What remains unverified

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.

Primary sources and further reading

Review why a voltage source changes under load and the interactive electronics lessons before adding another sensor or a physical display.