Skip to content

📘 Viewing PuffiAir Data on M5Paper

PuffiAir supports MQTT data publishing for air quality monitoring. With M5Paper, you can visualize this data on an e-ink display. This guide walks you through the setup and usage process.


🧾 Features

  • Real-time display of CO₂, temperature, humidity, PM1.0 / PM2.5 / PM10 / PM4.0, VOC index, and gas levels
  • Wi-Fi and MQTT auto-connect
  • Auto-refresh upon new MQTT messages
  • Page navigation with side wheel (overview + per-sensor charts)
  • Optional SD card data logging

PuffiAirM5Paper PuffiAirM5Paper

🔧 Requirements

Hardware

  • M5Paper (with built-in e-ink screen)
  • A PuffiAir device (publishing data via MQTT)
  • Wi-Fi network access
  • MQTT broker (e.g., Home Assistant, Mosquitto, etc.)

Flashing the Firmware

Use Arduino IDE or PlatformIO to flash the provided firmware to your M5Paper.


#include <M5EPD.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <SD.h>
#include <SPI.h>
#include <time.h>

const char* ssid = "your_wifi_ssid";
const char* password = "your_wifi_password";
const char* mqtt_server = "192.168.x.x";
const int mqtt_port = 1883;
const char* mqtt_user = "your_mqtt_user";
const char* mqtt_pass = "your_mqtt_password";

WiFiClient espClient;
PubSubClient client(espClient);
M5EPD_Canvas canvas(&M5.EPD);

float co2 = 0, temp = 0, humi = 0, pressure = 0;
float pm1 = 0, pm25 = 0, pm4 = 0, pm10 = 0;
float gas_adc = 0, voc_index = 0;
String status = "";

int currentPage = 0;
const int maxPage = 9;
unsigned long lastDebounce = 0;

int voltageToPercent(float v) {
  if (v <= 3.2) return 0;
  if (v >= 4.2) return 100;
  return int((v - 3.2) * 100.0);
}

void drawItem(String label, String value, int lx, int rx, int y) {
  canvas.drawString(label, lx, y, 1);
  canvas.drawRightString(value, rx, y, 1);
}

void drawGraph(const String& title, float latest, const String& filePath) {
  canvas.fillCanvas(0);
  canvas.setTextColor(15);
  canvas.setTextSize(3);
  canvas.drawCentreString(title, 480, 20, 1);
  canvas.drawLine(30, 60, 930, 60, 15);

  File file = SD.open(filePath);
  if (!file) {
    canvas.drawCentreString("No data", 480, 260, 1);
    canvas.pushCanvas(0, 0, UPDATE_MODE_GC16);
    return;
  }

  float values[100];
  int count = 0;
  while (file.available() && count < 100) {
    values[count++] = file.parseFloat();
  }
  file.close();

  float minVal = values[0], maxVal = values[0];
  for (int i = 1; i < count; i++) {
    if (values[i] < minVal) minVal = values[i];
    if (values[i] > maxVal) maxVal = values[i];
  }
  if (maxVal - minVal < 1e-3) maxVal = minVal + 1;

  int chartX = 60, chartY = 100, chartW = 840, chartH = 360;
  canvas.drawRect(chartX, chartY, chartW, chartH, 15);

  canvas.setTextSize(1);
  for (int i = 0; i <= 5; i++) {
    int y = chartY + i * chartH / 5;
    float val = maxVal - i * (maxVal - minVal) / 5.0;
    canvas.drawRightString(String(val, 1), chartX - 5, y - 8, 1);
    canvas.drawLine(chartX, y, chartX + chartW, y, i == 0 ? 15 : 7);
  }

  for (int i = 0; i < count - 1; i++) {
    int x0 = chartX + i * chartW / 100;
    int y0 = chartY + chartH - (values[i] - minVal) * chartH / (maxVal - minVal);
    int x1 = chartX + (i + 1) * chartW / 100;
    int y1 = chartY + chartH - (values[i + 1] - minVal) * chartH / (maxVal - minVal);
    canvas.drawLine(x0, y0, x1, y1, 15);
  }

  canvas.setTextSize(2);
  canvas.drawRightString("Latest: " + String(latest, 1), 900, 500, 1);
  canvas.pushCanvas(0, 0, UPDATE_MODE_GC16);
}

void drawMainScreen() {
  canvas.fillCanvas(0);
  canvas.setTextColor(15);
  canvas.setTextSize(4);
  canvas.drawCentreString("PuffiAir Monitor", 480, 10, 1);
  canvas.drawLine(30, 60, 930, 60, 15);

  int xL = 40, xR = 920, y = 80, h = 60;
  canvas.setTextSize(3);
  drawItem("CO2", String(co2, 0) + " ppm", xL, xR, y); y += h;
  drawItem("PM2.5", String(pm25, 1) + " ug/m3", xL, xR, y); y += h;
  drawItem("Temp", String(temp, 1) + " C", xL, xR, y); y += h;
  drawItem("VOC Index", String(voc_index, 1), xL, xR, y); y += h;

  time_t now = time(nullptr);
  struct tm* timeinfo = localtime(&now);
  char timeStr[32];
  strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M", timeinfo);
  canvas.setTextSize(6);
  canvas.drawCentreString(timeStr, 480, 430, 1);

  int bat_mv = M5.getBatteryVoltage();
  float bat_v = bat_mv / 1000.0;
  int bat_percent = voltageToPercent(bat_v);
  String batteryInfo = "Battery: " + String(bat_v, 2) + "V (" + String(bat_percent) + "%)";
  canvas.setTextSize(2);
  canvas.drawString(batteryInfo, 40, 480, 1);

  canvas.pushCanvas(0, 0, UPDATE_MODE_GC16);
}

