logo

Home Final Project Miner Dashboard About Me

Final Project Documentation

Product, Presentation & Demo

Product Design

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.

Demo

See how the device makes real-time pushes to the database and dashboard!

Official Presentation + Presentation slide

What does MineGuard do?

From this website's homepage:

Immediate alerts. Deferred reporting. Reliable protection.

With MineGuard, miners are warned of danger in real-time underground. Exposure data syncs automatically once back above ground.

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.

The Code

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.

This was the code I used for experimentation with firebase and the Wifi connection (template from here, I made some modifications ):

        
          /*********
  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());
}

Verifying successful pushes to firebase

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.

Proof that my push to firebase with fixed variables work:

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.

UI (Real-Time Miner Gas Exposure 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.

HTML & CSS for UI (sorry i had to encode the HTML):

<!DOCTYPE html> <html> <head> <link rel="stylesheet" href="miner_css.css"> <title>Gas Exposure Dashboard</title> </head> <body> <h2>Miner Gas Exposure Dashboard</h2> <table> <thead> <tr> <th>Miner ID</th> <th>Timestamp</th> <th>Gas (ppm)</th> <th>Danger</th> </tr> </thead> <tbody id="data-table"></tbody> </table> <script src="https://www.gstatic.com/firebasejs/9.22.2/firebase-app-compat.js"></script> <script src="https://www.gstatic.com/firebasejs/9.22.2/firebase-database-compat.js"></script> <script> const firebaseConfig = { apiKey: "AIzaSyDE3BncTtJQurEM3Zb-W99pMVIOlV2HOLI", authDomain: "data-check-ccce9.firebaseapp.com", databaseURL: "https://data-check-ccce9-default-rtdb.firebaseio.com", projectId: "data-check-ccce9", storageBucket: "data-check-ccce9.firebasestorage.app", messagingSenderId: "754736489688", appId: "1:754736489688:web:207e7573604c6daea863ad", measurementId: "G-5HWVZWR6K6" }; firebase.initializeApp(firebaseConfig); const dbRef = firebase.database().ref("readings"); dbRef.on("value", (snapshot) => { const data = snapshot.val(); const table = document.getElementById("data-table"); table.innerHTML = ""; // Clear existing for (let key in data) { const entry = data[key]; const row = document.createElement("tr"); row.innerHTML = ` <td>${entry.miner_id}</td> <td>${entry.timestamp}</td> <td>${entry.gas_ppm}</td> <td style="color:${entry.danger ? 'red' : 'green'}">${entry.danger ? 'YES' : 'NO'}</td> `; table.appendChild(row); } }); </script> </body> </html>

Shows:

        
          



  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;
          }
        
      

The Code, Continued: Sensor Code, LittleFS Code, and Putting it All Together

Sensor Code

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.

RTC Clock Time Formatting

Code for RTC module from this source, the code I used is the first example given. It explains how to format the clock time and getting the time when initially setting up the module or resetting the time if the rtc fails/loses power from its coin battery.

MQ2 Explanation

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

Explanation of the mathematical calculations from analogread sensor readings to PPM

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
}

        
      

Next Code Section: LittleFS integrated with Firebase Pushes and Why SPIFFS Sucks

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.

Sources for LittleFS portion of code/CSV file handling

Source for LittleFS base code

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.

The tracking of un-sent database entries while the ESP is offline, and clever updating when wifi is restored

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
    }
}
  

FULLY INTEGRATED WORKING CODE

  • Code download
  • 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
        }
    }
      
    

    Parts & Systems Made

    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.

    Circuit Board

  • Board file download
  • 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.

    Connections:

    RTC clock module

  • GND --> GND
  • VCC --> 3.3V
  • SDA --> GPIO6 (SDA)
  • SCL --> GPIO7 (SCL)
  • MQ2

  • GND --> GND
  • VCC --> 5V (THIS MUST BE 5V --> you can get away with 3.3V for the RTC, but the MQ2 needs to get hot enough to make proper reads)
  • A0 --> GPIO2 (true analog pin)
  • Active Buzzer

  • Positive end --> 5V
  • Negative end --> NPN transistor pin E (see buzzer diagram below)
  • 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.

    3D printed tracker box

  • Packaging file download
  • 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!

    Detailed Materials & Components List

    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)

    How was it evaluated? What are the implications?

    Evaluation

    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.

    Implications

    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