The device has a loop for a carabiner so it can be attached to belt loops, clothing, equipment, keys, and more. The grey bit sticking out from the bottom is the gas sensor.
See how the device makes real-time pushes to the database and dashboard!
From this website's homepage:
How does this project accomplish this? The microcontroller, a XIAO ESP32C3, is connected to an MQ2 gas sensor module, a loud active buzzer, and a real-time clock module. I chose the XIAO for its Wifi capabilities and small footprint. While the miner is underground, entries (consisting of a read from the real-time clock module and the mcq2 sensor) are added to a csv file on the microcontroller's flash memory. If the gas ppm goes above 2000 ppm (I'll discuss why this specific number later in the webpage), the miner is alerted using the loud active buzzer regardless of whether or not wifi is connected. When the miner comes above ground, and if there is a Wifi network in the vicinity, or if the device is taken to somewhere with Wifi, the CSV entries are pushed to the Firebase Real-Time Database and the dashboard updates. However, let's delve into some specifics.
First, I really tried to understand the basic code for pushing data to firebase through an ESP32 and any quirks associated with the XIAO and connecting to Wifi. The XIAO ESP32C3 definitely has strong Wifi capabilities. However, I did notice a few things. First, the XIAO obviously has an easier time connecting to Wifi when the antenna module is connected, so it is definitely worth sacrificing some space in a circuit board to attach it.
Second, the firebase library takes a very long time to compile. It might be worth setting up PlatformIO for any project involving the firebase library.
Finally, it matters what Wifi network you connect the XIAO to. Any 5GHz network is unacceptable for connecting a XIAO to. Most microcontrollers in the ESP32 family are designed to operate within the 2.4GHz frequency band, not in the 5GHz frequency. Also, in my experience, phone hotspot Wifi will not work with the XIAO. Surprisingly, Wifi designated for devices such as TVs is connectable for the XIAO. For example, at my college, we have a Wifi network called LyonsNet that is designated for things like Roku TVs and other devices. This was the only connectable network at my college. Our guest Wifi was not connectable, and neither was our secure college Wifi network. Both of those require authentication. Any Wifi requiring authentication is clearly not acceptable for XIAO connection.
/*********
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete instructions at https://RandomNerdTutorials.com/esp32-firebase-realtime-database/
*********/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <FirebaseClient.h>
// Network and Firebase credentials
#define WIFI_SSID "insert yours here"
#define WIFI_PASSWORD "insert yours here"
#define Web_API_KEY "insert yours here"
#define DATABASE_URL "insert yours here"
#define USER_EMAIL "insert yours here"
#define USER_PASS "insert yours here"
// User function
void processData(AsyncResult &aResult);
// Authentication
UserAuth user_auth(Web_API_KEY, USER_EMAIL, USER_PASS);
// Firebase components
FirebaseApp app;
WiFiClientSecure ssl_client;
using AsyncClient = AsyncClientClass;
AsyncClient aClient(ssl_client);
RealtimeDatabase Database;
// Timer variables for sending data every 10 seconds
unsigned long lastSendTime = 0;
const unsigned long sendInterval = 10000; // 10 seconds in milliseconds
// Variables to send to the database
String danger = "true";
int intValue = 350;
String miner_id = "hanna_miner_may7";
String timestamp = "2025-05-07T19:31:00Z";
void setup(){
Serial.println("i got to beginning of setup");
Serial.begin(9600);
// Connect to Wi-Fi
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(300);
}
Serial.println();
// Configure SSL client
ssl_client.setInsecure();
ssl_client.setConnectionTimeout(1000);
ssl_client.setHandshakeTimeout(5);
// Initialize Firebase
initializeApp(aClient, app, getAuth(user_auth), processData, "🔐 authTask");
app.getApp(Database);
Database.url(DATABASE_URL);
Serial.println("i got to end of setup");
}
void loop(){
// Maintain authentication and async tasks
app.loop();
// Check if authentication is ready
if (app.ready()){
// Periodic data sending every 10 seconds
unsigned long currentTime = millis();
if (currentTime - lastSendTime >= sendInterval){
// Update the last send time
lastSendTime = currentTime;
// send a string -- DONE
Database.set(aClient, "/readings/entry5/danger", danger, processData, "RTDB_Send_String");
Serial.println("i got into the loop after sending 1st string");
// send an int -- DONE
Database.set(aClient, "/readings/entry5/gas_ppm", intValue, processData, "RTDB_Send_Int");
// send a string -- DONE
Database.set(aClient, "/readings/entry5/miner_id", miner_id, processData, "RTDB_Send_String");
// send a string -- DONE
Database.set(aClient, "/readings/entry5/timestamp", timestamp, processData, "RTDB_Send_String");
}
}
}
void processData(AsyncResult &aResult) {
if (!aResult.isResult())
return;
if (aResult.isEvent())
Firebase.printf("Event task: %s, msg: %s, code: %d\n", aResult.uid().c_str(), aResult.eventLog().message().c_str(), aResult.eventLog().code());
if (aResult.isDebug())
Firebase.printf("Debug task: %s, msg: %s\n", aResult.uid().c_str(), aResult.debug().c_str());
if (aResult.isError())
Firebase.printf("Error task: %s, msg: %s, code: %d\n", aResult.uid().c_str(), aResult.error().message().c_str(), aResult.error().code());
if (aResult.available())
Firebase.printf("task: %s, payload: %s\n", aResult.uid().c_str(), aResult.c_str());
}
Before I moved on, I wanted to make sure that the smallest possible instance of firebase pushes would work. At this point, I just used fixed variables that looked like what we would later expect real entries to look like.
My code connecting to wifi and debugging each task, as well as sending each task (each of the items in the entry). This output in the serial monitor indicates a successful push to firebase.
Jumping ahead a bit, but this is proof that the entries show in the dashboard.
After I got the pushes with dummy variables to work, I really wanted to make a clean real-time dashboard to really cement the idea of ease of viewing the long-term effects of gas exposure. You must set up web app authentication through your firebase project. Otherwise, this is basically HTML & CSS, without a connection to the firebase database.
Gas Exposure Dashboard
Miner Gas Exposure Dashboard
Miner ID
Timestamp
Gas (ppm)
Danger
miner_ESP32C3
2025-06-19 09:49:01
5520.72
YES
miner_ESP32C3
2025-06-19 09:48:08
3924.46
YES
miner_ESP32C3
2025-06-19 09:46:05
179131.09
YES
Entries are not accurate to above (above is real-time), but this is how the UI looks.
body {
font-family: Arial, sans-serif;
padding: 30px;
background-color: #f9f9f9;
color: #333;
}
h2 {
text-align: center;
margin-bottom: 30px;
}
table {
width: 100%;
border-collapse: collapse;
margin: auto;
background-color: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
th, td {
padding: 15px;
text-align: center;
border-bottom: 1px solid #eee;
}
th {
background-color: #0077cc;
color: white;
text-transform: uppercase;
font-size: 14px;
}
tr:hover {
background-color: #f1f1f1;
}
.danger-yes {
color: white;
background-color: #e74c3c;
font-weight: bold;
padding: 5px 10px;
border-radius: 4px;
}
.danger-no {
color: white;
background-color: #2ecc71;
font-weight: bold;
padding: 5px 10px;
border-radius: 4px;
}
Next, I wanted to test the sensor code independently before integrating with LittleFS and Firebase. This involved setting up the RTC code, setting up the equation for getting the accurate ppm from the basic MQ2 sensor, and making the buzzer sound when the ppm reached above the danger threshold. The RTC module is relatively easy, it serves as a dedicated, battery-backed clock that accurately tracks the current date and time independently of the microcontroller's power state with a coin battery. The code uses the Wire library for its I2C communication protocol and the RTClib library to simplify time retrieval and setting operations. Upon initialization, the system verifies the RTC, and if it detects a loss of power, it automatically synchronizes the RTC's time with the project's compilation timestamp. This continuous timekeeping functionality allows the system to accurately timestamp sensor data, providing context for gas ppm levels. The buzzer is relatively simple, buzzing for 20 seconds if dangerPPM or greater is reached. The code for the MQ2 is a little more complicated.
The MQ2 sensor in this code operates by measuring changes in its electrical resistance when exposed to gases. This resistance reading is then processed using the relationship derived from the sensitivity characteristics graph, specifically the liquified petroleum gas (LPG) curve. LPGs are dangerous, they include butane and propane, which are explosive and can cause asphyxiation. This graph allows for the conversion of the sensor's resistance ratio (rs/ro) into an estimated gas concentration in ppm. The LPG curve was intentionally chosen for the alert threshold because its position as the lowest line on the graph means that the sensor is highly sensitive to LPG, allowing the system to detect this and similar combustible gases at lower concentrations and thus provide an earlier warning by activating the buzzer. The dangerppm variable is set to 2,000 ppm, because starting at 2,000 ppm, LPGs are considered immediately dangerous to life or health by the CDC
I developed the calculations based on this source (scroll to 'A. Equations' section).
Voltage divider formula source
Rs (Sensor Resistance in Gas) represents the resistance of the MQ2 sensor when exposed to the target gas; an increase in gas concentration results in a decrease in Rs due to enhanced sensor conductivity. R0 (Sensor Resistance in Clean Air) serves as the baseline, representing the sensor's resistance in a clean atmospheric environment. I establish this R0 value during a dedicated calibration phase, typically by averaging multiple readings taken in fresh air after the sensor has reached its stable operating temperature. This requires individual calibration, but I set it to 3.3 before my full circuit was made. My readRs function in the code calculates Rs by using a standard voltage divider circuit. The process began with reading the raw analog-to-digital converter (ADC) value from the sensor's output. This adc value is then converted into a corresponding voltage, accounting for my system's 12-bit ADC resolution (0-4095) and a 3.3V reference voltage. I calculate Rs using the voltage divider formula:
where Vin is 3.3V, Vout is the measured voltage, and RL is my loadResistor. This segment of my code directly implements the necessary electrical calculation to derive the sensor's resistance. The estimation of LPG concentration in PPM (parts per million) is performed by my estimateLPGppm function, which is directly informed by the MQ2 sensor's sensitivity curve. Referring to the graph, which displays a typical MQ2 sensitivity curve, the graph plots the Rs/R0 ratio on the Y-axis against gas concentration in PPM on the X-axis, with both axes utilizing a logarithmic scale. This logarithmic transformation is necessary because it converts the inherently non-linear, exponential relationship between the Rs/R0 ratio and gas concentration into a linear form. I can approximated the LPG curve on this log-log plot with a straight line using the linear equation Y=mX+B. In this context, Y represents log10(Rs/R0) and X represents log10(PPM). Through my analysis of the LPG line on the provided graph, I have empirically determined the equation for this line to be approximately log10(Rs/R0)=−0.38×log10(PPM)+1.15,
where m (slope) is -0.38 and B (y-intercept) is 1.15. Within the estimateLPGppm function, I first compute the rs_ro_ratio. I then convert this ratio to its base-10 logarithm (logRatio), which corresponds to the Y value in my linear model. Subsequently, I solve for logPPM (my X value) by rearranging the linear equation: log10(PPM)=(log10(Rs/R0)−B)/m.
This step puts in the slope and intercept derived from the logarithmic sensitivity curve. Finally, I convert the logarithmic PPM value back to a linear concentration by taking the inverse logarithm (PPM=10logPPM), giving the final estimated LPG concentration.
#include <Wire.h>
#include "RTClib.h"
#define MQ2pin 2 // analog pin for MQ2 sensor (can be 2 or 26)
#define Buzzer 5 // buzzer pin
#define RL_VAL 10.0 // load resistor in kΩ
#define Ro 3.3 // default Ro in clean air (kΩ), approximated
int dangerPPM = 2000; // 2000 ppm threshold for LPG alert
RTC_DS3231 rtc;
char daysOfTheWeek[7][12] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};
// function to read Rs using analog voltage
float readRs(int analogPin, float loadResistor) {
int adc = analogRead(analogPin);
// CHANGE THIS LINE:
float voltage = adc * (3.3 / 4095.0); // Corrected for 12-bit ADC (0-4095)
float rs = (3.3 - voltage) * loadResistor / voltage;
return rs;
}
// function to estimate LPG PPM based on Rs/Ro ratio
float estimateLPGppm(float rs, float ro) {
float rs_ro_ratio = rs / ro;
// LPG curve approximation from datasheet
float m = -0.38;
float b = 1.15;
float logRatio = log10(rs_ro_ratio);
float logPPM = (logRatio - b) / m;
float ppm = pow(10, logPPM);
return ppm;
}
void setup() {
Serial.begin(9600);
pinMode(Buzzer, OUTPUT);
Serial.println("MQ2 warming up!");
// allow the MQ2 to warm up
delay(20000);
// start RTC
if (!rtc.begin()) {
Serial.println("Couldn't find RTC");
while (1) delay(10);
}
if (rtc.lostPower()) {
Serial.println("RTC lost power, setting time to compile time");
rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
}
}
void loop() {
// get sensor reading and convert to PPM
float rs = readRs(MQ2pin, RL_VAL);
float ppm = estimateLPGppm(rs, Ro);
// get RTC time
DateTime now = rtc.now();
String yearStr = String(now.year(), DEC);
String monthStr = (now.month() < 10 ? "0" : "") + String(now.month(), DEC);
String dayStr = (now.day() < 10 ? "0" : "") + String(now.day(), DEC);
String hourStr = (now.hour() < 10 ? "0" : "") + String(now.hour(), DEC);
String minuteStr = (now.minute() < 10 ? "0" : "") + String(now.minute(), DEC);
String secondStr = (now.second() < 10 ? "0" : "") + String(now.second(), DEC);
String dayOfWeek = daysOfTheWeek[now.dayOfTheWeek()];
String timestamp = dayOfWeek + ", " + yearStr + "-" + monthStr + "-" + dayStr + " " + hourStr + ":" + minuteStr + ":" + secondStr;
// print readings
Serial.print("Timestamp: ");
Serial.println(timestamp);
Serial.print("Sensor Rs: ");
Serial.print(rs);
Serial.print(" kΩ, Estimated LPG PPM: ");
Serial.println(ppm);
// if ppm exceeds threshold, activate buzzer
if (ppm >= dangerPPM) {
Serial.println("Danger: Gas concentration exceeded safe limit");
digitalWrite(Buzzer, HIGH); // buzzer on
delay(20000); // give the miner time to react
digitalWrite(Buzzer, LOW); // buzzer off
}
delay(15000); // sample every 15 seconds
}
First, I want to start off with the warning about SPIFFS. It looks much easier to use than LittleFS based on the tutorials. SPIFFS has a very lightweight design and minimal memory overhead, which can make it seem easier for basic, static file storage without needing to consider more complex features like directory support or power-loss protection, which are inherent to LittleFS's design. However, DON'T USE SPIFFS!!!!!! SPIFFS is not supported by any Arduino version past version 1.8.x. It's considered depreciated past version 2.x. This in tandem with the firebase library compile time made it just about a nightmare. LittleFS is resilient to power loss and is non-depreciated.
The code starts by connecting to a Wi-Fi network and then preparing to send data to firebase. Simultaneously, it sets up LittleFS, a local file system on the XIAO itself, to create or update a file named /data.csv. This file serves as a crucial temporary storage, ensuring that no sensor readings are lost, even if the internet connection becomes unavailable. In its continuous operation, the XIAO simulates new "miner" data—including gas levels, danger status, miner ID, timestamp, and country. It saves this information to the /data.csv file. A "stopread" marker system is used to track which data has already been processed, ensuring that only the latest readings are considered. Periodically, when the Wi-Fi connection is stable, the ESP32 reads these new entries from its local storage and transmits them to the cloud database.
I used this source's code for understanding the schema of LittleFS file reading, appending, and for making writing new files. I won't paste it all here, but I used the first block of code to have something to build upon for making the file system. I developed the readAndProcessNewEntriesFromFile function using my past experience working with vectors and file handling from previous coursework (I looked at my old code from previous courses). CSV file handling is actually not too complicated, as it's basically regular text file handling but with commas separating rows and newlines separating entries.
LittleFS is designed to save files even when the XIAO is offline (no power), and the files are accessible once again when the XIAO is online. From this source:
"LittleFS is designed to handle random power failures. All file operations have strong copy-on-write guarantees and if power is lost the filesystem will fall back to the last known good state."
However, there is one line of code to change if you want the files to consistently save even when the XIAO is powered off multiple times:
#define FORMAT_LITTLEFS_IF_FAILED true
Set this to false if you do not want a new file to be made every time the XIAO is powered off and turned on again. For demo purposes, though, it should be set to true, as the user does not need the old entries from previous reads.
/*********
Rui Santos & Sara Santos - Random Nerd Tutorials (adapted)
Complete instructions at https://RandomNerdTutorials.com/esp32-firebase-realtime-database/
*********/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <FirebaseClient.h>
#include "FS.h"
#include <LittleFS.h>
#include <vector> // Include the vector library
// --- Configuration ---
// Network Credentials
#define WIFI_SSID "insert"
#define WIFI_PASSWORD "insert"
// Firebase Credentials
#define Web_API_KEY "AIzaSyDE3BncTtJQurEM3Zb-W99pMVIOlV2HOLI"
#define DATABASE_URL "https://data-check-ccce9-default-rtdb.firebaseio.com/"
#define USER_EMAIL "ondrasek_hanna@wheatoncollege.edu"
#define USER_PASS "Welcome1*"
// LittleFS Configuration
#define FORMAT_LITTLEFS_IF_FAILED true // Set to true to format LittleFS on first boot or if corrupted
// --- Global Variables ---
// Firebase Components
// Authentication
UserAuth user_auth(Web_API_KEY, USER_EMAIL, USER_PASS);
FirebaseApp app;
WiFiClientSecure ssl_client;
using AsyncClient = AsyncClientClass;
AsyncClient aClient(ssl_client);
RealtimeDatabase Database;
// Data Structure for Miner Entries
struct MinerDataEntry {
String entryNum;
String dangerStatus;
float gasPPM;
String minerId;
String timestamp;
String country;
};
// Global vector to store new data entries read from LittleFS
std::vector newEntriesToProcess;
// Timer variables for periodic tasks (Firebase push)
unsigned long lastSendTime = 0;
const unsigned long sendInterval = 10000; // 10 seconds in milliseconds (for Firebase push)
// --- Function Prototypes ---
void processFirebaseResult(AsyncResult &aResult);
void writeFile(fs::FS &fs, const char * path, const char * message);
void appendFile(fs::FS &fs, const char * path, const char * message);
void readAndProcessNewEntriesFromFile(fs::FS &fs, const char * path);
void pushEntriesToFirebase();
// --- LittleFS Helper Functions ---
void writeFile(fs::FS &fs, const char * path, const char * message){
// Serial.printf("Writing file: %s\r\n", path); // Commented out to reduce verbose output
File file = fs.open(path, FILE_WRITE);
if(!file){
Serial.println("- Failed to open file for writing");
return;
}
if(file.print(message)){
Serial.println("- File written successfully.");
} else {
Serial.println("- File write failed.");
}
file.close();
}
void appendFile(fs::FS &fs, const char * path, const char * message){
// Serial.printf("Appending to file: %s\r\n", path); // Commented out to reduce verbose output
File file = fs.open(path, FILE_APPEND);
if(!file){
Serial.println("- Failed to open file for appending");
return;
}
if(file.print(message)){
Serial.println("- Message appended successfully.");
} else {
Serial.println("- Append failed.");
}
file.close();
}
// --- Modified readFile Function for new entries ---
void readAndProcessNewEntriesFromFile(fs::FS &fs, const char * path){
Serial.println("\n--- Starting file read for new entries ---");
File file = fs.open(path, FILE_READ);
if(!file || file.isDirectory()){
Serial.println("- Failed to open file for reading (or it's a directory).");
return;
}
String currentLine = "";
bool readingNewEntries = false;
// Clear the vector before populating it with new entries from this read cycle
newEntriesToProcess.clear();
Serial.println("- newEntriesToProcess vector cleared for this cycle.");
while(file.available()){
char c = file.read();
currentLine += c;
if (c == '\n') { // End of a line/entry
if (currentLine.indexOf("stopread") != -1) {
Serial.println("--- 'stopread' marker found. Processing new entries from this point. ---");
readingNewEntries = true;
} else if (readingNewEntries) {
// This is a new entry line after the 'stopread' marker or if marker not yet found
MinerDataEntry entry; // Create a new struct instance for this entry
int lastCommaIndex = -1;
int fieldCount = 0;
// Parse the line into individual fields
for (int i = 0; i < currentLine.length(); i++) {
char fieldChar = currentLine.charAt(i);
if (fieldChar == ',' || fieldChar == '\n') {
String field = currentLine.substring(lastCommaIndex + 1, i);
field.trim(); // Remove any leading/trailing whitespace
// Assign to the correct field in the struct
switch (fieldCount) {
case 0: entry.entryNum = field; break;
case 1: entry.dangerStatus = field; break;
case 2: entry.gasPPM = field.toFloat(); break;
case 3: entry.minerId = field; break;
case 4: entry.timestamp = field; break;
case 5:
entry.country = field;
entry.country.replace("\r", ""); // Clean up potential carriage return
break;
}
fieldCount++;
lastCommaIndex = i;
}
}
// Add the parsed entry to the global vector if valid
if (fieldCount >= 6) {
newEntriesToProcess.push_back(entry);
Serial.println(" --> New entry parsed and added to queue. Details:");
// Verification prints
Serial.printf(" Entry Num: %s\n", entry.entryNum.c_str());
Serial.printf(" Danger: %s\n", entry.dangerStatus.c_str());
Serial.printf(" Gas PPM: %.2f\n", entry.gasPPM);
Serial.printf(" Miner ID: %s\n", entry.minerId.c_str());
Serial.printf(" Timestamp: %s\n", entry.timestamp.c_str());
Serial.printf(" Country: %s\n", entry.country.c_str());
Serial.println(" -------------------------------------");
} else {
Serial.printf(" Malformed entry (skipped): %s\n", currentLine.c_str());
}
}
currentLine = ""; // Reset for the next line
}
}
file.close(); // Close the file after reading
Serial.printf("- Finished reading file. %d new entries found and queued.\n", newEntriesToProcess.size());
// Append the 'stopread' marker at the end of the file
// This marks all currently read data as processed for the next cycle.
File appendFileHandle = fs.open(path, FILE_APPEND);
if(!appendFileHandle){
Serial.println("- Failed to open file for appending 'stopread' marker.");
return;
}
if(appendFileHandle.print("stopread\n")){
Serial.println("- 'stopread' marker appended to file.");
} else {
Serial.println("- Failed to append 'stopread' marker.");
}
appendFileHandle.close();
Serial.println("--- File read and marker update complete. ---");
}
// --- Firebase Push Function ---
void pushEntriesToFirebase() {
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Wi-Fi not connected. Skipping Firebase push.");
return;
}
if (!newEntriesToProcess.empty()) {
Serial.println("\n--- Pushing New Entries to Firebase Realtime Database ---");
delay(10000); // needs to be some kind of delay
for (const auto& entry : newEntriesToProcess) {
String path = "/readings/" + entry.entryNum; // Base path for this entry
// Push each field as a child node under the entry path
Database.set(aClient, path + "/danger", entry.dangerStatus, processFirebaseResult, "RTDB_danger_" + entry.entryNum);
Database.set(aClient, path + "/gas_ppm", entry.gasPPM, processFirebaseResult, "RTDB_gas_ppm_" + entry.entryNum);
Database.set(aClient, path + "/miner_id", entry.minerId, processFirebaseResult, "RTDB_miner_id_" + entry.entryNum);
Database.set(aClient, path + "/timestamp", entry.timestamp, processFirebaseResult, "RTDB_timestamp_" + entry.entryNum);
Database.set(aClient, path + "/country", entry.country, processFirebaseResult, "RTDB_country_" + entry.entryNum);
Serial.printf(" Sent Entry %s to Firebase.\n", entry.entryNum.c_str());
}
// After attempting to push all new entries, clear the vector.
// This assumes successful push or that you're fine with potential re-sends
// if Firebase fails for some entries. For production, you might want more
// granular success tracking.
newEntriesToProcess.clear();
Serial.println("- All queued entries sent to Firebase and cleared from local vector.");
} else {
Serial.println("\n--- No new entries to push to Firebase this cycle. ---");
}
}
// --- Firebase Result Processing Function ---
void processFirebaseResult(AsyncResult &aResult) {
if (!aResult.isResult())
return;
if (aResult.isEvent())
Serial.printf("Event task: %s, msg: %s, code: %d\n", aResult.uid().c_str(), aResult.eventLog().message().c_str(), aResult.eventLog().code());
if (aResult.isDebug())
Serial.printf("Debug task: %s, msg: %s\n", aResult.uid().c_str(), aResult.debug().c_str());
if (aResult.isError())
Serial.printf("Error task: %s, msg: %s, code: %d\n", aResult.uid().c_str(), aResult.error().message().c_str(), aResult.error().code());
if (aResult.available())
Serial.printf("task: %s, payload: %s\n", aResult.uid().c_str(), aResult.c_str());
}
// --- Setup Function ---
void setup(){
Serial.begin(115200); // Increased baud rate for faster output
Serial.println("\n--- Starting ESP32 Setup ---");
// Connect to Wi-Fi
Serial.printf("Connecting to Wi-Fi: %s", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(300);
}
Serial.println("\nWi-Fi Connected!");
Serial.printf("IP Address: %s\n", WiFi.localIP().toString().c_str());
// Configure SSL client for Firebase
ssl_client.setInsecure(); // This disables certificate validation, use with caution in production
ssl_client.setConnectionTimeout(1000);
ssl_client.setHandshakeTimeout(5);
// Initialize Firebase Async Client and App
initializeApp(aClient, app, getAuth(user_auth), processFirebaseResult, "🔐 authTask");
app.getApp(Database);
Database.url(DATABASE_URL);
Serial.println("Firebase Initialized.");
// Initialize LittleFS
if(!LittleFS.begin(FORMAT_LITTLEFS_IF_FAILED)){
Serial.println("LittleFS Mount Failed!");
// Consider what to do if LittleFS fails, e.g., halt or try again.
return;
} else {
Serial.println("LittleFS Mounted Successfully.");
}
// Write the header ONCE or append 'stopread' based on file existence
if (FORMAT_LITTLEFS_IF_FAILED || !LittleFS.exists("/data.csv")) {
Serial.println("Creating or formatting /data.csv with header.");
writeFile(LittleFS, "/data.csv", "entry,danger,gas_ppm,miner_id,timestamp,country\n");
} else {
Serial.println("File /data.csv already exists. Appending 'stopread' to mark previous data on boot.");
appendFile(LittleFS, "/data.csv", "stopread\n");
}
Serial.println("--- ESP32 Setup Complete ---");
}
// --- Loop Function ---
void loop(){
// Maintain Firebase authentication and async tasks
app.loop();
// Only proceed if Wi-Fi is connected and Firebase is ready
if (WiFi.status() == WL_CONNECTED && app.ready()){
// --- Data Generation and Logging to LittleFS ---
// You'd replace this with your actual sensor readings
float ppm = random(50, 200); // Simulate fluctuating gas ppm
static unsigned long entryCounter = 0; // Persistent counter for entries
entryCounter++;
String entry_num = "entry" + String(entryCounter);
String danger_status = (ppm > 150) ? "DANGER" : "SAFE";
String miner_id = "miner_ESP32C3";
// Using `unsigned long` for timestamp now for better uniqueness
String timestamp_str = String(millis());
String country_name = "USA";
// Construct the full entry string for logging
String dataToLog = entry_num + "," + danger_status + "," + String(ppm) + "," + miner_id + "," + timestamp_str + "," + country_name + "\n";
Serial.println("\n--- Appending New Data Entry to LittleFS ---");
appendFile(LittleFS, "/data.csv", dataToLog.c_str());
// --- Read New Data from LittleFS and Queue for Firebase ---
readAndProcessNewEntriesFromFile(LittleFS, "/data.csv");
// --- Periodic Firebase Push ---
unsigned long currentTime = millis();
if (currentTime - lastSendTime >= sendInterval){
lastSendTime = currentTime; // Update the last send time
pushEntriesToFirebase(); // Call the function to push queued entries
}
} else if (WiFi.status() != WL_CONNECTED) {
Serial.println("Wi-Fi is disconnected. Attempting to reconnect...");
WiFi.reconnect();
delay(1000); // Small delay before next reconnection attempt
} else {
Serial.println("Firebase not ready. Waiting for authentication.");
delay(1000); // Wait for Firebase to become ready
}
}
This version is like the code above, but instead of using dummy values for entries, we're actually getting values from the circuit board. One essential thing I noticed is that the Ro needed to be changed depending on the MQ2, which requires calibrating in clean air. Entries are also recorded pushed very very fast, because for some reason, firebase will only accept every 10th push or so. Fast entry recording offsets the fact that firebase only accepts every 10th push, because the difference between the 1st and 10th entry in the csv is basically the same. I'm still not totally sure why, I think it might have something to do with firebase limiting the amount of times you can push in a certain amount of time. Also, another thing I changed in this new code is that I made the stopread marker system better, I have a CSV keeping track of the last pushed entry so there are no duplicate entries pushed to firebase.
#include <Wire.h>
#include "RTClib.h"
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <FirebaseClient.h>
#include "FS.h"
#include <LittleFS.h>
#include <vector> // Include the vector library
// --- MQ2 Sensor & RTC Configuration ---
#define MQ2pin 2 // analog pin for MQ2 sensor (can be 2 or 26)
#define Buzzer 5 // buzzer pin
#define RL_VAL 10.0 // load resistor in kΩ
//#define Ro 3.3 // default Ro in clean air (kΩ), approximated
#define Ro 288.73
// Change your global Ro definition
//float Ro_calibrated = 0; // Initialize global Ro
int dangerPPM = 2000; // 2000 ppm threshold for LPG alert
RTC_DS3231 rtc; // RTC object
// Removed daysOfTheWeek array as it's no longer needed for the timestamp string.
// char daysOfTheWeek[7][12] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};
// --- Network & Firebase Credentials ---
#define WIFI_SSID "insert"
#define WIFI_PASSWORD "insert"
#define Web_API_KEY "AIzaSyDE3BncTtJQurEM3Zb-W99pMVIOlV2HOLI"
#define DATABASE_URL "https://data-check-ccce9-default-rtdb.firebaseio.com/"
#define USER_EMAIL "ondrasek_hanna@wheatoncollege.edu"
#define USER_PASS "Welcome1*"
// --- LittleFS Configuration ---
#define FORMAT_LITTLEFS_IF_FAILED true // Set to true to format LittleFS on first boot or if corrupted
// --- Global Variables ---
// Firebase Components
UserAuth user_auth(Web_API_KEY, USER_EMAIL, USER_PASS);
FirebaseApp app;
WiFiClientSecure ssl_client;
using AsyncClient = AsyncClientClass;
AsyncClient aClient(ssl_client);
RealtimeDatabase Database;
// Data Structure for Miner Entries
struct MinerDataEntry {
String entryNum;
String dangerStatus;
float gasPPM;
String minerId;
String timestamp;
String country;
};
// Global vector to store new data entries read from LittleFS
std::vector newEntriesToProcess;
// Timer variables for periodic tasks (Firebase push)
unsigned long lastSendTime = 0;
const unsigned long sendInterval = 10000; // 10 seconds in milliseconds (for Firebase push)
unsigned long lastSensorReadTime = 0;
const unsigned long sensorReadInterval = 1000; // 1 second in milliseconds (for sensor reading and local logging)
static unsigned long entryCounter = 0; // Persistent counter for entries
// --- Function Prototypes ---
void processFirebaseResult(AsyncResult &aResult);
void writeFile(fs::FS &fs, const char *path, const char *message);
void appendFile(fs::FS &fs, const char *path, const char *message);
void readAndProcessNewEntriesFromFile(fs::FS &fs, const char *path);
void pushEntriesToFirebase();
float readRs(int analogPin, float loadResistor);
float estimateLPGppm(float rs, float ro);
// --- MQ2 Sensor Functions ---
float readRs(int analogPin, float loadResistor) {
int adc = analogRead(analogPin);
float voltage = adc * (3.3 / 4095.0); // Corrected for 12-bit ADC 0-4095
float rs = (3.3 - voltage) * loadResistor / voltage;
return rs;
}
float estimateLPGppm(float rs, float ro) {
float rs_ro_ratio = rs / ro;
float m = -0.50; // LPG curve approximation from datasheet
float b = 1.10;
float logRatio = log10(rs_ro_ratio);
float logPPM = (logRatio - b) / m;
float ppm = pow(10, logPPM);
return ppm;
}
// --- LittleFS Helper Functions ---
void writeFile(fs::FS &fs, const char *path, const char *message) {
File file = fs.open(path, FILE_WRITE);
if (!file) {
Serial.println("- Failed to open file for writing");
return;
}
if (file.print(message)) {
Serial.println("- File written successfully.");
} else {
Serial.println("- File write failed.");
}
file.close();
}
void appendFile(fs::FS &fs, const char *path, const char *message) {
File file = fs.open(path, FILE_APPEND);
if (!file) {
Serial.println("- Failed to open file for appending");
return;
}
if (file.print(message)) {
Serial.println("- Message appended successfully.");
} else {
Serial.println("- Append failed.");
}
file.close();
}
int readLastProcessedLine(fs::FS &fs, const char *path = "/last_read.txt") {
File file = fs.open(path, FILE_READ);
if (!file) return 0;
String numStr = file.readStringUntil('\n');
file.close();
return numStr.toInt(); // Defaults to 0 if unreadable
}
void writeLastProcessedLine(fs::FS &fs, int lineNumber, const char *path = "/last_read.txt") {
File file = fs.open(path, FILE_WRITE);
if (!file) return;
file.printf("%d\n", lineNumber);
file.close();
}
void readAndProcessNewEntriesFromFile(fs::FS &fs, const char *path) {
Serial.println("\n--- Starting file read for new entries ---");
File file = fs.open(path, FILE_READ);
if (!file || file.isDirectory()) {
Serial.println("- Failed to open file for reading.");
return;
}
int lastProcessedLine = readLastProcessedLine(fs);
int currentLineNum = 0;
String currentLine = "";
newEntriesToProcess.clear();
Serial.printf("- Skipping to line %d (last processed)\n", lastProcessedLine);
while (file.available()) {
char c = file.read();
currentLine += c;
if (c == '\n') {
currentLineNum++;
if (currentLineNum <= lastProcessedLine) {
currentLine = "";
continue;
}
MinerDataEntry entry;
int lastCommaIndex = -1;
int fieldCount = 0;
for (int i = 0; i < currentLine.length(); i++) {
char fieldChar = currentLine.charAt(i);
if (fieldChar == ',' || fieldChar == '\n') {
String field = currentLine.substring(lastCommaIndex + 1, i);
field.trim();
switch (fieldCount) {
case 0: entry.entryNum = field; break;
case 1: entry.dangerStatus = field; break;
case 2: entry.gasPPM = field.toFloat(); break;
case 3: entry.minerId = field; break;
case 4: entry.timestamp = field; break; // This now correctly maps to the timestamp
case 5:
entry.country = field;
entry.country.replace("\r", "");
break;
}
fieldCount++;
lastCommaIndex = i;
}
}
if (fieldCount >= 6) {
newEntriesToProcess.push_back(entry);
Serial.printf(" --> Queued new entry %s\n", entry.entryNum.c_str());
} else {
Serial.printf(" Malformed entry on line %d (skipped): %s\n", currentLineNum, currentLine.c_str());
}
currentLine = ""; // reset for next
}
}
file.close();
Serial.printf("- Finished. %d new entries queued.\n", newEntriesToProcess.size());
if (currentLineNum > lastProcessedLine) {
writeLastProcessedLine(fs, currentLineNum);
Serial.printf("- Updated last processed line to %d\n", currentLineNum);
}
}
// --- Firebase Push Function ---
void pushEntriesToFirebase() {
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Wi-Fi not connected. Skipping Firebase push.");
return;
}
if (!newEntriesToProcess.empty()) {
Serial.println("\n--- Pushing New Entries to Firebase Realtime Database ---");
for (const auto& entry : newEntriesToProcess) {
String path = "/readings/" + entry.entryNum;
Database.set(aClient, path + "/danger", entry.dangerStatus, processFirebaseResult, "RTDB_danger_" + entry.entryNum);
Database.set(aClient, path + "/gas_ppm", entry.gasPPM, processFirebaseResult, "RTDB_gas_ppm_" + entry.entryNum);
Database.set(aClient, path + "/miner_id", entry.minerId, processFirebaseResult, "RTDB_miner_id_" + entry.entryNum);
Database.set(aClient, path + "/timestamp", entry.timestamp, processFirebaseResult, "RTDB_timestamp_" + entry.entryNum); // Corrected mapping
Database.set(aClient, path + "/country", entry.country, processFirebaseResult, "RTDB_country_" + entry.entryNum); // Corrected mapping
Serial.printf(" Sent Entry %s to Firebase.\n", entry.entryNum.c_str());
}
newEntriesToProcess.clear();
Serial.println("- All queued entries sent to Firebase and cleared from local vector.");
} else {
Serial.println("\n--- No new entries to push to Firebase this cycle. ---");
}
}
// --- Firebase Result Processing Function ---
void processFirebaseResult(AsyncResult &aResult) {
if (!aResult.isResult())
return;
if (aResult.isEvent())
Serial.printf("Event task: %s, msg: %s, code: %d\n", aResult.uid().c_str(), aResult.eventLog().message().c_str(), aResult.eventLog().code());
if (aResult.isDebug())
Serial.printf("Debug task: %s, msg: %s\n", aResult.uid().c_str(), aResult.debug().c_str());
if (aResult.isError())
Serial.printf("Error task: %s, msg: %s, code: %d\n", aResult.uid().c_str(), aResult.error().message().c_str(), aResult.error().code());
if (aResult.available())
Serial.printf("task: %s, payload: %s\n", aResult.uid().c_str(), aResult.c_str());
}
// --- Setup Function ---
void setup() {
Serial.begin(115200);
pinMode(Buzzer, OUTPUT);
Serial.println("\n--- Starting ESP32 Setup ---");
// MQ2 Sensor warm-up
Serial.println("MQ2 warming up!");
delay(20000); // Allow the MQ2 to warm up
// Start RTC
if (!rtc.begin()) {
Serial.println("Couldn't find RTC");
while (1) delay(10);
}
if (rtc.lostPower()) {
Serial.println("RTC lost power, setting time to compile time");
rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
}
Serial.println("RTC Initialized.");
// Connect to Wi-Fi
Serial.printf("Connecting to Wi-Fi: %s", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(300);
}
Serial.println("\nWi-Fi Connected!");
Serial.printf("IP Address: %s\n", WiFi.localIP().toString().c_str());
// Configure SSL client for Firebase
ssl_client.setInsecure(); // This disables certificate validation, use with caution in production
ssl_client.setConnectionTimeout(1000);
ssl_client.setHandshakeTimeout(5);
// Initialize Firebase Async Client and App
initializeApp(aClient, app, getAuth(user_auth), processFirebaseResult, "🔐 authTask");
app.getApp(Database);
Database.url(DATABASE_URL);
Serial.println("Firebase Initialized.");
// Initialize LittleFS
if (!LittleFS.begin(FORMAT_LITTLEFS_IF_FAILED)) {
Serial.println("LittleFS Mount Failed!");
return;
} else {
Serial.println("LittleFS Mounted Successfully.");
}
}
// --- Loop Function ---
void loop() {
app.loop(); // Maintain Firebase authentication and async tasks
// Check if Wi-Fi is connected and Firebase is ready
if (WiFi.status() == WL_CONNECTED && app.ready()) {
unsigned long currentTime = millis();
// --- Gas Sensor Reading and Local Logging (every 1 second) ---
if (currentTime - lastSensorReadTime >= sensorReadInterval) {
lastSensorReadTime = currentTime;
// Get sensor reading and convert to PPM
float rs = readRs(MQ2pin, RL_VAL);
float ppm = estimateLPGppm(rs, Ro);
// Get RTC time
DateTime now = rtc.now();
String yearStr = String(now.year(), DEC);
String monthStr = (now.month() < 10 ? "0" : "") + String(now.month(), DEC);
String dayStr = (now.day() < 10 ? "0" : "") + String(now.day(), DEC);
String hourStr = (now.hour() < 10 ? "0" : "") + String(now.hour(), DEC);
String minuteStr = (now.minute() < 10 ? "0" : "") + String(now.minute(), DEC);
String secondStr = (now.second() < 10 ? "0" : "") + String(now.second(), DEC);
// Corrected timestamp format: YYYY-MM-DD HH:MM:SS
String timestamp = yearStr + "-" + monthStr + "-" + dayStr + " " + hourStr + ":" + minuteStr + ":" + secondStr;
// Determine danger status and activate buzzer if necessary
String danger_status = (ppm >= dangerPPM) ? "DANGER" : "SAFE";
if (ppm >= dangerPPM) {
Serial.println("Danger: Gas concentration exceeded safe limit");
digitalWrite(Buzzer, HIGH); // buzzer on
} else {
digitalWrite(Buzzer, LOW); // buzzer off
}
// Print readings to serial monitor
Serial.print("Timestamp: ");
Serial.println(timestamp);
Serial.print("Sensor Rs: ");
Serial.print(rs);
Serial.print(" kΩ, Estimated LPG PPM: ");
Serial.println(ppm);
Serial.print("Danger Status: ");
Serial.println(danger_status);
// Prepare data for logging
entryCounter++;
String entry_num = "entry" + String(entryCounter);
String miner_id = "miner_ESP32C3"; // Placeholder
String country_name = "USA"; // Corrected country string
// Corrected order for logging to LittleFS
String dataToLog = entry_num + "," + danger_status + "," + String(ppm) + "," + miner_id + "," + timestamp + "," + country_name + "\n";
Serial.println("\n--- Appending New Data Entry to LittleFS ---");
appendFile(LittleFS, "/data.csv", dataToLog.c_str());
// Read newly appended data from LittleFS and queue for Firebase
readAndProcessNewEntriesFromFile(LittleFS, "/data.csv");
}
// --- Periodic Firebase Push (every 10 seconds) ---
if (currentTime - lastSendTime >= sendInterval) {
lastSendTime = currentTime; // Update the last send time
pushEntriesToFirebase(); // Call the function to push queued entries
}
} else if (WiFi.status() != WL_CONNECTED) {
Serial.println("Wi-Fi is disconnected. Attempting to reconnect...");
WiFi.reconnect();
delay(1000); // Small delay before next reconnection attempt
} else {
Serial.println("Firebase not ready. Waiting for authentication.");
delay(1000); // Wait for Firebase to become ready
}
}
Please see Week 16 for more detailed information on the development of the circuit board & the little tracker box the components are housed in. However, I will summarize here.
For a long time (few weeks), I was actually stuck on this project because of the circuit board. The code and components would only intermittently work, even though everything looked to be correct. Most of the time, when the active buzzer was introduced, everything would stop working. What I discovered is that the active buzzer absolutely needs an NPN transistor. It can't be directly connected to 5V & GND. The NPN transistor helps amplify the current in the board to the buzzer.
PCB with transistor. Pin E of the transistor is connected to the bottom left single pinheader, which is where the negative end of the active buzzer goes.
The milled board with all components. I used thick red wire and shrink tubes for some of the components, but I would recommend using thinner wire, even if it means just using jumper cables if they're the thinnest wire you've got.
Please see Week 16 for more detailed information on the print. However, I used generic PLA, and made my box in Fusion360. I measured parts such as the circuit board and the MQ2 sensor size to make holes and tabs that would neatly fit each component. In the demo, you can't even hear any rattling when I shake the box. The little knob for clipping on a carabiner is also pretty clever, you want to make sure the material is nice and thick, about .5" so it doesn't snap off.
Multiple angles of the print.
Everything fits! How cool! The microcontroller is powered by the USB-C in this photo. The XIAO is absolutely capable of being powered by a Li-Polymer battery by soldering to the + and - pads on the back. If you look closely at the circuit board, you'll see that I had cut a little rectangle in my edge cut rectangle line for easy access. However, I didn't want the database getting cluttered (there could be tens of thousands of entries by now), so I didn't solder the battery. on
Components in the box & working. Neat!
Name | Description | Price (USD) |
---|---|---|
Seeed XIAO ESP32C3 (available in lab) | The microcontroller for my board | $4.99/unit |
Copper Clad PCB Laminate (available in lab) | Used for milling circuit board | $0.70/unit |
Generic PLA (available in lab) | Used for 3D printing the tracker box | I used roughly $1 for 10 hours of cumulative printing |
Socket Headers (available in lab) | For connecting components to PCB traces | $0.38/unit |
NPN Transistor (available in lab) | Amplifies current for active buzzer | $0.55/unit |
Wire (available in lab) | For attaching components to headers. | $2.95/unit |
Heat Shrink (available in lab) | For securing wire around component headers | $4.95/unit |
MQ-2 Gas and Smoke Analog Sensor 5V (bought) | Gas sensor | $2.40/unit |
USB-C Data Transfer Cable (bought) | Upload code to the XIAO microcontroller | $13.59/unit |
5V Active Buzzer Electronic Alarm Magnetic(bought) | Alerts miners of danger if gases are present | $0.70/unit |
DS3231 AT24C32 Clock Module Real Time Clock Module IIC RTC (available in lab) | Returns accurate timestamp without Wifi | $2.97/unit (incl. battery) |
At the time of my presentation, I had milled my circuit board, but I had not yet attached the components yet. I was still using the breadboard, and while the functionality was there, I definitely needed a board and its housing. That was what Professor Gershenfeld told me. I have managed to make the board work and make the board work in its housing. My professor also told me to make sure my final project showed the board working in the box (housing), as well as the firebase realtime database, and the cool miner dashboard. I have clearly accomplished these objectives.
Will my project fall into obscurity, a mere cool idea? Or will it persist? Can I bring myself to put myself out there and promote this project? Really, there's not too much I need to add to make this super functional. I need to attach a battery and maybe make the housing more attractive/artistic, and maybe send it off to get professionally milled. I think this project could definitely be helpful for many people around the world, and for researchers. If there just so happens to be someone out there trying to study the long-term effects of gas exposure in mines right now, I would definitely reach out. Exploitation of mining employees is a major issue, as we continue to mine lithium, cobalt, nickel, and copper at increasingly rapid rates for technology such as electric vehicle batteries. As the world pushes all-electric, we have to consider the exploitation of miners and the long-term health consequences they suffer.
License:
MineGuard © 2025 by Hanna Ondrasek is licensed under CC BY-NC-SA 4.0