AirBox V1.9

AirBox v1.9 is a small prototype containing the ESP32-S2 and SCD30 essential components for ahead of time testing of sensor behaviour.

The ESP32 is programmed to mimic the data format used by AirBox V2 EduKit (Indoor only).
Due to having a constant USB-C power supply available, the ESP32-S2 does not necessarily need to go to sleep in between measurements.

Construction

The AirBox V1.9 will be housed inside a 3D printed plastic case, with 2 parts (lid and housing) and fastened using 4 screws.

Inside the box, the ESP32 and SCD30 have small fins that guide the component in its correct position.
It is especially important to have the ESP32 sit in the perfect position because any misalignment will causwe the USB-C connector to not fit.

The RGB led will be a WS2812 mounted on a round PCB. The front of the case contains the 5x5mm cutout for the RGB led, and the attached PCB can have any size as long as it fits in a 12mm circle.

Hotglue will be used to secure the components in place. Unlike epoxy resins and other stronger adehsives, hotglue is tolerant of mistakes. It can be removed by spraying with Isopropanol and applying a bit of force.

The case is fastened with 4x SHCS (DIN912) M2 8mm screws.

airbox-v19-4.png airbox-v19-1.png airbox-v19-2.png airbox-v19-3.png

ESP Pin mappings

The WS2812 RGB LED is powered from 3.3v.

The slow breathing animation has a 2 second cycle (off to full brightness and back). If the LED is dark and you expected it on, check that the device is powered.

Timeline

29 April 4 May
Designed case sourced components Finished assembly

SCD30 lessons learned (no hardware power-cycle available)

Notes from getting a Sensirion SCD30 to run reliably on a Wemos S2 Mini (ESP32-S2) with VDD permanently tied to 3.3 V - i.e. the only way to recover a wedged sensor is via I2C / firmware, not by toggling its power.

TL;DR

I2C bus configuration and init sequence

