You dont have javascript enabled! Please enable it!

Practical case: Arduino Nano ePaper air quality data logger

Practical case: Arduino Nano ePaper air quality data logger — hero

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 $USER then 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 .pio directory 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 $USER then 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 --version returns >= 6.1.11.

  • Project files

  • platformio.ini matches this guide.
  • src/main.cpp copied exactly.

  • Build/Upload

  • pio run builds 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, D dumps CSV with plausible values.
  • Resetting the board preserves the log.
  • C clears log; F forces 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

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the primary sensor used in the air quality logger?




Question 2: Which display technology is used in this project?




Question 3: What is the function of the ring buffer in the system?




Question 4: Which operating systems are supported for the host?




Question 5: What is the purpose of sleeping between samples?




Question 6: What toolchain version is required for PlatformIO?




Question 7: What type of USB cable is recommended for this project?




Question 8: What is the proficiency level required for this tutorial?




Question 9: What is the main advantage of using the Arduino Nano 33 IoT?




Question 10: What should you do to ensure Linux users can access the Arduino Nano?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:
Scroll to Top