📘 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
🔧 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.