Wire.setClock(50000) and Wire.setTimeOut(500) (default ~50 ms on ESP32-S2 is too short for the SCD30's worst-case ~150 ms stretch during internal calibration). Set internal pullups via pinMode(..., INPUT_PULLUP) before Wire.begin() as belt-and-suspenders only; they are not a substitute for external 4.7 kOhm pullups.

GPIO8/9 worked fine on our S2 Mini. Some community threads report certain pins on S2/S3 cause hangs within minutes; if 50 kHz + pullups + stable sensor still wedges, try a different pin pair.

The init sequence that works in practice:

scd30.begin(Wire, SCD30_I2C_ADDR_61);
scd30.softReset();           // best-effort - may error on cold sensor; ignore
delay(3000);                 // Sensirion says >=2 s, 3 s is more reliable

err = scd30.setMeasurementInterval(2);            // must succeed
err = scd30.activateAutoCalibration(1);           // non-fatal - persists in NVM
err = scd30.setTemperatureOffset(300);            // 0.01 degC units, persists in NVM

for (int i = 0; i < 4; i++) {                     // start can fail first try
  err = scd30.startPeriodicMeasurement(0);
  if (err == 0) break;
  delay(300);
}

softReset() first clears any residual state from a previous boot where the MCU rebooted but the sensor kept running; if it errors here, that's fine - it just means the sensor was already idle. The 3 s post-reset delay is needed for the SCD30's command processor to come back. activateAutoCalibration sometimes errors with 270 on a cold sensor but the setting persists in NVM once it succeeds, so a later successful run sticks - don't abort init on this. The first startPeriodicMeasurement call commonly returns 270 immediately after init; subsequent attempts succeed, hence the retry. The argument is ambient pressure compensation in mbar; 0 disables it.

Error codes seen: 0 OK, 270 (0x010E) write/CRC error from sensor not ready or clock-stretch overrun, 523 read-category error from bus too slow / master timeout.

Sampling: do NOT cycle start/stop

We initially tried "on-demand" sampling - startPeriodicMeasurement(), poll dataReady, read, stopPeriodicMeasurement() - to save power. This reliably wedged the sensor within a few minutes with no recovery short of power-cycling.

Use continuous mode instead: start periodic measurement once in init, leave it running, just poll for new data when you want a sample. The sensor draws ~19 mA peak / ~6 mA average in this mode - tolerable cost for stability.

uint16_t ready = 0;
unsigned long t0 = millis();
while (millis() - t0 < MEAS_READY_TIMEOUT_MS) {
  if (scd30.getDataReady(ready) == 0 && ready) break;
  delay(50);
}
if (!ready) { /* dataReady timeout - consider error state if persistent */ }

float co2, temp, hum;
scd30.readMeasurementData(co2, temp, hum);

The first sample after init may report co2 == 0 - this is the sensor warming up, not an error. Skip the tick silently rather than logging an error.

Recovery without VDD control

When the sensor stops responding, try these in order. The ESP32-S2's I2C controller can wedge from a clock-stretch overrun even when the SCD30 itself is fine, so reset the master side first:

Wire.end();
delay(50);
pinMode(I2C_SDA, INPUT_PULLUP);
pinMode(I2C_SCL, INPUT_PULLUP);
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(50000);
Wire.setTimeOut(500);

If the sensor was still alive and only the master was wedged, the next scd30Present() ping returns 0 and you can re-run the init sequence. softReset() over I2C is the SCD30's strongest software recovery primitive; if the sensor's command processor responds, this reboots its firmware.

What you cannot do: ESP.restart() does not help if the SCD30 shares 3.3 V with the MCU - the sensor isn't power-cycled and stays wedged through the MCU reboot. No I2C-only command exists that matches a hardware power-cycle for the SCD30 (well-documented Sensirion firmware bug, see Ribbit Network issue #61, Adafruit thread). There is no reset pin on the SCD30 - only VDD/GND/SCL/SDA/SEL/RDY.

If the sensor is genuinely deadlocked, the device will sit in error state until the user power-cycles the whole board. This must be visible to the user - at minimum a fault LED pattern, ideally also a fault publish over MQTT before the bus dies. The firmware should also track time since last good sample, enter an error state if it exceeds a threshold (~60 s with a 20 s sampling cadence), keep retrying init in error state (sometimes the sensor recovers on its own after a calibration cycle finishes), and stop publishing stale readings while in error state.

Self-heating temperature offset

SCD30 dissipates ~30 mW continuously and reads ~1-4 degC above ambient depending on enclosure airflow. Apply correction with setTemperatureOffset(ticks) where ticks are in 0.01 degC units (e.g. 300 = 3.00 degC); the offset is subtracted from reported temperature and persists in the SCD30's NVM after a successful write. Set once, sticks. To clear, write 0.

The offset also corrects reported humidity automatically - the SCD30 derives RH% using the corrected temperature in its saturation-vapor-pressure calculation. (Humidity itself is measured by a separate physical sensor, not derived from temperature.)

Tuning: run device 30+ minutes at thermal equilibrium, compare reported temp to a trusted reference thermometer in the same air, set offset = reported - reference.

When to add a hardware reset

If you're spinning a new PCB and reliability matters, do not skip this: put the SCD30's VDD on a switchable rail. Either an N-channel low-side switch (or P-channel high-side) driven by an MCU GPIO, or - for a single sensor - drive VDD directly from a GPIO pin (ESP32 GPIOs source up to ~40 mA, enough for one SCD30; cheap and effective, only caveat is the GPIO must default high at boot and not be a strapping pin). Then in firmware: drop VDD low for 500 ms when the watchdog trips, raise it, re-init. This is the only mechanism that always recovers a wedged SCD30.

WiFi init for hidden SSIDs (ESP32-S2)

WiFi.begin(ssid, pass) on arduino-esp32 defaults to fast-scan, which only matches APs that broadcast their SSID in beacons. Hidden networks come back as reason=201 (NO_AP_FOUND) forever. The fix is to drive the connect via raw ESP-IDF with WIFI_ALL_CHANNEL_SCAN and never let WiFi.begin() overwrite the config afterwards. Also set the country code so the radio scans channels 12/13 - many home APs sit there to avoid congestion, and the default US firmware skips them silently.

#include <WiFi.h>
#include <esp_wifi.h>

void wifiApplyConfigAndConnect() {
  wifi_config_t cfg = {};
  strncpy((char*)cfg.sta.ssid,     WIFI_SSID,     sizeof(cfg.sta.ssid));
  strncpy((char*)cfg.sta.password, WIFI_PASSWORD, sizeof(cfg.sta.password));
  cfg.sta.scan_method        = WIFI_ALL_CHANNEL_SCAN;
  cfg.sta.sort_method        = WIFI_CONNECT_AP_BY_SIGNAL;
  cfg.sta.threshold.rssi     = -127;
  cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;   // tighten if WPA3 mixed
  cfg.sta.pmf_cfg.capable    = true;
  cfg.sta.bssid_set          = 0;
  esp_wifi_disconnect();
  esp_wifi_set_config(WIFI_IF_STA, &cfg);
  esp_wifi_connect();
}

void wifiBegin() {
  WiFi.mode(WIFI_STA);                 // starts the WiFi driver
  WiFi.setHostname(HOSTNAME);
  WiFi.setAutoReconnect(false);        // we drive reconnects ourselves
  WiFi.persistent(false);
  WiFi.setSleep(WIFI_PS_NONE);         // S2 default modem-sleep is unstable
  WiFi.setTxPower(WIFI_POWER_19_5dBm); // S2 PCB antenna is poor; max it

  wifi_country_t country = {
    .cc = "01", .schan = 1, .nchan = 13, .policy = WIFI_COUNTRY_POLICY_MANUAL
  };
  esp_wifi_set_country(&country);

  wifiApplyConfigAndConnect();
}

Both initial connect and any retry must call wifiApplyConfigAndConnect(), not WiFi.begin(), because WiFi.begin() resets the scan method back to fast-scan. setAutoReconnect(false) keeps the IDF from racing your retries with its own (silent) attempts that would use stale config. An all-channel scan on 2.4 GHz takes ~1.5-3 s, so the first connect attempt is noticeably slower than fast-scan - drive a startup LED indicator from a separate FreeRTOS task so the UI stays alive while the radio scans.

SCD30 Firmware issues

The SCD30 sensor's firmware will lock up after a 3-6 hours of operation, regardless if it is being read or not. Thus, the airbox needs to be reset after some hours, when the LED inveitably blinks purple.

To address this, in the next version (V2) we will add a P-MOS on the sensor's VCC rail to power cycle the sensor automaticallly when it fails.

Lifetime

Due to the above functonality-breaking bug, V1.9 prototype enters End of Life as of 7 May 2026.

V1.9 was instrumetal to designing the AirBox V2 with manufacturing and reliability in mind.
Many of the lessons learned on V1.9 have served to bring a smooth development process for V2.

References