TRICKY CIRCUITLocal review · design and software checks, no physical test

ARTICLE 75 · PRACTICAL EXPERIMENT

SGP40: Raw Gas Counts Are Not Air Quality in ppm

A gas sensor can return a sixteen-bit number without returning a gas concentration. This Pico experiment reads an exact SGP40 module, verifies the transfer, keeps the measurement cadence and records the raw signal honestly. There is no shortcut from one raw count to parts per million, a carbon-dioxide reading or a universal air-quality score. That boundary is the main lesson, not a missing multiplication constant.

What the sensing element measures

Volatile organic compounds (VOCs) are a broad group of gases. The SGP40 contains a heated metal-oxide (MOX) sensing element whose resistance responds to its environment. Its digital raw output is related to the logarithm of that resistance. It is not a selective analyser that identifies every compound and reports each concentration. Temperature, humidity, operating history and gas composition affect interpretation.

Sensirion's Gas Index Algorithm processes a history of raw samples into its VOC Index. That index has an adaptive reference and is different from an air-quality index (AQI), a regulatory score with its own definitions. The manufacturer describes an index reference around the preceding indoor history, not an absolute promise that 100 means clean or safe air. This lesson deliberately does not implement the algorithm. Every record leaves VOC_Index, gas_ppm and CO2_ppm unknown.

Use an original RP2040 Raspberry Pi Pico, MicroPython 1.26.0, and Adafruit product 4829, the SGP40 module populated with Sensirion SGP40-D-R4. The exact device uses a six-contact dual-flat-no-leads (DFN) package. The current official product-linked datasheet is version 1.2, February 2022, even though the download page's display label still says September 2021. The document itself, not that label, determines this review's revision.

Module support parts and the two chip supplies

Original Pico 3V3 OUT physical pin 36 feeds Adafruit SGP40 product 4829 Vin. Ground pin 38 joins GND, GP0 pin 1 joins SDA and GP1 pin 2 joins SCL. Factory regulator, decoupling and 10 kiloohm bus pull-ups remain; the module’s regulated output is open.
Original proposed module wiring at 3.3 V. The SGP40 hotplate is internal; no independent heater drive or invented humidity sensor is connected. Open full-size diagram.
Pico headerAdafruit 4829Purpose
3V3 OUT, physical 36Vin3.3 V module supply and bus reference
Ground, physical 38GNDShared return
GP0, physical 1SDAInter-Integrated Circuit data
GP1, physical 2SCLInter-Integrated Circuit clock

The Inter-Integrated Circuit (I²C) address is 0x59 in seven-bit form. Set the bus to 100 kHz. The breakout already provides a regulator, bus level shifting, decoupling and 10 kΩ pull-ups to its input supply. Keep those factory components fitted and leave its 3.3V output unconnected. It is an output, not another supply input. Power the Pico through its Universal Serial Bus (USB) connector only, with short wiring and no external five-volt bus.

The bare SGP40 pin functions are 1 VDD, 2 VSS ground, 3 SDA, 4 a grounded nonfunctional contact, 5 VDDH hotplate supply and 6 SCL. VDD and VDDH must share the same supported supply. The bare device's operating range is 1.7–3.6 V; it is not directly a five-volt part just because the breakout accepts a higher VIN. The manufacturer recommends local bypass capacitance for the bare design; this module already implements its support circuit. Do not duplicate or bypass it accidentally.

Place the sensor in normal dry indoor air, shielded from bright illumination and away from the Pico's warm components. Do not seal it inside an unventilated box and interpret that as room air. Avoid condensation, solvents and contamination. This experiment observes normal environmental changes; it does not require burning material or deliberately releasing chemicals. The electronics course explains supply and bus fundamentals before adding sensor interpretation.

A checked word, a self-test and a deliberate discard

All command words contain two bytes. The raw measurement command is hexadecimal 26 0F, followed by two parameter words and their checksums. The sensor returns two raw bytes, most significant first, and one cyclic redundancy check (CRC) byte. CRC uses polynomial 0x31, initial value 0xFF and no final exclusive-OR operation. The manufacturer test vector BEEF produces 92. Commands themselves do not receive an extra appended CRC byte.

The exact default packet is 26 0F 80 00 A2 66 66 93. The first parameter represents the default 50% relative humidity; the second represents 25 °C. Together, these defaults explicitly disable on-chip humidity compensation. They do not report measured room conditions. The program labels compensation false rather than claiming a temperature or humidity sensor that is absent from the circuit.