void saveToFile(const String& path, float value) {
  File f = SD.open(path, FILE_APPEND);
  if (f) {
    f.println(value);
    f.close();
  }
}

void handlePage() {
  switch (currentPage) {
    case 0: drawMainScreen(); break;
    case 1: drawGraph("CO2 (ppm)", co2, "/co2.txt"); break;
    case 2: drawGraph("Temp (C)", temp, "/temp.txt"); break;
    case 3: drawGraph("Humidity (%)", humi, "/humi.txt"); break;
    case 4: drawGraph("PM2.5", pm25, "/pm25.txt"); break;
    case 5: drawGraph("PM1.0", pm1, "/pm1.txt"); break;
    case 6: drawGraph("PM4.0", pm4, "/pm4.txt"); break;
    case 7: drawGraph("PM10", pm10, "/pm10.txt"); break;
    case 8: drawGraph("VOC Index", voc_index, "/voc.txt"); break;
    case 9: drawGraph("Gas ADC", gas_adc, "/gas.txt"); break;
  }
}

void callback(char* topic, byte* payload, unsigned int length) {
  payload[length] = '\0';
  String payloadStr = String((char*)payload);
  Serial.printf("[MQTT] %s => %s\n", topic, payloadStr.c_str());

  if (String(topic) == "puffiair/data") {
    StaticJsonDocument<768> doc;
    if (deserializeJson(doc, payloadStr) == DeserializationError::Ok) {
      co2 = doc["co2"].as<float>();
      temp = doc["temperature"].as<float>();
      humi = doc["humidity"].as<float>();
      pressure = doc["pressure"].as<float>();
      pm1 = doc["pm1"].as<float>();
      pm25 = doc["pm25"].as<float>();
      pm4 = doc["pm4"].as<float>();
      pm10 = doc["pm10"].as<float>();
      voc_index = doc["voc_index"].as<float>();
      gas_adc = doc["gas_adc"].as<float>();
      status = doc["status"].as<String>();

      saveToFile("/co2.txt", co2);
      saveToFile("/temp.txt", temp);
      saveToFile("/humi.txt", humi);
      saveToFile("/pressure.txt", pressure);
      saveToFile("/pm1.txt", pm1);
      saveToFile("/pm25.txt", pm25);
      saveToFile("/pm4.txt", pm4);
      saveToFile("/pm10.txt", pm10);
      saveToFile("/voc.txt", voc_index);
      saveToFile("/gas.txt", gas_adc);

      handlePage();
    }
  }
}

void reconnect() {
  while (!client.connected()) {
    Serial.print("[MQTT] Connecting...");
    if (client.connect("M5PaperClient", mqtt_user, mqtt_pass)) {
      Serial.println("Connected");
      client.subscribe("puffiair/data");
    } else {
      Serial.printf("Failed, rc=%d. Retry in 5s\n", client.state());
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  M5.begin();
  M5.EPD.SetRotation(4);
  M5.BatteryADCBegin();
  canvas.createCanvas(960, 540);
  canvas.setTextSize(2);
  canvas.setTextDatum(TL_DATUM);

  WiFi.begin(ssid, password);
  Serial.print("[WiFi] Connecting");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500); Serial.print(".");
  }
  Serial.printf("\n[WiFi] Connected: %s\n", WiFi.localIP().toString().c_str());

  configTime(8 * 3600, 0, "ntp.nict.jp", "pool.ntp.org");

  client.setServer(mqtt_server, mqtt_port);
  client.setCallback(callback);

  if (!SD.begin()) {
    Serial.println("[SD] Mount failed");
  } else {
    Serial.println("[SD] Card initialized");
  }

  pinMode(37, INPUT_PULLUP);
  pinMode(39, INPUT_PULLUP);
  pinMode(38, INPUT_PULLUP);

  handlePage();
}

void loop() {
  if (!client.connected()) reconnect();
  client.loop();

  if (millis() - lastDebounce > 300) {
    if (digitalRead(39) == LOW) {
      currentPage = (currentPage + 1) % (maxPage + 1);
      handlePage();
      lastDebounce = millis();
    } else if (digitalRead(37) == LOW) {
      currentPage = (currentPage - 1 + maxPage + 1) % (maxPage + 1);
      handlePage();
      lastDebounce = millis();
    }
  }
}

Edit these variables in the code to match your network:

const char* ssid = "your_wifi_ssid";
const char* password = "your_wifi_password";
const char* mqtt_server = "192.168.x.x";
const int mqtt_port = 1883;
const char* mqtt_user = "your_mqtt_user";
const char* mqtt_pass = "your_mqtt_password";

📡 MQTT Settings

PuffiAir publishes sensor data to the following topic:

puffiair/data

🖥️ What’s Displayed

On the main overview screen, the following data is shown:

  • CO₂ (ppm)
  • Temperature (°C) and humidity (%)
  • PM1.0 / PM2.5 / PM4.0 / PM10 (μg/m³)
  • VOC index
  • Raw GAS sensor value
  • Overall status (normal / warning / danger)

You can scroll using the side wheel to view historical charts per sensor. Chart data is stored on the SD card.

⏱ Refresh Behavior

The screen refreshes immediately whenever new MQTT data is received. No delay, no need to press anything.