Indoor air-quality logger: a Raspberry Pi Pico 2 W reads CO₂, temperature and humidity from a Sensirion SCD41 over I²C and posts each measurement to a small Flask service, which stores it in SQLite.
The sensor driver is written directly against the datasheet rather than pulled from a library, so every command word, CRC check and timing constraint is visible in one file.
| Part | Notes |
|---|---|
| Raspberry Pi Pico 2 W | RP2350, running MicroPython |
| Sensirion SCD41 | photoacoustic NDIR CO₂ sensor, I²C address 0x62 |
Wiring uses the default I²C0 pins — SDA on GP0, SCL on GP1 — at 100 kHz.
The Pico connects to WiFi, brings the sensor to a known state, and then loops: read,
POST, sleep 60 s. The server timestamps each reading on arrival and appends it to a
readings table.
A few details are worth pointing out, because they are the difference between a demo and something that runs unattended for weeks.
Every word is CRC-checked. The SCD41 returns each 16-bit word followed by a CRC-8 byte
(polynomial 0x31, init 0xFF, no reflection, no final XOR — datasheet §3.11). All three
words of a measurement frame are validated, so a corrupted frame is rejected instead of
being silently used.
co2 == 0 is a sentinel, not a reading. The sensor reports zero when it cannot produce
a valid CO₂ figure — for example in a draught near an open window — even though temperature
and humidity remain good. Such samples are discarded and the read is retried, up to five
times per cycle, with 5 s between attempts because periodic mode produces a fresh sample
roughly every 5 s.
Settings can only be touched in idle mode. Periodic measurement is stopped before configuration is read or written, which is why startup begins with a stop command and a 500 ms wait.
Automatic self-calibration is verified, not assumed. On startup the ASC flag is read
back from the sensor; if it is off, it is enabled and persisted to EEPROM. persist_settings
is deliberately called only when the flag actually changed — the EEPROM is rated for about
2000 write cycles.
The SCD41 drifts, and there are two ways to correct it.
ASC (automatic self-calibration) assumes the sensor sees fresh outdoor air — about 420 ppm — at some point over a week, and quietly corrects itself towards that minimum. It is enabled by default here and needs no intervention, but it produces wrong results in a room that is never ventilated.
FRC (forced recalibration) corrects the sensor immediately against a known reference.
The full documented procedure (datasheet §3.7.1) is implemented in
run_forced_recalibration:
- run periodic measurement for more than three minutes in stable air of known CO₂ concentration,
- stop periodic measurement and wait 500 ms,
- issue the FRC command and read back the correction that was applied.
To use it, put the sensor in fresh outdoor air, set RUN_FRC_ON_START = True, power the
board, wait for the correction to be printed, then set the flag back to False. The routine
raises if the sensor reports 0xFFFF, which means the recalibration was rejected.
| File | Contents |
|---|---|
main.py |
Runs on the Pico: WiFi, SCD41 driver, calibration, measurement loop |
server.py |
Flask service — POST /api/scd41, appends to SQLite |
flash.sh |
Copies main.py to the board with mpremote |
install-mpremote.sh |
Installs mpremote on the host |
install-py-deps.sh |
Creates a virtualenv and installs Flask |
1 — Flash MicroPython. Hold BOOTSEL, plug the board in, and copy the .uf2 image onto
the drive that appears.
2 — Install the host tools.
./install-mpremote.sh
./install-py-deps.sh3 — Configure. In main.py set WIFI_SSID, WIFI_PASSWORD and POST_URL; in
server.py set LISTEN_HOST and LISTEN_PORT to match that URL.
⚠️ WiFi credentials live inmain.pyin plain text and are copied onto the board as part of the source. Do not publish the file, and do not commit real credentials.
4 — Start the server.
source venv/bin/activate
./server.py5 — Flash the board.
./flash.shmain.py runs automatically on boot. Attach a serial terminal to watch the log — the ASC
state, each reading and the HTTP status are printed there.
- The server binds to a fixed address on the local network, has no authentication and no TLS. It is intended for a private LAN only.
- There is no buffering on the device: if the server is unreachable, that measurement is lost rather than retried later.
- The stored data is not exposed anywhere — reading it back means querying
scd41.dbdirectly.