For a later compensated design, actual humidity and temperature values need their own valid measurements, ages and physical placement. Their encoding follows RH × 65535/100 and (T+45) × 65535/175, with a CRC after each word. A fixed value typed into a script is an assumption. Adding compensation without tracking that distinction can make data look more scientific while introducing an unobserved error.

The program first sends heater-off command 36 15 to establish an idle measurement session. It then runs built-in self-test 28 0E, waits the specified maximum 320 milliseconds and verifies the returned CRC. A passing test has high byte D4; the lower byte is not used for that pass decision. This checks the reported self-test outcome, not the gas calibration or authenticity of the installed component.

Each raw conversion waits 30 milliseconds, the documented maximum execution interval. Without using the Gas Index Algorithm, the datasheet instructs the reader to discard the first measured value. The code does exactly that and emits FIRST_SAMPLE_DISCARDED with no raw value. It never disguises the first result as a stable baseline. A new run performs the idle/self-test/start sequence and discards again.

Cadence and warm-up are separate requirements

Eight command bytes are grouped as command 26 0F, default humidity word with CRC, and default temperature word with CRC. A nominal timeline marks the first 30 millisecond conversion as discarded and a later conversion starting at 1000 milliseconds as a raw diagnostic. No concentration mapping is drawn.
Original protocol structure and nominal application timing. The narrow conversion bars show the 30 ms wait within a 1000 ms start-to-start period; these are calculated schedule markers, not measured bus or gas-response traces. Open full-size diagram.

One-second start-to-start cadence is a useful controlled setting for this raw experiment and is the required input cadence for the manufacturer's index algorithm. A conversion taking 30 milliseconds does not mean that the correct period is 30 milliseconds, nor that sleeping one second after all other work gives exactly one-second sample starts. The code measures work time and sleeps the remainder of one second.

A later interval outside 500–1500 milliseconds stops this run. The broad tolerance is an explicit logging policy, not a precision timing claim or permission to feed irregular samples into an algorithm expecting an exact cadence. A backwards clock also stops, and the loop has a bounded number of iterations. MicroPython's wrap-safe ticks_diff handles ordinary counter wrap. Actual serial printing, scheduler overhead and bus timing still need observation on the device.

Gas-sensing warm-up is not the same as digital power-up. The manufacturer's table distinguishes typical time to recognise an event from the longer time until its stated sensing specifications are met. A short successful bus transaction establishes neither. Record how long the sensor has operated and how it was stored. The supplied three-minute run is a raw-signal and protocol exercise; it does not claim a calibrated air-quality instrument after three minutes.

The SGP40 heater stays on between normal periodic raw measurements. Do not turn it off every second to save power and assume the same interpretation remains valid. The main program attempts heater-off in its final cleanup, including interrupted runs. If that command fails, it prints HEATER_OFF_NOT_CONFIRMED. Software cannot claim a hardware state from an unacknowledged command.

Full raw-signal program

Save the complete program as main.py or run it from the MicroPython read–evaluate–print loop (REPL), the interactive console. It includes the actual CRC and device commands, first-discard behaviour, sample scheduling and cleanup. There is no external gas-index library hidden behind a function name. Every successful later row is RAW_DIAGNOSTIC rather than a health recommendation.

"""SGP40 raw signal lab; original Pico / MicroPython1.26.0.
No VOC Index algorithm or gas concentration conversion is implemented.
"""

def crc8(data):
    crc=255
    for b in data:
        crc^=b
        for _ in range(8):crc=((crc<<1)^0x31)&255 if crc&128 else (crc<<1)&255
    return crc


def word(frame):
    if not isinstance(frame,(bytes,bytearray)) or len(frame)!=3:raise ValueError('Need exactly one word and CRC')
    if crc8(frame[:2])!=frame[2]:raise ValueError('CRC failed')
    return (frame[0]<<8)|frame[1]


def packet():
    # Manufacturer defaults explicitly DISABLE humidity compensation.
    return bytes.fromhex('260f8000a2666693')


