1.Introduction
Imagine building a low-cost electronic nose capable of distinguishing different smells using just an IoT board and a single gas sensor. This project demonstrates how we turned the Bug in Circuit IoT board (ESP32) into an E-Nose capable of classifying coffee and ethanol aromas using the BME680 environmental sensor.
The project is ideal for hobbyists, makers, and students who want to explore gas sensing, machine learning, and IoT integration with a simple yet powerful hardware platform.
👉 The Bug in Circuit kit is currently LIVE on Kickstarter!
If you love hands-on IoT projects, support the campaign and grab your kit:
Bug in Circuit is a unique IoT debugging kit based on the ESP32 microcontroller. It is designed to help students and makers develop strong troubleshooting skills while building IoT applications. For this project, the same board is repurposed as the core of our E-Nose system.
3. How it Works4.BME680 Sensor: The Bosch BME680 sensor measures gas resistance alongside temperature, humidity, and pressure. Different gases affect the sensor resistance differently, giving us a unique “smell fingerprint.”
- BME680 Sensor: The Bosch BME680 sensor measures gas resistance alongside temperature, humidity, and pressure. Different gases affect the sensor resistance differently, giving us a unique “smell fingerprint.”
5.Heater Profiling: By modulating the heater temperature, the sensor responds differently to ethanol vapors vs. coffee aromas.
- Heater Profiling: By modulating the heater temperature, the sensor responds differently to ethanol vapors vs. coffee aromas.
6.Feature Extraction: Gas resistance changes are processed to calculate steady-state values, slopes, and ratios across the heater steps.
- Feature Extraction: Gas resistance changes are processed to calculate steady-state values, slopes, and ratios across the heater steps.
7.Classification: A lightweight classifier compares the features to pre-trained profiles (coffee vs ethanol) and determines the sample.
- Classification: A lightweight classifier compares the features to pre-trained profiles (coffee vs ethanol) and determines the sample.
Detect and classify aromas (Coffee vs Ethanol)
- Detect and classify aromas (Coffee vs Ethanol)
Uses a single BME680 sensor with ESP32
- Uses a single BME680 sensor with ESP32
Lightweight on-device classification (no cloud needed)
- Lightweight on-device classification (no cloud needed)
Easy to expand for more classes (tea, acetone, etc.)
- Easy to expand for more classes (tea, acetone, etc.)
Simple training: collect data with labeled samples and update centroids
- Simple training: collect data with labeled samples and update centroids
Open-source firmware for customization
- Open-source firmware for customization
Bug in Circuit IoT board (ESP32)
- Bug in Circuit IoT board (ESP32)
BME680 Environmental Sensor Module
- BME680 Environmental Sensor Module
Jumper wires
- Jumper wires
3D-printed airflow nozzle (optional)
- 3D-printed airflow nozzle (optional)
Coffee grounds and ethanol for testing
- Coffee grounds and ethanol for testing
BME680 Pin ESP32 Pin
VCC 3.3V
GND GND
SDA GPIO 21
SCL GPIO 22
Arduino IDE / PlatformIO
- Arduino IDE / PlatformIO
Adafruit BME680 Library
- Adafruit BME680 Library
Custom firmware for heater profiling and feature extraction
- Custom firmware for heater profiling and feature extraction
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include "Adafruit_BME680.h"
#include <math.h>
// ====== User config ======
#define SDA_PIN 21
#define SCL_PIN 22
#define I2C_CLK_HZ 400000
#define BME_ADDR 0x76 // change to 0x77 if needed
// Heater profile: temps (°C) & dwell (ms)
const int HEAT_T[] = {220, 260, 320};
const int HEAT_MS[] = {3000, 3000, 3000};
const int N_STEPS = sizeof(HEAT_T)/sizeof(HEAT_T[0]);
// Sampling ~20 Hz
const int SAMPLE_MS = 50;
// Purge before each profile (clean air preferred)
const int PURGE_MS = 4000;
// Baseline learning in clean air
const unsigned long R0_TIME_MS = 6UL * 60UL * 1000UL; // ~6 minutes
// Feature extraction windows
const uint32_t STEADY_WIN_MS = 800; // last 0.8s for steady mean
const uint32_t SLOPE_WIN_MS = 500; // first 0.5s for slope
// ====== Globals ======
Adafruit_BME680 bme;
float R0 = NAN; // clean-air baseline (Ohms)
unsigned long r0Start = 0;
enum Mode { MODE_LEARN_COFFEE, MODE_LEARN_ETHANOL, MODE_RUN };
Mode mode = MODE_RUN;
const int N_FEAT = 12;
// feature layout per cycle (12):
// steady_0, delta_0, slope_0,
// steady_1, delta_1, slope_1,
// steady_2, delta_2, slope_2,
// ratio_1_0, ratio_2_0, RH_avg
// Centroids for two classes
float coffee_mu[N_FEAT]; bool coffee_trained = false;
float ethanol_mu[N_FEAT]; bool ethanol_trained = false;
// Accumulators used during learning
float acc_mu[N_FEAT]; int acc_n = 0;
// Forward decl
bool readOnce(float &tC, float &rh, float &p_hPa, float &Rs);
void learnBaseline();
void runCycle(float feat[N_FEAT], bool logRaw);
float eucDist(const float a[], const float b[], int n);
void resetAccumulator();
void addToAccumulator(const float feat[]);
void finalizeCentroid(float dst_mu[]);
// ====== Setup ======
void setup() {
Serial.begin(115200);
delay(300);
Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(I2C_CLK_HZ);
Serial.println(F("\n== BME680 Coffee vs Ethanol - Simple Classifier =="));
if (!bme.begin(BME_ADDR)) {
Serial.println(F("BME at 0x76 not found, trying 0x77..."));
if (!bme.begin(0x77)) {
Serial.println(F("❌ No BME680/688 found. Check wiring."));
while (1) delay(100);
}
}
bme.setTemperatureOversampling(BME680_OS_8X);
bme.setHumidityOversampling(BME680_OS_2X);
bme.setPressureOversampling(BME680_OS_4X);
bme.setIIRFilterSize(BME680_FILTER_SIZE_3);
bme.setGasHeater(HEAT_T[0], 150);
r0Start = millis();
Serial.println(F("\nCommands in Serial Monitor:"));
Serial.println(F(" C : Learn COFFEE (collect ~8 cycles)"));
Serial.println(F(" E : Learn ETHANOL (collect ~8 cycles)"));
Serial.println(F(" R : Run classification"));
Serial.println(F("\nFirst, let the sensor learn R0 in CLEAN AIR (~6 minutes)."));
}
// ====== Loop ======
void loop() {
// Serial command handling
if (Serial.available()) {
char c = toupper(Serial.read());
if (c == 'C') { mode = MODE_LEARN_COFFEE; resetAccumulator(); Serial.println(F("\n[LEARN COFFEE] Place coffee aroma. Press 'R' when done.")); }
if (c == 'E') { mode = MODE_LEARN_ETHANOL; resetAccumulator(); Serial.println(F("\n[LEARN ETHANOL] Place ethanol vapor. Press 'R' when done.")); }
if (c == 'R') {
if (mode == MODE_LEARN_COFFEE && acc_n >= 3) { finalizeCentroid(coffee_mu); coffee_trained = true; Serial.println(F("✅ Coffee centroid saved.")); }
if (mode == MODE_LEARN_ETHANOL && acc_n >= 3) { finalizeCentroid(ethanol_mu); ethanol_trained = true; Serial.println(F("✅ Ethanol centroid saved.")); }
mode = MODE_RUN;
Serial.println(F("\n[RUN] Classifying cycles..."));
}
}
if (isnan(R0)) { learnBaseline(); return; }
// One measurement cycle -> features
float feat[N_FEAT];
bool logRaw = (mode != MODE_RUN); // during learning, print raw rows
runCycle(feat, logRaw);
if (mode == MODE_RUN) {
if (coffee_trained && ethanol_trained) {
float dc = eucDist(feat, coffee_mu, N_FEAT);
float de = eucDist(feat, ethanol_mu, N_FEAT);
const char* label = (dc < de) ? "COFFEE" : "ETHANOL";
float conf = (dc + de > 1e-6f) ? (1.0f - fabs(dc - de) / (dc + de)) : 0.5f; // rough confidence (0..1)
Serial.printf("CLASS=%s (dC=%.3f, dE=%.3f, conf~%.2f)\n", label, dc, de, conf);
} else {
Serial.println(F("⚠️ Train both classes first (press 'C' and 'E')."));
}
} else {
// Accumulate for the current class
addToAccumulator(feat);
Serial.printf("[LEARN] collected=%d\n", acc_n);
// Tip: collect ~8 cycles per class before pressing 'R'
}
}
// ====== Helpers ======
bool readOnce(float &tC, float &rh, float &p_hPa, float &Rs) {
unsigned long endTime = bme.beginReading();
if (endTime == 0) return false;
unsigned long now = millis();
if (endTime > now) delay(endTime - now);
if (!bme.endReading()) return false;
tC = bme.temperature;
rh = bme.humidity;
p_hPa = bme.pressure / 100.0f;
Rs = bme.gas_resistance;
return true;
}
void learnBaseline() {
static double sumR = 0; static uint32_t n = 0;
float tC, rh, p, Rs;
if (readOnce(tC, rh, p, Rs)) {
sumR += Rs; n++;
if (millis() - r0Start > R0_TIME_MS && n > 30) {
R0 = sumR / (double)n;
Serial.print(F("✅ R0 learned: ")); Serial.print(R0/1000.0, 2); Serial.println(F(" kΩ"));
Serial.println(F("You can now learn classes: 'C' for coffee, 'E' for ethanol; 'R' to run."));
} else if ((n % 50) == 0) {
Serial.printf("…learning R0 (%u samples)\n", n);
}
}
}
// Runs one full cycle: PURGE → STEP0 → STEP1 → STEP2; outputs 12‑feature vector
void runCycle(float feat[N_FEAT], bool logRaw) {
// PURGE
uint32_t t0 = millis();
while (millis() - t0 < (uint32_t)PURGE_MS) {
float tC, rh, p, Rs;
if (readOnce(tC, rh, p, Rs) && logRaw) {
float RsR0 = isnan(R0) ? NAN : (Rs / R0);
Serial.printf("RAW,PURGE,%.2f,%.2f,%.2f,%.1f,%.6f\n", tC, rh, p, Rs, RsR0);
}
delay(SAMPLE_MS);
}
float steady[3] = {NAN,NAN,NAN};
float delta [3] = {NAN,NAN,NAN};
float slope [3] = {NAN,NAN,NAN};
double rhSum=0; uint32_t rhN=0;
for (int s=0; s<N_STEPS; s++) {
bme.setGasHeater(HEAT_T[s], 150);
uint32_t stepStart = millis();
// For slope window (first 0.5s)
float firstRsR0 = NAN, lastSlopeRsR0 = NAN;
uint32_t slopeStart = millis();
// For steady window (last 0.8s)
double steadySum = 0; uint32_t steadyN = 0;
// For delta
float startRsR0 = NAN, endRsR0 = NAN;
while (millis() - stepStart < (uint32_t)HEAT_MS[s]) {
float tC, rh, p, Rs;
if (readOnce(tC, rh, p, Rs)) {
float RsR0 = (isnan(R0) ? NAN : (Rs / R0));
if (!isnan(RsR0)) {
if (isnan(startRsR0)) startRsR0 = RsR0;
endRsR0 = RsR0;
// slope window
if (millis() - slopeStart <= SLOPE_WIN_MS) {
if (isnan(firstRsR0)) firstRsR0 = RsR0;
lastSlopeRsR0 = RsR0;
}
// steady window: last STEADY_WIN_MS of the step
uint32_t elapsed = millis() - stepStart;
if (elapsed + STEADY_WIN_MS >= (uint32_t)HEAT_MS[s]) {
steadySum += RsR0; steadyN++;
}
}
// Env avg
rhSum += rh; rhN++;
if (logRaw) {
Serial.printf("RAW,STEP%d,%.2f,%.2f,%.2f,%.1f,%.6f\n", s, tC, rh, p, Rs, RsR0);
}
}
delay(SAMPLE_MS);
}
// compute features for this step
steady[s] = (steadyN>0) ? (float)(steadySum / steadyN) : NAN;
delta[s] = (!isnan(startRsR0) && !isnan(endRsR0)) ? (endRsR0 - startRsR0) : NAN;
// slope ≈ Δ over first 0.5s / 0.5s
if (!isnan(firstRsR0) && !isnan(lastSlopeRsR0)) {
slope[s] = (lastSlopeRsR0 - firstRsR0) / (SLOPE_WIN_MS / 1000.0f);
} else {
slope[s] = NAN;
}
}
// Cross‑step ratios and env averages
float ratio_1_0 = (!isnan(steady[0]) && steady[0] != 0 && !isnan(steady[1])) ? (steady[1]/steady[0]) : NAN;
float ratio_2_0 = (!isnan(steady[0]) && steady[0] != 0 && !isnan(steady[2])) ? (steady[2]/steady[0]) : NAN;
float rh_avg = (rhN>0) ? (float)(rhSum / rhN) : NAN;
// Pack features (12 total)
int k=0;
feat[k++] = steady[0]; feat[k++] = delta[0]; feat[k++] = slope[0];
feat[k++] = steady[1]; feat[k++] = delta[1]; feat[k++] = slope[1];
feat[k++] = steady[2]; feat[k++] = delta[2]; feat[k++] = slope[2];
feat[k++] = ratio_1_0; feat[k++] = ratio_2_0; feat[k++] = rh_avg;
// Print feature row for your logs (optional)
// Serial.print("FEAT,");
// for (int i=0;i<N_FEAT;i++){ Serial.print(feat[i],6); if(i<N_FEAT-1)Serial.print(","); }
// Serial.println();
}
void resetAccumulator() {
for (int i=0;i<N_FEAT;i++) acc_mu[i] = 0;
acc_n = 0;
}
void addToAccumulator(const float feat[]) {
for (int i=0;i<N_FEAT;i++) acc_mu[i] += feat[i];
acc_n++;
}
void finalizeCentroid(float dst_mu[]) {
if (acc_n <= 0) return;
for (int i=0;i<N_FEAT;i++) dst_mu[i] = acc_mu[i] / (float)acc_n;
}
float eucDist(const float a[], const float b[], int n) {
double s=0;
for (int i=0;i<n;i++) {
float ai=a[i], bi=b[i];
if (isnan(ai) || isnan(bi)) continue; // skip NaNs
double d = (double)ai - (double)bi;
s += d*d;
}
return sqrt(s);
}
12. Training & TestingBaseline: Run in clean air for ~6 min to learn R0 (baseline resistance).
- Baseline: Run in clean air for ~6 min to learn R0 (baseline resistance).
Learn Coffee: Place near coffee aroma, press C to collect cycles.
- Learn Coffee: Place near coffee aroma, press C to collect cycles.
Learn Ethanol: Place near ethanol vapor, press E to collect cycles.
- Learn Ethanol: Place near ethanol vapor, press E to collect cycles.
Run: Press R to start classification.
- Run: Press R to start classification.
13.Output:
CLASS=COFFEE (dC=0.32, dE=0.67, conf~0.82)
14.Results
The E-Nose reliably distinguishes coffee and ethanol in a consistent environment.
- The E-Nose reliably distinguishes coffee and ethanol in a consistent environment.
The system can be expanded by adding more labeled data and classes.
- The system can be expanded by adding more labeled data and classes.
Air quality monitoring
- Air quality monitoring
Food freshness detection
- Food freshness detection
Education and STEM projects
- Education and STEM projects
Basic odor detection in lab experiments
- Basic odor detection in lab experiments
Add more classes and improve ML model accuracy.
- Add more classes and improve ML model accuracy.
Log data to Firebase or an SD card.
- Log data to Firebase or an SD card.
Add a fan/pump for controlled airflow.
- Add a fan/pump for controlled airflow.
Build a compact enclosure.
- Build a compact enclosure.
Code: Arduino sketch for ESP32
- Code: Arduino sketch for ESP32
3D files: Airflow nozzle STL
- 3D files: Airflow nozzle STL
Datasets: Example feature logs (optional)
- Datasets: Example feature logs (optional)
This project demonstrates how versatile the Bug in Circuit board can be. With just a single sensor and smart data processing, we built an affordable, working E-Nose system.
Comments