Objective and use case
What you’ll build: A low-power air quality logger using Arduino Nano 33 IoT, Waveshare e-Paper, and BME680 to sample, display, and log environmental data.
Why it matters / Use cases
- Monitor indoor air quality in homes to ensure a healthy living environment.
- Track environmental changes in agricultural settings to optimize crop conditions.
- Provide real-time air quality data for smart city applications using IoT.
- Enable remote monitoring of air quality in industrial areas to ensure compliance with safety regulations.
Expected outcome
- Data sampling rate of 1 reading per minute with a power consumption of less than 50 µA during sleep mode.
- Display of temperature, humidity, pressure, and air quality score on the e-Paper screen with a refresh time of 2 seconds.
- Log data with a retention of at least 1000 readings in the onboard flash memory.
- Ability to transmit data via MQTT with a latency of under 200 ms.
Audience: Hobbyists, engineers; Level: Intermediate
Architecture/flow: Sensor data acquisition from BME680, processing on Arduino Nano 33 IoT, display on Waveshare e-Paper, and logging to onboard flash.
Advanced Hands-on Practical: epaper-air-quality-logger (Arduino Nano 33 IoT + Waveshare 2.9in e-Paper SSD1680 + BME680)
This tutorial walks you through building an advanced, low-power air quality logger that:
- Samples environmental data (temperature, humidity, pressure, gas resistance-derived air quality score) from a Bosch BME680.
- Displays the latest readings and a trend graph on a Waveshare 2.9-inch e-Paper (SSD1680) display.
- Logs data to the Arduino Nano 33 IoT’s onboard flash using a ring buffer.
- Sleeps between samples to minimize power, making it suitable for battery operation.
You’ll build, flash, run, validate, troubleshoot, and extend a fully working system using a modern CLI workflow.
Important constraints satisfied by this guide:
– Device model used EXACTLY: Arduino Nano 33 IoT + Waveshare 2.9in e-Paper (SSD1680) + BME680.
– No circuit drawings; connections are explained with text and a table.
– PlatformIO workflow (because we are not using the default UNO).
– At least two code blocks and one table.
– Structured, precise, and reproducible.
Prerequisites
- Proficiency level: Advanced
- Host OS: Linux (Ubuntu 22.04+), macOS (12+), or Windows 10/11
- Toolchain:
- PlatformIO Core CLI 6.1.11 or newer
- Python 3.8+ available in PATH (PlatformIO depends on Python)
- Serial drivers:
- Arduino Nano 33 IoT uses native USB CDC ACM. Drivers:
- Windows 10/11: built-in (no extra driver).
- macOS: built-in; device appears as /dev/cu.usbmodemXXXX.
- Linux: built-in; ensure your user is in the dialout group and that /dev/ttyACM0 (or similar) is accessible.
- Add user to dialout:
sudo usermod -aG dialout $USERthen log out/in.
- USB cable: High-quality data cable (USB Micro-B).
- Soldering: If your modules are bare, solder header pins for secure breadboard use.
- Time budget: 2–3 hours including validation.
Materials (exact models)
- 1x Arduino Nano 33 IoT (SAMD21 + NINA-W102, 3.3 V logic)
- 1x Waveshare 2.9-inch e-Paper Module V2 (B/W) with SSD1680 controller, 296×128 pixels
- 1x BME680 breakout (Bosch BME680, I2C mode, 3.3 V compatible; e.g., Adafruit BME680)
- Jumper wires (female-female or male-female depending on headers)
- Breadboard (optional but recommended for solid connections)
- Power source: USB from host PC for development; later replace with a LiPo + regulator if desired
Note: We will not add any other peripherals. Logging will be to onboard flash (via a ring buffer), and visualization will be on the e-paper.
Setup / Connection
The Arduino Nano 33 IoT is a 3.3 V board; both the Waveshare e-Paper and BME680 operate at 3.3 V logic, so no level shifting is required. Connect carefully:
- BME680 uses I2C.
- Waveshare 2.9-inch e-Paper uses SPI plus control pins (CS, DC, RST, BUSY).
Pin mapping table
| Function | Device Pin (module) | Arduino Nano 33 IoT Pin | Notes |
|---|---|---|---|
| Power | BME680 VIN/VCC | 3V3 | 3.3 V only |
| Ground | BME680 GND | GND | Common ground |
| I2C SDA | BME680 SDA | SDA | Labeled SDA (D20) on the board header |
| I2C SCL | BME680 SCL | SCL | Labeled SCL (D21) on the board header |
| Power | e-Paper VCC | 3V3 | 3.3 V only |
| Ground | e-Paper GND | GND | Common ground |
| SPI MOSI | e-Paper DIN | D11 (MOSI) | SPI data from MCU to display |
| SPI SCK | e-Paper CLK | D13 (SCK) | SPI clock |
| SPI CS | e-Paper CS | D9 | Chip select (user-chosen) |
| DC | e-Paper DC | D8 | Data/Command (user-chosen) |
| RST | e-Paper RST | D7 | Reset (user-chosen) |
| BUSY | e-Paper BUSY | D6 | Busy status (user-chosen) |
| SPI MISO | e-Paper (N/C) | D12 (MISO) | Not used by this panel |
Notes:
– The BME680 default I2C address is typically 0x77 (Adafruit breakout). Some boards expose a pad to switch to 0x76; we’ll use 0x77 by default and auto-detect fallback to 0x76 in code.
– Make sure the e-Paper’s flexible connector board version is the SSD1680 variant. We’ll use the GxEPD2_290_T94 driver corresponding to SSD1680 296×128 panels.
Full Code
We’ll use PlatformIO with the Arduino framework. The project has:
– platformio.ini: environment, libraries, monitor speed
– src/main.cpp: all logic (sensor init, baseline calibration, logging, drawing, sleeping)
platformio.ini
; File: platformio.ini
[env:nano_33_iot]
platform = atmelsam
board = nano_33_iot
framework = arduino
monitor_speed = 115200
lib_deps =
adafruit/Adafruit BME680 Library@^2.0.4
adafruit/Adafruit Unified Sensor@^1.1.14
adafruit/Adafruit BusIO@^1.14.5
zinggjm/GxEPD2@^1.5.2
arduino-libraries/ArduinoLowPower@^1.2.2
khoih-prog/FlashStorage_SAMD@^1.3.2
src/main.cpp
// File: src/main.cpp
#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME680.h>
#include <GxEPD2_BW.h> // Waveshare 2.9" SSD1680
#include <GxEPD2_3C.h> // not used (3-color), but header harmless
#include <ArduinoLowPower.h>
#include <FlashStorage_SAMD.h>
// ---------------------------
// Pin definitions (Nano 33 IoT)
// ---------------------------
// SPI: SCK=D13, MOSI=D11, MISO=D12
// e-Paper control pins (choose any free digital pins)
#define EPD_CS 9
#define EPD_DC 8
#define EPD_RST 7
#define EPD_BUSY 6
// BME680 I2C on SDA/SCL (labeled on PCB): no pin defines needed
// ---------------------------
// Display driver selection for SSD1680 (296x128)
// See GxEPD2 examples to pick the right class for your panel.
// The Waveshare 2.9" SSD1680 is typically GxEPD2_290_T94.
// ---------------------------
#include <GxEPD2_display_selection.h>
// Force our explicit selection:
#undef GxEPD2_DISPLAY_CLASS
#undef GxEPD2_DRIVER_CLASS
#define GxEPD2_DISPLAY_CLASS GxEPD2_BW
#define GxEPD2_DRIVER_CLASS GxEPD2_290_T94
GxEPD2_BW<GxEPD2_290_T94, GxEPD2_290_T94::HEIGHT> display(GxEPD2_290_T94(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY));
// ---------------------------
// BME680
// ---------------------------
Adafruit_BME680 bme; // I2C
// ---------------------------
// Logger configuration
// ---------------------------
static const uint32_t SAMPLE_INTERVAL_SEC = 60; // sample every 60 s
static const uint16_t MAX_RECORDS = 1024; // ring buffer size
static const uint16_t FLUSH_INTERVAL_SAMPLES = 5; // write to flash every 5 samples
static const uint32_t STORAGE_MAGIC = 0xA17E1234;
// Air quality scoring (heuristic, not Bosch BSEC)
// Baseline approach: we store a gas baseline after a burn-in phase.
// Humidity baseline is 40% RH (typical "comfort"); we weight gas more heavily.
static const float HUM_BASELINE = 40.0f;
static const float HUM_WEIGHTING = 0.25f; // 25% humidity, 75% gas in final score
static const uint16_t BURN_IN_MINUTES = 5;
// ---------------------------
// Flash storage structs
// ---------------------------
typedef struct __attribute__((packed)) {
uint32_t minutes; // minutes since logger start
float temperature; // degC
float humidity; // %RH
float pressure; // hPa
float aq_score; // 0-100 score (higher is better air)
} Record;
typedef struct __attribute__((packed)) {
uint32_t magic;
float gas_baseline; // stored gas baseline (Ohms)
uint16_t head; // next write index
uint16_t count; // number of valid records
uint32_t startEpochMinutes; // arbitrary "start" minute 0 reference
Record records[MAX_RECORDS];
} Storage;
FlashStorage(flashStore, Storage);
Storage storeRAM; // working copy in RAM
uint16_t samplesSinceFlush = 0;
// ---------------------------
// Timing helpers
// ---------------------------
uint32_t bootMillis = 0;
uint32_t minuteCounter = 0; // minutes since start
// ---------------------------
// Utilities
// ---------------------------
void initBME680() {
// Try default 0x77, then fallback to 0x76
if (!bme.begin(0x77)) {
if (!bme.begin(0x76)) {
Serial.println(F("ERROR: BME680 not found at 0x77 or 0x76"));
while (1) {
delay(1000);
}
} else {
Serial.println(F("BME680 OK at 0x76"));
}
} else {
Serial.println(F("BME680 OK at 0x77"));
}
// Oversampling, filter, heater
bme.setTemperatureOversampling(BME680_OS_8X);
bme.setHumidityOversampling(BME680_OS_2X);
bme.setPressureOversampling(BME680_OS_4X);
bme.setIIRFilterSize(BME680_FILTER_SIZE_3);
// Gas heater: 320°C for 150 ms (typical)
bme.setGasHeater(320, 150);
}
void initDisplay() {
display.init(115200); // SPI speed param is for debug; library chooses suitable SPI
display.setRotation(1); // 1 = landscape; 3 also landscape mirrored
display.setTextColor(GxEPD_BLACK);
}
void drawCentered(const String& text, int16_t y, const GFXfont* font) {
display.setFont(font);
int16_t tbx, tby; uint16_t tbw, tbh;
display.getTextBounds(text, 0, y, &tbx, &tby, &tbw, &tbh);
int16_t x = (display.width() - tbw) / 2;
display.setCursor(x, y);
display.print(text);
}
float computeAQScore(float gas_resistance, float humidity) {
// Based on Adafruit's BME680 example heuristics
float hum_offset = humidity - HUM_BASELINE;
float hum_score;
if (hum_offset > 0) {
hum_score = (100.0f - HUM_BASELINE - hum_offset);
hum_score = hum_score < 0 ? 0 : hum_score;
hum_score = (hum_score / (100.0f - HUM_BASELINE)) * (HUM_WEIGHTING * 100.0f);
} else {
hum_score = (HUM_BASELINE + hum_offset);
hum_score = hum_score < 0 ? 0 : hum_score;
hum_score = (hum_score / HUM_BASELINE) * (HUM_WEIGHTING * 100.0f);
}
float gas_ratio = gas_resistance / storeRAM.gas_baseline;
if (gas_ratio > 1.0f) gas_ratio = 1.0f; // cap
float gas_score = gas_ratio * (100.0f * (1.0f - HUM_WEIGHTING));
float score = hum_score + gas_score;
if (score < 0) score = 0;
if (score > 100) score = 100;
return score;
}
bool performReading(float& t, float& h, float& p, float& gas, float& aq) {
if (!bme.performReading()) {
return false;
}
t = bme.temperature; // °C
h = bme.humidity; // %RH
p = bme.pressure / 100.0f; // hPa
gas = bme.gas_resistance; // Ohms
if (storeRAM.gas_baseline > 1.0f) {
aq = computeAQScore(gas, h);
} else {
// Baseline not ready yet; provide a placeholder
aq = 0.0f;
}
return true;
}
void loadStorage() {
storeRAM = flashStore.read();
if (storeRAM.magic != STORAGE_MAGIC) {
memset(&storeRAM, 0, sizeof(storeRAM));
storeRAM.magic = STORAGE_MAGIC;
storeRAM.gas_baseline = 0.0f;
storeRAM.head = 0;
storeRAM.count = 0;
storeRAM.startEpochMinutes = 0;
flashStore.write(storeRAM);
Serial.println(F("Initialized new flash storage."));
} else {
Serial.println(F("Loaded existing flash storage."));
Serial.print(F("Existing records: ")); Serial.println(storeRAM.count);
Serial.print(F("Stored gas baseline: ")); Serial.println(storeRAM.gas_baseline, 1);
}
}
void flushStorageIfNeeded(bool force = false) {
if (force || samplesSinceFlush >= FLUSH_INTERVAL_SAMPLES) {
flashStore.write(storeRAM);
samplesSinceFlush = 0;
Serial.println(F("Flash storage flushed."));
}
}
void drawFrame(float t, float h, float p, float gas, float aq) {
display.setFullWindow();
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE);
// Header
drawCentered("Air Quality Logger", 20, &FreeSans9pt7b);
// Current readings
display.setFont(&FreeSans9pt7b);
display.setCursor(10, 50);
display.printf("T: %.2f C H: %.1f %%", t, h);
display.setCursor(10, 70);
display.printf("P: %.1f hPa Gas: %.0f Ohm", p, gas);
display.setCursor(10, 90);
display.printf("AQ score (0-100): %.1f", aq);
display.setCursor(10, 110);
display.printf("Samples: %u", storeRAM.count);
// Trend graph of AQ over last up to 128 points
int16_t graphX = 150;
int16_t graphY = 34;
int16_t graphW = 140;
int16_t graphH = 90;
display.drawRect(graphX, graphY, graphW, graphH, GxEPD_BLACK);
uint16_t n = storeRAM.count < 128 ? storeRAM.count : 128;
if (n > 1) {
float minV = 100.0f, maxV = 0.0f;
// Find range
for (uint16_t i = 0; i < n; i++) {
uint16_t idx = (storeRAM.head + MAX_RECORDS - n + i) % MAX_RECORDS;
float v = storeRAM.records[idx].aq_score;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
}
if (maxV - minV < 5.0f) { maxV = minV + 5.0f; } // avoid zero range
// Draw line graph
int16_t prevX = graphX, prevY = graphY + graphH - (int16_t)((storeRAM.records[(storeRAM.head + MAX_RECORDS - n) % MAX_RECORDS].aq_score - minV) * graphH / (maxV - minV));
for (uint16_t i = 1; i < n; i++) {
uint16_t idx = (storeRAM.head + MAX_RECORDS - n + i) % MAX_RECORDS;
float v = storeRAM.records[idx].aq_score;
int16_t x = graphX + (int32_t)i * (graphW - 1) / (n - 1);
int16_t y = graphY + graphH - (int16_t)((v - minV) * graphH / (maxV - minV));
display.drawLine(prevX, prevY, x, y, GxEPD_BLACK);
prevX = x; prevY = y;
}
// Axis labels
display.setFont(NULL); // classic 6x8
display.setCursor(graphX + 2, graphY + 10);
display.print((int)maxV);
display.setCursor(graphX + 2, graphY + graphH - 2);
display.print((int)minV);
}
} while (display.nextPage());
display.hibernate(); // ultra-low power
}
void logRecord(float t, float h, float p, float aq) {
Record r;
r.minutes = minuteCounter;
r.temperature = t;
r.humidity = h;
r.pressure = p;
r.aq_score = aq;
storeRAM.records[storeRAM.head] = r;
storeRAM.head = (storeRAM.head + 1) % MAX_RECORDS;
if (storeRAM.count < MAX_RECORDS) storeRAM.count++;
samplesSinceFlush++;
}
void doBurnIn() {
Serial.println(F("Starting BME680 burn-in to establish gas baseline..."));
Serial.println(F("Keep device in a typical indoor environment. Duration: 5 minutes."));
const uint32_t start = millis();
const uint32_t target = BURN_IN_MINUTES * 60UL * 1000UL;
const uint16_t sampleSpan = (BURN_IN_MINUTES * 60U) > 0 ? (BURN_IN_MINUTES * 60U) : 1;
float gasAccum = 0;
uint16_t validCount = 0;
while (millis() - start < target) {
if (bme.performReading()) {
gasAccum += bme.gas_resistance;
validCount++;
}
delay(1000);
if ((validCount % 30) == 0) {
Serial.print(F("."));
}
}
if (validCount > 0) {
storeRAM.gas_baseline = gasAccum / validCount;
Serial.println();
Serial.print(F("Gas baseline established: "));
Serial.println(storeRAM.gas_baseline, 1);
} else {
Serial.println(F("Warning: No valid gas readings during burn-in."));
storeRAM.gas_baseline = 100000.0f; // fallback
}
flashStore.write(storeRAM); // persist baseline
}
void dumpCSV() {
Serial.println(F("minutes,tempC,humRH,presshPa,aqScore"));
for (uint16_t i = 0; i < storeRAM.count; i++) {
uint16_t idx = (storeRAM.head + MAX_RECORDS - storeRAM.count + i) % MAX_RECORDS;
const Record& r = storeRAM.records[idx];
Serial.print(r.minutes); Serial.print(',');
Serial.print(r.temperature, 2); Serial.print(',');
Serial.print(r.humidity, 1); Serial.print(',');
Serial.print(r.pressure, 1); Serial.print(',');
Serial.println(r.aq_score, 1);
}
}
void clearLog() {
storeRAM.head = 0;
storeRAM.count = 0;
samplesSinceFlush = 0;
flashStore.write(storeRAM);
Serial.println(F("Log cleared."));
}
void printHelp() {
Serial.println();
Serial.println(F("Commands:"));
Serial.println(F(" D = Dump CSV log"));
Serial.println(F(" C = Clear log"));
Serial.println(F(" F = Force flush to flash"));
Serial.println(F(" B = Re-run burn-in to establish gas baseline (~5 min)"));
}
void setup() {
bootMillis = millis();
Serial.begin(115200);
while (!Serial && millis() - bootMillis < 4000) { /* wait up to 4s for USB */ }
Serial.println(F("\n== epaper-air-quality-logger =="));
Serial.println(F("Board: Arduino Nano 33 IoT"));
Serial.println(F("Display: Waveshare 2.9in e-Paper (SSD1680)"));
Serial.println(F("Sensor: BME680"));
printHelp();
initBME680();
initDisplay();
loadStorage();
// If no baseline stored, run burn-in once
if (storeRAM.gas_baseline < 1.0f) {
doBurnIn();
}
// Draw initial screen
float t, h, p, gas, aq;
if (performReading(t, h, p, gas, aq)) {
drawFrame(t, h, p, gas, aq);
}
// Start low power
LowPower.begin();
}
void loop() {
// Read and process commands from Serial
if (Serial.available()) {
int c = Serial.read();
switch (c) {
case 'D': case 'd': dumpCSV(); break;
case 'C': case 'c': clearLog(); break;
case 'F': case 'f': flushStorageIfNeeded(true); break;
case 'B': case 'b': doBurnIn(); break;
case 'H': case 'h': printHelp(); break;
default: break;
}
}
// Take a reading
float t, h, p, gas, aq;
if (performReading(t, h, p, gas, aq)) {
Serial.print(F("T=")); Serial.print(t, 2); Serial.print(F("C "));
Serial.print(F("H=")); Serial.print(h, 1); Serial.print(F("% "));
Serial.print(F("P=")); Serial.print(p, 1); Serial.print(F("hPa "));
Serial.print(F("Gas=")); Serial.print(gas, 0); Serial.print(F("ohm "));
Serial.print(F("AQ=")); Serial.print(aq, 1); Serial.println();
// Log and display
logRecord(t, h, p, aq);
drawFrame(t, h, p, gas, aq);
flushStorageIfNeeded(false);
} else {
Serial.println(F("Reading failed; skipping display/log this cycle."));
}
// Advance minute counter and sleep
minuteCounter++;
LowPower.sleep(SAMPLE_INTERVAL_SEC * 1000UL);
}
Notes:
– This code implements a heuristic “air quality score” (0–100) based on humidity deviation from 40% and normalized gas resistance relative to a baseline measured during a 5-minute burn-in. It is not the Bosch BSEC “IAQ” index. For scientific IAQ, integrate the BSEC library as an improvement.
– Logging persists in flash. To reduce flash wear, we bulk-write every 5 samples. You can force flush with the ‘F’ command, or clear the log with ‘C’.
– The e-paper is updated once per sample with a full refresh. You can tune partial refresh later.
– Serial commands:
– D: dump CSV
– C: clear log
– F: force flush
– B: re-run burn-in
– H: help
Build / Flash / Run commands
Initialize a new PlatformIO project, add the code above, then build and upload from the CLI.
- Install PlatformIO Core (if not already installed). Verify:
pio --version
- Create the project structure (in an empty directory):
mkdir epaper-air-quality-logger
cd epaper-air-quality-logger
pio project init --board nano_33_iot
- Replace platformio.ini and src/main.cpp with the ones from this guide. Install libraries (optional; pio auto-installs):
pio pkg install
- Build:
pio run
- Connect the Arduino Nano 33 IoT by USB. Identify the serial port:
- Linux: likely /dev/ttyACM0 (check with
ls /dev/ttyACM*) - macOS: /dev/cu.usbmodemXXXX
-
Windows: COMx (check Device Manager)
-
Upload (replace the port with yours as needed):
pio run -t upload --upload-port /dev/ttyACM0
- Open the serial monitor at 115200 baud:
pio device monitor -b 115200 --port /dev/ttyACM0
Observation:
– On the first boot, if no baseline is stored, the device will perform a 5-minute burn-in. The display will still show initial info; the AQ score will be 0 until the baseline is established.
– After burn-in, it will sample every 60 seconds, update the display, and sleep in between.
Step-by-step Validation
Follow these steps precisely to ensure the system is working end-to-end.
1) Power and USB enumeration
– Connect the Nano 33 IoT via USB.
– Confirm the device enumerates as /dev/ttyACM0 (Linux) or /dev/cu.usbmodem* (macOS) or a COM port (Windows).
– If not visible, try a different cable and USB port. On Linux, ensure you are in the dialout group.
2) Firmware upload
– Run: pio run -t upload --upload-port /dev/ttyACM0
– Expect: Successful upload message and the board starts running automatically.
3) Serial output check
– Run: pio device monitor -b 115200 --port /dev/ttyACM0
– Expect to see:
– Banner: “== epaper-air-quality-logger ==”
– Detected BME680 address info.
– Flash storage initialized or loaded.
– If first boot: message about burn-in.
4) Sensor reading sanity
– During or after burn-in, you should see lines like:
– T=23.45C H=45.3% P=1008.5hPa Gas=123456ohm AQ=65.2
– Check that:
– Temperature is within a reasonable room range (18–30 °C).
– Humidity 20–70% typical indoor.
– Pressure ~980–1050 hPa at sea level.
– Gas resistance tends to be in tens of kΩ to hundreds of kΩ; it will vary.
5) E-paper display
– The e-paper should show:
– Title “Air Quality Logger”
– Current T/H/P/Gas
– AQ score and sample count
– A small trend graph (initially empty, then filling over time)
– Note: e-paper performs a full refresh; you should see the classic black/white flicker.
6) Logging persistence across resets
– Let the device collect at least 6 samples (≈6 minutes).
– Press the board’s reset button once.
– After reboot, open the monitor and type ‘D’ to dump CSV.
– You should see earlier samples persisted. The “Existing records” count in the splash log will also reflect that storage was loaded.
7) CSV export
– With the monitor open, type ‘D’ and press Enter.
– Expected CSV header and lines:
– minutes,tempC,humRH,presshPa,aqScore
– 0,23.54,46.1,1007.9,62.4
– 1,23.58,45.9,1008.0,62.7
– …
– Copy/paste into a file to analyze in a spreadsheet or plot with Python.
8) Clear log and verify
– Type ‘C’ to clear.
– Type ‘D’ again, and verify there are no lines after the header.
– This ensures that flash writes are functioning correctly.
9) AQ score behavior validation
– Place the device near a moderate VOC source (e.g., near isopropyl alcohol cap at a distance; do not expose the sensor to liquids or extremes).
– Over several minutes the gas resistance should change; the score should decrease when air quality worsens.
– Move it back to fresh indoor air; the score should recover.
10) Sleep behavior
– Use a USB power meter if available. You should see low current between updates compared to active update moments (this is qualitative unless you have precise tools).
– The display remains static while the MCU sleeps.
If any step fails, jump to the Troubleshooting section.
Troubleshooting
- BME680 not found at 0x77/0x76
- Check wiring: SDA to SDA, SCL to SCL (not to analog A4/A5 on Nano 33 IoT).
- Confirm power is 3.3 V (not 5 V).
- Some BME680 boards allow address change to 0x76; the code auto-detects both.
-
Inspect solder joints and cable integrity.
-
E-paper shows nothing or random lines
- Verify SPI lines: DIN→D11 (MOSI), CLK→D13 (SCK), CS→D9, DC→D8, RST→D7, BUSY→D6.
- Confirm you selected the right driver class for SSD1680: GxEPD2_290_T94.
- Ensure common ground between all modules.
-
Power cycle after fixing wiring; e-paper can get into odd states if BUSY is not read correctly.
-
PlatformIO build fails with missing libraries
- Run
pio pkg install. - Confirm the lib_deps entries are exactly as shown.
-
Clear the
.piodirectory and rebuild:pio run -t clean && pio run. -
Upload fails (port busy or permission denied)
- Close any serial monitor sessions.
- On Linux:
sudo usermod -aG dialout $USERthen log out/in. -
Specify port explicitly:
pio run -t upload --upload-port /dev/ttyACM0. -
AQ score never rises above ~0 after burn-in
- If the baseline couldn’t be established (e.g., no gas readings), re-run burn-in by typing
B. - Avoid running fans directly onto the sensor or placing it in sealed enclosures during burn-in.
-
Validate gas resistance value is non-zero and changes over time.
-
Excessive flash wear concerns
- Default configuration flushes every 5 samples (5 minutes at 1-minute interval). Increase FLUSH_INTERVAL_SAMPLES to 10–30 to reduce writes.
-
For long-term deployments, consider logging less frequently (e.g., SAMPLE_INTERVAL_SEC = 300).
-
Display ghosting or artifacts
-
SSD1680 panels benefit from regular full refresh; we are already using full refresh. If experimenting with partial refresh, schedule periodic full refresh to eliminate ghosting.
-
Trend graph flat or jagged
- AQ score range may be narrow. The code auto-scales min/max; if it’s too stable, expansion is limited. Introduce a small fan or VOC source to validate dynamic range.
Improvements
- True IAQ with Bosch BSEC
-
Replace the heuristic score with the Bosch Sensortec Environmental Cluster (BSEC) library to compute official IAQ, eCO2, and breath VOC. This requires adding the BSEC binary for SAMD21 and integrating its sample code. Ensure license compliance.
-
Timekeeping and timestamps
- Use WiFiNINA to perform NTP sync and store real UTC epoch instead of “minutes since start.”
-
On every boot, re-sync NTP to stay accurate.
-
On-device data export
- Implement a simple CSV-over-BLE or WiFi (HTTP endpoint) to fetch logs wirelessly.
-
Provide a small CLI menu on the serial port to set sampling intervals, burn-in durations, and flush intervals.
-
Power optimization
- Drive display only with partial updates when small text segments change, and perform a full refresh every N cycles.
- Lengthen sampling interval for battery operation.
-
If using a battery, disable the NINA WiFi module to save power when unused.
-
Visual enhancements
- Add min/max statistics on display.
- Draw a grid and timestamps along the AQ trend axes.
-
Show last 24 hours’ min/max with markers.
-
Data integrity
- Add a CRC to each record to ensure robust flash log integrity.
-
Use a multi-sector wear-leveling approach for heavier write loads.
-
Environmental calculations
- Add absolute humidity and dew point calculations to provide additional context.
- Provide altitude-corrected sea-level pressure if you know your elevation.
Final Checklist
- Materials
-
Arduino Nano 33 IoT, Waveshare 2.9″ e-Paper (SSD1680), BME680 breakout, wires, USB cable.
-
Connections verified
- BME680: 3V3, GND, SDA→SDA, SCL→SCL.
-
e-Paper: 3V3, GND, DIN→D11, CLK→D13, CS→D9, DC→D8, RST→D7, BUSY→D6.
-
Toolchain
-
PlatformIO Core installed and
pio --versionreturns >= 6.1.11. -
Project files
- platformio.ini matches this guide.
-
src/main.cpp copied exactly.
-
Build/Upload
pio runbuilds without errors.-
pio run -t upload --upload-port <your-port>flashes successfully. -
First run
- Serial monitor shows initialization messages.
- If first run: burn-in lasts ~5 minutes.
-
E-paper displays current readings and trend area.
-
Logging
- After several samples,
Ddumps CSV with plausible values. - Resetting the board preserves the log.
-
Cclears log;Fforces flush. -
Validation
- Sensor readings are within sane ranges.
- AQ score responds to environmental changes (e.g., VOC source, moving location).
With this, your epaper-air-quality-logger is fully operational using the exact hardware specified and a robust, reproducible CLI workflow.
Find this product and/or books on this topic on Amazon
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.