class SGP40:
    def __init__(self,bus,sleep_ms):
        self.bus,self.sleep=bus,sleep_ms;self.started=False;self.first=True
    def send(self,data):
        if self.bus.writeto(0x59,data)!=len(data):raise OSError('Short command write')
    def off(self):
        self.started=False;self.first=True
        self.send(bytes.fromhex('3615'));self.sleep(1)
    def start(self):
        self.off()
        self.send(bytes.fromhex('280e'));self.sleep(320)
        tested=word(self.bus.readfrom(0x59,3))
        if tested>>8!=0xd4:raise ValueError('Device self-test failed')
        self.started=True;self.first=True
    def raw(self):
        if not self.started:raise ValueError('Start a qualified session first')
        try:
            self.send(packet());self.sleep(30)
            value=word(self.bus.readfrom(0x59,3))
        except BaseException:
            self.started=False;raise
        if self.first:
            self.first=False
            return None
        return value


def acquire(sensor,ticks_ms,ticks_diff,sleep_ms,emit=print,count=180):
    if type(count)is not int or not 1<=count<=3600:raise ValueError('Invalid bounded sample count')
    sensor.start();previous=None;start=ticks_ms()
    for i in range(count):
        now=ticks_ms();age=ticks_diff(now,start)
        if age<0 or (previous is not None and not 500<=ticks_diff(now,previous)<=1500):
            raise ValueError('Sampling cadence broken; start a separate run')
        value=sensor.raw()
        emit({'index':i,'controller_age_ms':age,'state':'FIRST_SAMPLE_DISCARDED' if value is None else 'RAW_DIAGNOSTIC',
              'raw_ticks':value,'humidity_compensation_enabled':False,
              'VOC_Index':None,'gas_ppm':None,'CO2_ppm':None})
        previous=now
        # Keep start-to-start cadence near one second, rather than adding conversion time.
        used=ticks_diff(ticks_ms(),now)
        if not 0<=used<=1000:raise ValueError('Acquisition overran one-second schedule')
        sleep_ms(1000-used)


def main():
    from machine import Pin,I2C
    from time import ticks_ms,ticks_diff,sleep_ms
    sensor=SGP40(I2C(0,sda=Pin(0),scl=Pin(1),freq=100000),sleep_ms)
    try:acquire(sensor,ticks_ms,ticks_diff,sleep_ms)
    except (OSError,ValueError)as error:print({'state':'SESSION_FAULT','raw_ticks':None,'error':str(error)})
    finally:
        try:sensor.off()
        except OSError as error:print({'state':'HEATER_OFF_NOT_CONFIRMED','error':str(error)})


if __name__=='__main__':main()

Record a signal without inventing its cause

  1. Inspect wiring without power. Record the exact module, firmware, supply, placement and time since power-up. Let handling effects settle in normal dry indoor air.
  2. Start one run and capture the self-test success implied by reaching acquisition. Confirm that the first row discards its raw value, compensation remains false, and all concentration/index fields remain unknown.
  3. Observe the stationary fixture. Record ordinary room events, such as a door opening, with an independent timestamp. A correlated raw change is an observation; it does not identify which gas caused it.
  4. Repeat a separate run after a documented change of placement or operating time. Do not join separated histories as if there were no idle interval. Keep the prior run's raw values and context intact.
  5. Stop with the keyboard and confirm the cleanup path does not report heater-off failure. After a bus fault, inspect power and wiring before starting another clearly identified run.

Use the blank comma-separated values (CSV) worksheet for controller age, independent elapsed time, raw ticks, state and observed context. Missing readings stay missing. A short reply, bad CRC or failed write invalidates the active sensor session; the code will not quietly retry and reuse an old successful reading. An I²C acknowledgment and a plausible number cannot prove the sensor's sample represents the room.

Challenge: the raw count changes by 500 ticks after a door opens. Can you print 500 ppm or declare the air safer? Answer: no. Ticks describe this sensor signal, and the gas mixture, humidity, operating history and event cause remain unspecified. Even the direction and meaning of a processed index require the correct documented algorithm and context. A carbon-dioxide concentration is a different measurement.

Twenty actual-code host checks exercise every sixteen-bit decoded word, both parameter checksums, the exact default packet, self-test result interpretation, first-sample discard, command waits, transfer faults, session invalidation, frozen and wrapped clocks, and actual acquisition output fields. They execute on a desktop interpreter with injected I²C and time objects. They are not a MicroPython target run, firmware build, gas exposure test, chemical calibration or validation of a VOC Index algorithm. No physical bench results are prefilled.

Primary documentation

No separate applicable erratum was located in the manufacturer's current product documentation during review; absence is not guaranteed. Check the exact board and current primary documentation before physical construction, and keep raw experimentation separate from air-quality decisions.