Parts list
| Part | Qty | Approx. | Buy |
|---|---|---|---|
| ESP32-C3 SuperMini | 1 | ₹230 | Buy ESP32-C3 SuperMini |
| SGP30 TVOC / eCO2 gas sensor module (with regulator) | 1 | ₹750 | Buy SGP30 TVOC / eCO2 gas sensor module (with regulator) |
| WS2812B LED strip, 8 LEDs (or an 8-LED stick) | 1 | ₹120 | Buy WS2812B LED strip, 8 LEDs (or an 8-LED stick) |
| 74AHCT125 quad buffer (DIP-14) | 1 | ₹35 | Buy 74AHCT125 quad buffer (DIP-14) |
| 330 Ω resistor + 470 µF 10 V capacitor | 1 | ₹10 | Buy 330 Ω resistor + 470 µF 10 V capacitor |
| Mini breadboard + jumper wires | 1 | ₹90 | Buy Mini breadboard + jumper wires |
| 5 V 1 A USB adapter + USB-C cable | 1 | ₹150 | Buy 5 V 1 A USB adapter + USB-C cable |
| Total | ₹1,385 |
Affiliate disclosure: “Buy” links may earn FlashReady a small commission at no extra cost to you. Prices are indicative.
Wiring
| ESP32-C3 SuperMini | Part · pin | Wire | Notes |
|---|---|---|---|
| 7 | SGP30 TVOC / eCO2 gas sensor module · SCL | yellow | GPIO7 |
| 6 | SGP30 TVOC / eCO2 gas sensor module · SDA | blue | GPIO6 — keeps I2C off strapping pins 8 and 9 |
| 3V3 | SGP30 TVOC / eCO2 gas sensor module · VIN | red | The module’s regulator and shifters take 3.3 V |
| GND | SGP30 TVOC / eCO2 gas sensor module · GND | black | |
| 10 | WS2812B addressable RGB LED strip (NeoPixel) · DIN | green | Via 74AHCT125 buffer + 330 Ω — GPIO10 → 74AHCT125 1A; 1Y → 330 Ω → DIN of the first LED. Buffer VCC = 5V, 1OE = GND, GND = GND |
| 5V | WS2812B addressable RGB LED strip (NeoPixel) · 5V | red | 5 V straight from USB; 470 µF across 5V/GND at the strip |
| GND | WS2812B addressable RGB LED strip (NeoPixel) · GND | black |
Board quirks: The SGP30 measures total volatile organic compounds (TVOC) and calculates an “equivalent CO2” from them. It does not detect LPG reliably, carbon monoxide or PM2.5 smoke particles — for an LPG leak build the gas leak alarm with an MQ-6, and never rely on this for safety. It learns a baseline: the first 12 hours of running are a learning period. After that the sketch saves the baseline to flash every hour, so a power cut doesn’t restart the learning (if the board is off for more than a week, it should relearn). The bands (green under 220 ppb, yellow to 660, orange to 2200, red above) follow the German UBA indoor guidance as commonly quoted. They are a guide to “ventilate now”, not a health limit. The bar is kept at brightness 40 of 255, so eight LEDs draw well under 200 mA — fine from the SuperMini’s 5V pin on a 1 A USB adapter.
Code
// Kitchen air quality monitor — ESP32-C3 SuperMini + SGP30 (TVOC/eCO2) + 8-LED WS2812B bar
// SGP30: SDA GPIO6, SCL GPIO7, VIN 3V3 (I2C kept off strapping pins 8/9).
// WS2812B: DIN from GPIO10 through one 74AHCT125 gate (5 V) and a 330 Ω resistor; 5V from the
// SuperMini's 5V pin (USB). The bar fills and changes colour with total VOCs: cooking smoke,
// agarbatti, mosquito coils, floor cleaner, paint. Plain Wire commands, no extra library.
#include <Wire.h>
#include <Preferences.h>
#include <Adafruit_NeoPixel.h>
constexpr uint8_t SDA_PIN = 6, SCL_PIN = 7, LED_PIN = 10;
constexpr uint8_t NUM_LEDS = 8;
constexpr uint8_t SGP30 = 0x58;
constexpr uint8_t BRIGHTNESS = 40; // 0–255; keeps the strip's current low on USB power
Adafruit_NeoPixel bar(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800);
Preferences prefs;
unsigned long lastMeasure = 0, lastPrint = 0, lastBaseline = 0;
uint8_t crc8(const uint8_t* d, uint8_t len) { // Sensirion CRC: poly 0x31, init 0xFF
uint8_t crc = 0xFF;
for (uint8_t i = 0; i < len; i++) {
crc ^= d[i];
for (uint8_t b = 0; b < 8; b++) crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : crc << 1;
}
return crc;
}
bool sgpCommand(uint16_t cmd, const uint16_t* args = nullptr, uint8_t nArgs = 0) {
Wire.beginTransmission(SGP30);
Wire.write((uint8_t)(cmd >> 8));
Wire.write((uint8_t)(cmd & 0xFF));
for (uint8_t i = 0; i < nArgs; i++) {
uint8_t w[2] = {(uint8_t)(args[i] >> 8), (uint8_t)(args[i] & 0xFF)};
Wire.write(w, 2);
Wire.write(crc8(w, 2));
}
return Wire.endTransmission() == 0;
}
bool sgpRead(uint16_t* out, uint8_t n) {
if (Wire.requestFrom(SGP30, (uint8_t)(n * 3)) != n * 3) return false;
for (uint8_t i = 0; i < n; i++) {
uint8_t w[2] = {(uint8_t)Wire.read(), (uint8_t)Wire.read()};
if (crc8(w, 2) != Wire.read()) return false;
out[i] = (w[0] << 8) | w[1];
}
return true;
}
// Rough bands for total VOCs (ppb), after the German UBA indoor guidance.
uint32_t bandColour(uint16_t tvoc) {
if (tvoc < 220) return bar.Color(0, 255, 0); // good
if (tvoc < 660) return bar.Color(255, 200, 0); // moderate: ventilate
if (tvoc < 2200) return bar.Color(255, 80, 0); // poor: open windows, chimney on
return bar.Color(255, 0, 0); // unhealthy
}
const char* bandName(uint16_t tvoc) {
return tvoc < 220 ? "good" : tvoc < 660 ? "moderate" : tvoc < 2200 ? "poor" : "unhealthy";
}
void showBar(uint16_t tvoc) {
// 0–2200 ppb across 8 LEDs; at least one LED always lit so you can see it's working.
uint8_t lit = constrain(1 + (long)tvoc * (NUM_LEDS - 1) / 2200, 1, NUM_LEDS);
uint32_t c = bandColour(tvoc);
for (uint8_t i = 0; i < NUM_LEDS; i++) bar.setPixelColor(i, i < lit ? c : 0);
bar.show();
}
void setup() {
Serial.begin(115200);
bar.begin();
bar.setBrightness(BRIGHTNESS);
bar.fill(bar.Color(0, 0, 255)); // blue = warming up
bar.show();
Wire.begin(SDA_PIN, SCL_PIN);
if (!sgpCommand(0x2003)) Serial.println("SGP30 not found at 0x58 — check SDA GPIO6, SCL GPIO7 and 3V3."); // iaq_init
delay(10);
prefs.begin("sgp30", false);
uint16_t eco2Base = prefs.getUShort("eco2", 0), tvocBase = prefs.getUShort("tvoc", 0);
if (eco2Base && tvocBase) {
uint16_t args[2] = {tvocBase, eco2Base}; // set_iaq_baseline takes TVOC first, then eCO2
sgpCommand(0x201E, args, 2);
Serial.printf("Restored baseline eCO2=0x%04X TVOC=0x%04X\n", eco2Base, tvocBase);
} else {
Serial.println("No saved baseline: the first 12 hours are a learning period.");
}
Serial.println("tvoc_ppb,eco2_ppm,band");
}
void loop() {
if (millis() - lastMeasure < 1000) return; // the SGP30 wants exactly one measurement a second
lastMeasure = millis();
uint16_t v[2];
if (!sgpCommand(0x2008)) return; // measure_iaq
delay(12);
if (!sgpRead(v, 2)) {
Serial.println("SGP30 read failed");
return;
}
uint16_t eco2 = v[0], tvoc = v[1];
if (millis() < 15000) return; // the first 15 s always read 400 ppm / 0 ppb
showBar(tvoc);
if (millis() - lastPrint >= 10000) {
lastPrint = millis();
Serial.printf("%u,%u,%s\n", tvoc, eco2, bandName(tvoc));
}
// After 12 h of running, save the learned baseline every hour so a power cut doesn't lose it.
if (millis() > 12UL * 3600UL * 1000UL && millis() - lastBaseline > 3600UL * 1000UL) {
lastBaseline = millis();
uint16_t base[2];
if (sgpCommand(0x2015) && (delay(10), sgpRead(base, 2))) { // get_iaq_baseline: eCO2, TVOC
prefs.putUShort("eco2", base[0]);
prefs.putUShort("tvoc", base[1]);
Serial.println("Baseline saved");
}
}
}// Kitchen air quality monitor — ESP32-C3 SuperMini + SGP30 (TVOC/eCO2) + 8-LED WS2812B bar
// SGP30: SDA GPIO6, SCL GPIO7, VIN 3V3 (I2C kept off strapping pins 8/9).
// WS2812B: DIN from GPIO10 through one 74AHCT125 gate (5 V) and a 330 Ω resistor; 5V from the
// SuperMini's 5V pin (USB). The bar fills and changes colour with total VOCs: cooking smoke,
// agarbatti, mosquito coils, floor cleaner, paint. Plain Wire commands, no extra library.
#include <Wire.h>
#include <Preferences.h>
#include <Adafruit_NeoPixel.h>
constexpr uint8_t SDA_PIN = 6, SCL_PIN = 7, LED_PIN = 10;
constexpr uint8_t NUM_LEDS = 8;
constexpr uint8_t SGP30 = 0x58;
constexpr uint8_t BRIGHTNESS = 40; // 0–255; keeps the strip's current low on USB power
Adafruit_NeoPixel bar(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800);
Preferences prefs;
unsigned long lastMeasure = 0, lastPrint = 0, lastBaseline = 0;
uint8_t crc8(const uint8_t* d, uint8_t len) { // Sensirion CRC: poly 0x31, init 0xFF
uint8_t crc = 0xFF;
for (uint8_t i = 0; i < len; i++) {
crc ^= d[i];
for (uint8_t b = 0; b < 8; b++) crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : crc << 1;
}
return crc;
}
bool sgpCommand(uint16_t cmd, const uint16_t* args = nullptr, uint8_t nArgs = 0) {
Wire.beginTransmission(SGP30);
Wire.write((uint8_t)(cmd >> 8));
Wire.write((uint8_t)(cmd & 0xFF));
for (uint8_t i = 0; i < nArgs; i++) {
uint8_t w[2] = {(uint8_t)(args[i] >> 8), (uint8_t)(args[i] & 0xFF)};
Wire.write(w, 2);
Wire.write(crc8(w, 2));
}
return Wire.endTransmission() == 0;
}
bool sgpRead(uint16_t* out, uint8_t n) {
if (Wire.requestFrom(SGP30, (uint8_t)(n * 3)) != n * 3) return false;
for (uint8_t i = 0; i < n; i++) {
uint8_t w[2] = {(uint8_t)Wire.read(), (uint8_t)Wire.read()};
if (crc8(w, 2) != Wire.read()) return false;
out[i] = (w[0] << 8) | w[1];
}
return true;
}
// Rough bands for total VOCs (ppb), after the German UBA indoor guidance.
uint32_t bandColour(uint16_t tvoc) {
if (tvoc < 220) return bar.Color(0, 255, 0); // good
if (tvoc < 660) return bar.Color(255, 200, 0); // moderate: ventilate
if (tvoc < 2200) return bar.Color(255, 80, 0); // poor: open windows, chimney on
return bar.Color(255, 0, 0); // unhealthy
}
const char* bandName(uint16_t tvoc) {
return tvoc < 220 ? "good" : tvoc < 660 ? "moderate" : tvoc < 2200 ? "poor" : "unhealthy";
}
void showBar(uint16_t tvoc) {
// 0–2200 ppb across 8 LEDs; at least one LED always lit so you can see it's working.
uint8_t lit = constrain(1 + (long)tvoc * (NUM_LEDS - 1) / 2200, 1, NUM_LEDS);
uint32_t c = bandColour(tvoc);
for (uint8_t i = 0; i < NUM_LEDS; i++) bar.setPixelColor(i, i < lit ? c : 0);
bar.show();
}
void setup() {
Serial.begin(115200);
bar.begin();
bar.setBrightness(BRIGHTNESS);
bar.fill(bar.Color(0, 0, 255)); // blue = warming up
bar.show();
Wire.begin(SDA_PIN, SCL_PIN);
if (!sgpCommand(0x2003)) Serial.println("SGP30 not found at 0x58 — check SDA GPIO6, SCL GPIO7 and 3V3."); // iaq_init
delay(10);
prefs.begin("sgp30", false);
uint16_t eco2Base = prefs.getUShort("eco2", 0), tvocBase = prefs.getUShort("tvoc", 0);
if (eco2Base && tvocBase) {
uint16_t args[2] = {tvocBase, eco2Base}; // set_iaq_baseline takes TVOC first, then eCO2
sgpCommand(0x201E, args, 2);
Serial.printf("Restored baseline eCO2=0x%04X TVOC=0x%04X\n", eco2Base, tvocBase);
} else {
Serial.println("No saved baseline: the first 12 hours are a learning period.");
}
Serial.println("tvoc_ppb,eco2_ppm,band");
}
void loop() {
if (millis() - lastMeasure < 1000) return; // the SGP30 wants exactly one measurement a second
lastMeasure = millis();
uint16_t v[2];
if (!sgpCommand(0x2008)) return; // measure_iaq
delay(12);
if (!sgpRead(v, 2)) {
Serial.println("SGP30 read failed");
return;
}
uint16_t eco2 = v[0], tvoc = v[1];
if (millis() < 15000) return; // the first 15 s always read 400 ppm / 0 ppb
showBar(tvoc);
if (millis() - lastPrint >= 10000) {
lastPrint = millis();
Serial.printf("%u,%u,%s\n", tvoc, eco2, bandName(tvoc));
}
// After 12 h of running, save the learned baseline every hour so a power cut doesn't lose it.
if (millis() > 12UL * 3600UL * 1000UL && millis() - lastBaseline > 3600UL * 1000UL) {
lastBaseline = millis();
uint16_t base[2];
if (sgpCommand(0x2015) && (delay(10), sgpRead(base, 2))) { // get_iaq_baseline: eCO2, TVOC
prefs.putUShort("eco2", base[0]);
prefs.putUShort("tvoc", base[1]);
Serial.println("Baseline saved");
}
}
}Compiled for c3supermini on esp32 3.3.12 with Adafruit NeoPixel 1.15.5 · 3 Nov 2026
How it works
Indian kitchens produce a lot of airborne chemicals: the smoke from a hot tadka, frying, burnt milk, and in the rest of the home agarbatti, dhoop, mosquito coils and liquid vaporisers, phenyl and floor cleaners, fresh paint and new furniture. Most of these are volatile organic compounds (VOCs). This light bar shows the total at a glance, so you know when the chimney or a window is needed — and when it’s safe to switch them off.
How it works
The SGP30 contains four tiny heated metal-oxide sensing elements. VOCs react on their hot surface and change their resistance; the chip’s own processor turns that into TVOC in ppb and an estimated eCO2 in ppm. It needs one measurement command every second, exactly, because its on-chip algorithm tracks a slowly moving baseline.
The sketch talks to it with plain Wire commands (no library): iaq_init at power-up, then measure_iaq once a second. Every reply word carries a CRC byte, which the sketch checks. The first 15 seconds always read 400 ppm / 0 ppb, so the bar stays blue until real values arrive.
Then the 8-LED WS2812B bar fills in proportion to TVOC (0–2200 ppb across eight LEDs, at least one LED lit so you can see it’s alive) and takes the colour of the band:
| TVOC (ppb) | Colour | Meaning |
|---|---|---|
| under 220 | green | good |
| 220–659 | yellow | moderate — ventilate |
| 660–2199 | orange | poor — chimney on, open a window |
| 2200 and more | red | unhealthy level of fumes |
Every 10 seconds a CSV line goes to Serial.
Baseline that survives power cuts
The SGP30 keeps comparing against the cleanest air it has seen. After 12 hours of running the sketch reads that baseline from the chip every hour and stores it with Preferences in the C3’s flash. At the next boot it writes it back (TVOC first, then eCO2, as the datasheet orders them), so a power cut doesn’t mean another 12-hour learning period.
Why the buffer chip
A WS2812B powered from 5 V wants a data signal of at least about 3.5 V; the C3’s 3.3 V output is just under. One gate of a 74AHCT125, powered from 5 V, turns the 3.3 V signal into a clean 5 V one, and the 330 Ω resistor at the strip end tames ringing on the data wire. Use GPIO10 for data: GPIO8 and 9 are strapping pins on the SuperMini, and 6/7 carry the I2C bus.
Mount the sensor at head height near the kitchen doorway, not right above the stove — steam and splashes shorten its life.
Troubleshooting
The bar stays blue
Blue is the 15-second warm-up. If it stays blue, the sketch can’t read the sensor: check SDA on GPIO6, SCL on GPIO7 and the Serial line “SGP30 not found”.
The LEDs flicker or show random colours
The data signal isn’t clean: check the 74AHCT125’s 1OE pin is grounded and its VCC is 5 V, and keep the wire from 1Y to DIN short. A missing common GND causes the same.
It reads high all the time in a clean room
Normal for the first hours and after a move. Give it a day with the windows open at some point so it learns what “clean” is.
Upgrades
- Add an AHT20 and send the absolute humidity to the SGP30 for better accuracy.
- Switch the kitchen exhaust fan with the Wi-Fi relay when the bar reaches orange.
- Add an MQ-6 for LPG with its own buzzer — a separate safety alarm.



