M5Stack CoreS3 CO2 monitor with the CO2L Unit (SCD41): setup, drift vs a SwitchBot meter, and forced recalibration
Contents

Following the microSD troubleshooting, I hooked a CO2 sensor up to the M5Stack CoreS3 and built a room CO2 monitor. The sensor is M5Stack’s official CO2L Unit, which carries a Sensirion SCD41 and also reports temperature and humidity.
Same host as last time: Windows 11 + arduino-cli 1.5.1 + esp32 core 3.3.10. Libraries: M5Unified 0.2.17 plus M5Unit-ENV 1.5.0.
The CO2L Unit (SCD41)
Also bought at Marutsu in Akihabara.

The bag contains the unit and one Grove cable.

The label on the back says “SCD41 (SINGLE SHOT) / PORT A I2C / SCL SDA 5V GND”. So it goes on Port A and speaks I2C.

The SCD41 is a photoacoustic CO2 sensor. Key specs:
| Item | Value |
|---|---|
| CO2 output range | 0-40000 ppm |
| Specified range | 400-5000 ppm |
| CO2 accuracy | ±50ppm ±2.5% m.v. to ±40ppm ±5.0% m.v. (range dependent) |
| CO2 response time (τ63%) | 60 s |
| RH accuracy | ±6 %RH (0-95 %RH) |
| Temperature accuracy | ±0.8 °C |
| Supply voltage | 2.4-5.5 V |
| Supply current | 15 mA avg / 205 mA max |
| Operating temperature | -10 to 60 °C |
Two things set it apart from the SCD40: the specified measurement range is 400-5000 ppm versus the SCD40’s 400-2000 ppm, and it adds single-shot measurement support (intermittent, lower power) — the higher-end part of the pair. This room once clocked 3000 ppm, so the wider range was the deciding factor. An SCD40 would have pegged at its ceiling.
Which port is Port A
The CoreS3 has three similar-looking connectors. Port A is the red one on the side of the core unit, next to the USB-C port.
| Port | Color | Location | Bus |
|---|---|---|---|
| Port A | Red | Side of the core (next to USB-C) | I2C |
| Port B | Black | Back of the DIN BASE | GPIO |
| Port C | Blue | Back of the DIN BASE | UART |

Instead of hardcoding the I2C pins, M5Unified can tell you:
int sda = M5.Ex_I2C.getSDA(); // G2 on the CoreS3
int scl = M5.Ex_I2C.getSCL(); // G1 on the CoreS3
On the CoreS3 this returns SDA=G2, SCL=G1. Printing the pins on the boot screen turned out handy when second-guessing the wiring.

The sketch
M5Unit-ENV ships an SCD4X class (based on SparkFun’s SCD4x library). The sketch boots without the sensor and probes for it once a second, so the unit can be plugged in at any time.
#include <M5Unified.h>
#include "M5UnitENV.h"
SCD4X scd4x(SCD4x_SENSOR_SCD41);
bool sensorFound = false;
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
Serial.begin(115200);
M5.Display.setTextSize(2);
M5.Display.println("searching sensor...");
}
void loop() {
M5.update();
if (!sensorFound) {
if (scd4x.begin(&Wire, SCD4X_I2C_ADDR,
M5.Ex_I2C.getSDA(), M5.Ex_I2C.getSCL(), 400000U)) {
sensorFound = true;
scd4x.stopPeriodicMeasurement();
scd4x.startPeriodicMeasurement();
} else {
delay(1000);
}
return;
}
if (scd4x.update()) { // true when a fresh reading arrives (every 5 s)
uint16_t co2 = scd4x.getCO2();
float temp = scd4x.getTemperature();
float hum = scd4x.getHumidity();
// draw to screen (color by CO2: <1000 green, <1500 yellow, else red)
}
delay(1000);
}
Measurements run in periodic mode at 5-second intervals.
update() returns true only when new data lands.
The display shows the CO2 value large, colored green below 1000 ppm, yellow below 1500, red above. 1000 ppm is the ventilation guideline figure in Japan’s building sanitation standard.
Plug the Grove cable into the red Port A and the sensor is found within seconds.

1697 ppm in red, right away. The room was not that bad — my own breath was blowing straight onto the sensor while I worked at the desk. The SCD41 easily spikes to 1500-2000 ppm from close-range exhalation.
The numbers disagree with a SwitchBot meter
To sanity-check the values I put it next to a SwitchBot CO2 meter (a consumer NDIR device).
The first comparison was M5Stack 1697 ppm vs SwitchBot 533 ppm — alarming until I noticed it was not a fair fight. The SwitchBot screen said “28 minutes ago”: a stale reading versus a live breath spike. Pressing the SwitchBot’s physical button forces a fresh measurement, so all later comparisons used that.

With both devices sitting together, nobody breathing at them, and the SwitchBot freshly re-measured:
| CO2 | Temp | RH | |
|---|---|---|---|
| SwitchBot (reference) | 748 ppm | 28.9°C | 62% |
| CoreS3 + SCD41 | 976 ppm | 28.4°C | 81.6% |
That is +228 ppm (about +30%) on CO2 and +20 points on humidity. Even the loosest accuracy spec (±40ppm ±5.0% m.v., which is ±89 ppm at 976 ppm) does not cover this, and RH is far outside ±6 %RH. A factory-fresh SCD41 has never run its automatic self-calibration (ASC), and this kind of initial offset is apparently common. ASC assumes the sensor sees fresh outdoor-level air (~400 ppm) regularly over a week, so it does nothing right away.
Forced recalibration (FRC) against the SwitchBot
For an immediate fix there is FRC (Forced Recalibration).
You feed it a reference CO2 concentration and it rewrites the internal offset so the current reading matches. In the SCD4X library that is performForcedRecalibration().
The textbook way is outdoor air (~400-420 ppm), but I had a live NDIR reading sitting right next to it, so the SwitchBot value became the reference.
FRC has two procedural constraints:
- Run periodic measurement for at least 3 minutes beforehand
- Stop measurement before executing (
stopPeriodicMeasurement()→ FRC → restart)
I added a serial command to the sketch so the PC can trigger it with frc 1069:
// in loop(): "frc 1069" over serial runs forced recalibration
if (Serial.available()) {
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd.startsWith("frc ")) {
uint16_t ref = cmd.substring(4).toInt();
scd4x.stopPeriodicMeasurement(); // FRC requires measurement stopped
delay(600);
float correction = 0;
bool ok = scd4x.performForcedRecalibration(ref, &correction);
Serial.printf("FRC %s, correction=%.1f ppm\n", ok ? "OK" : "FAILED", correction);
scd4x.startPeriodicMeasurement();
}
}
The SwitchBot read 1069 ppm after a button refresh, so that became the reference:
FRC start: ref=1069 ppm
FRC OK, correction=73.0 ppm
CO2: 1171 ppm, Temp: 34.3 C, Hum: 60.1 %
CO2: 1089 ppm, Temp: 33.9 C, Hum: 61.1 %
CO2: 1057 ppm, Temp: 33.6 C, Hum: 62.0 %
CO2: 1016 ppm, Temp: 33.4 C, Hum: 62.7 %
After FRC the readings track the SwitchBot. Humidity moved from 81% to around 62%, close to the SwitchBot’s 66%. The 34°C temperature is a transient right after the measurement restart and settles back down within minutes.