Top 5 Arduino & ESP32 Projects for Science Fairs (With Code & Circuit)

Top 5 Arduino & ESP32 Projects for Science Fairs (With Code & Circuit)

Science fair judges look for projects that solve a real problem, demonstrate a clear hypothesis, and produce measurable data. These 5 projects tick all three boxes — they're original, visually impressive, and backed by real sensor readings you can turn into a proper scientific report. Each one has won at regional or national level before.

Project 1 — Earthquake Early Warning Detector

Project Description

An MPU-6050 accelerometer/gyroscope detects the P-waves (primary waves) of an earthquake — the fast, low-amplitude waves that arrive before the damaging S-waves. When P-wave characteristics are detected, a buzzer sounds and a Telegram alert is sent. P-waves travel at ~6 km/s giving 10–90 seconds of warning before the S-wave arrives for distant earthquakes. Excellent hypothesis: "Can low-cost MEMS sensors detect earthquake P-waves?"

Components: ESP32, MPU-6050 accelerometer/gyroscope, buzzer, red LED, 220Ω resistor, breadboard, jumper wires.

Circuit Description

MPU-6050ESP32 Pin
SDAGPIO 21
SCLGPIO 22
VCC3.3V
GNDGND
INTGPIO 4 (optional interrupt)

Code

#include <Wire.h>
#include <MPU6050.h>
#include <WiFi.h>
#include <HTTPClient.h>

MPU6050 mpu;
#define BUZZER 26
#define LED    27

const char* ssid     = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* botToken = "YOUR_BOT_TOKEN";
const char* chatID   = "YOUR_CHAT_ID";

// P-wave detection threshold — tune based on your baseline noise floor
#define P_WAVE_THRESHOLD 4000  // raw accelerometer units

float baseline_ax = 0, baseline_ay = 0, baseline_az = 0;
const int SAMPLES = 200;

void calibrate() {
  Serial.println("Calibrating — keep sensor still...");
  long sx=0, sy=0, sz=0;
  for (int i = 0; i < SAMPLES; i++) {
    int16_t ax,ay,az,gx,gy,gz;
    mpu.getMotion6(&ax,&ay,&az,&gx,&gy,&gz);
    sx+=ax; sy+=ay; sz+=az; delay(5);
  }
  baseline_ax = sx/SAMPLES; baseline_ay = sy/SAMPLES; baseline_az = sz/SAMPLES;
  Serial.printf("Baseline: %.0f, %.0f, %.0f\n", baseline_ax, baseline_ay, baseline_az);
}

void setup() {
  Serial.begin(115200);
  Wire.begin();
  mpu.initialize();
  pinMode(BUZZER, OUTPUT); pinMode(LED, OUTPUT);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  calibrate();
  Serial.println("Earthquake detector ready.");
}

void loop() {
  int16_t ax,ay,az,gx,gy,gz;
  mpu.getMotion6(&ax,&ay,&az,&gx,&gy,&gz);

  float da = abs(ax - baseline_ax) + abs(ay - baseline_ay) + abs(az - baseline_az);

  if (da > P_WAVE_THRESHOLD) {
    Serial.printf("P-WAVE DETECTED! Magnitude proxy: %.0f\n", da);
    digitalWrite(LED, HIGH); digitalWrite(BUZZER, HIGH);

    HTTPClient http;
    String url = "https://api.telegram.org/bot" + String(botToken) +
                 "/sendMessage?chat_id=" + chatID +
                 "&text=⚠️ P-Wave detected! Magnitude proxy: " + String(da, 0);
    http.begin(url); http.GET(); http.end();

    delay(10000); // refractory period
    digitalWrite(LED, LOW); digitalWrite(BUZZER, LOW);
  }
  delay(10);
}

Conclusion

This project directly models how Japan's ShakeAlert and Mexico's SASMEX systems work. For your science fair report, compare your sensor's detection against the USGS earthquake feed for your region, calculate your false-positive rate, and propose threshold tuning methods. Next step: deploy multiple sensors at different distances to triangulate epicentre location.

Project 2 — Mobile Air Pollution Mapper

Project Description

An ESP32 on a bicycle or pushchair reads PM2.5 particulate levels (PMS5003 sensor) and GPS coordinates every 10 seconds. When back home, the CSV log is uploaded to Google MyMaps to create a colour-coded pollution map of your neighbourhood. Hypothesis: "Are pollution levels higher near busy roads or at certain times of day?"

Components: ESP32, PMS5003 particulate sensor, NEO-6M GPS module, SD card module, 18650 LiPo battery.

Circuit Description

ComponentESP32 Pin
PMS5003 TXGPIO 16 (UART2 RX)
PMS5003 RXGPIO 17 (UART2 TX)
NEO-6M TXGPIO 4 (Serial1 RX via SoftwareSerial)
SD Module CSGPIO 5
SD MOSI/MISO/SCKGPIO 23/19/18

Code

#include <HardwareSerial.h>
#include <TinyGPS++.h>
#include <SD.h>
#include <SPI.h>

HardwareSerial pmsSerial(2); // UART2 for PMS5003
HardwareSerial gpsSerial(1); // UART1 for GPS
TinyGPSPlus gps;
#define SD_CS 5

struct PMS5003Data { uint16_t pm1_0, pm2_5, pm10; };

bool readPMS(PMS5003Data &d) {
  if (pmsSerial.available() < 32) return false;
  uint8_t buf[32];
  if (pmsSerial.read() != 0x42 || pmsSerial.read() != 0x4D) return false;
  pmsSerial.readBytes(buf, 30);
  d.pm1_0 = (buf[2]<<8)|buf[3];
  d.pm2_5 = (buf[4]<<8)|buf[5];
  d.pm10  = (buf[6]<<8)|buf[7];
  return true;
}

void setup() {
  Serial.begin(115200);
  pmsSerial.begin(9600, SERIAL_8N1, 16, 17);
  gpsSerial.begin(9600, SERIAL_8N1, 4, -1);
  SD.begin(SD_CS);
  if (!SD.exists("/pollution.csv")) {
    File f = SD.open("/pollution.csv", FILE_WRITE);
    f.println("datetime,lat,lng,pm1_0,pm2_5,pm10");
    f.close();
  }
  Serial.println("Pollution mapper ready. Start moving!");
}

void loop() {
  // Feed GPS
  while (gpsSerial.available()) gps.encode(gpsSerial.read());

  PMS5003Data pms;
  if (readPMS(pms) && gps.location.isValid()) {
    String row = String(gps.date.year()) + "-" + String(gps.date.month()) + "-" + String(gps.date.day()) +
                 " " + String(gps.time.hour()) + ":" + String(gps.time.minute()) + "," +
                 String(gps.location.lat(), 6) + "," +
                 String(gps.location.lng(), 6) + "," +
                 String(pms.pm1_0) + "," + String(pms.pm2_5) + "," + String(pms.pm10);
    File f = SD.open("/pollution.csv", FILE_APPEND);
    if (f) { f.println(row); f.close(); }
    Serial.println(row);
    delay(10000);
  }
}

Conclusion

GPS-tagged pollution mapping is exactly what environmental researchers use in real urban air quality studies. For your report: plot the data in Google MyMaps, identify hotspots, and correlate with traffic density or time of day. Next step: add a second sensor run on a different route on the same day to compare air quality across different parts of your town.

Project 3 — UV Index & Sun Exposure Tracker

Project Description

A VEML6075 UV sensor measures UVA and UVB intensity and calculates the WHO UV Index every 30 seconds. A buzzer and red LED warn when UV index exceeds 6 (high) or 8 (very high). A daily maximum and cumulative dose are tracked and sent as a Telegram summary each evening. Hypothesis: "How does UV exposure vary by time of day, weather, and season?"

Components: ESP32, VEML6075 UV sensor, buzzer, red LED, 220Ω resistor, breadboard, jumper wires.

Circuit Description

VEML6075ESP32 Pin
SDAGPIO 21
SCLGPIO 22
VCC3.3V
GNDGND

Code

#include <Wire.h>
#include <Adafruit_VEML6075.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <time.h>

Adafruit_VEML6075 uv;
#define BUZZER 26
#define LED    27

const char* ssid     = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* botToken = "YOUR_BOT_TOKEN";
const char* chatID   = "YOUR_CHAT_ID";

float dailyMaxUVI = 0;
float dailyDose   = 0; // J/m2 approximate
int lastHour = -1;

void setup() {
  Serial.begin(115200);
  uv.begin();
  pinMode(BUZZER, OUTPUT); pinMode(LED, OUTPUT);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  configTime(0, 3600, "pool.ntp.org"); // adjust UTC offset for your timezone
}

void loop() {
  float uvi = uv.readUVI();
  dailyDose += uvi * 0.025 * 30; // rough MED accumulation (30s interval)
  if (uvi > dailyMaxUVI) dailyMaxUVI = uvi;

  String risk = "Low";
  if (uvi >= 11) risk = "Extreme";
  else if (uvi >= 8) risk = "Very High";
  else if (uvi >= 6) risk = "High";
  else if (uvi >= 3) risk = "Moderate";

  Serial.printf("UVI: %.1f | Risk: %s | Daily max: %.1f\n", uvi, risk.c_str(), dailyMaxUVI);
  digitalWrite(LED,   uvi >= 6 ? HIGH : LOW);
  digitalWrite(BUZZER, uvi >= 8 ? HIGH : LOW);

  struct tm t; getLocalTime(&t);
  if (t.tm_hour == 20 && lastHour != 20) {
    HTTPClient http;
    String msg = "☀️ Daily UV Summary:\nMax UVI: " + String(dailyMaxUVI,1) +
                 "\nCumulative dose: " + String(dailyDose,0) + " J/m²";
    String url = "https://api.telegram.org/bot" + String(botToken) +
                 "/sendMessage?chat_id=" + String(chatID) + "&text=" + msg;
    http.begin(url); http.GET(); http.end();
    dailyMaxUVI = 0; dailyDose = 0;
  }
  lastHour = t.tm_hour;
  delay(30000);
}

Conclusion

UV tracking has direct real-world health relevance — this is exactly how sports coaches and outdoor workers monitor safe sun exposure limits. For your report: graph UVI over a week, identify the daily peak window, and calculate cumulative MED (Minimal Erythemal Dose) on sunny vs cloudy days. Next step: add a GPS to compare UV at different altitudes — UV increases approximately 10% per 1000m elevation.

Project 4 — Heart Rate Variability Monitor

Project Description

A MAX30105 optical sensor reads pulse and SpO2. The ESP32 calculates BPM and heart rate variability (HRV) — the millisecond variation between beats — which is a proven indicator of stress, recovery, and fitness level. Graphs are displayed live on a web browser. Hypothesis: "Does HRV change measurably before and after exercise or a stressful event?"

Components: ESP32, MAX30105 pulse/SpO2 sensor, 0.96" OLED, breadboard, jumper wires.

Circuit Description

MAX30105ESP32 Pin
SDAGPIO 21
SCLGPIO 22
VCC3.3V
GNDGND
INTNot required

Code

#include <Wire.h>
#include <"MAX30105.h">
#include <"heartRate.h">
#include <Adafruit_SSD1306.h>

MAX30105 particleSensor;
Adafruit_SSD1306 display(128, 64, &Wire, -1);

const byte RATE_SIZE = 8;
byte rates[RATE_SIZE];
byte rateSpot = 0;
long lastBeat = 0;
float bpm = 0, avgBPM = 0;
long rrIntervals[10];
byte rrIndex = 0;

float calculateHRV() {
  // RMSSD: root mean square of successive differences
  float sum = 0;
  for (int i = 1; i < 10; i++) {
    float diff = rrIntervals[i] - rrIntervals[i-1];
    sum += diff * diff;
  }
  return sqrt(sum / 9.0);
}

void setup() {
  Serial.begin(115200);
  particleSensor.begin(Wire, I2C_SPEED_FAST);
  particleSensor.setup();
  particleSensor.setPulseAmplitudeRed(0x0A);
  particleSensor.setPulseAmplitudeGreen(0);
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  display.setTextColor(WHITE);
}

void loop() {
  long irValue = particleSensor.getIR();
  if (checkForBeat(irValue)) {
    long delta = millis() - lastBeat;
    lastBeat = millis();
    bpm = 60 / (delta / 1000.0);
    rrIntervals[rrIndex % 10] = delta; rrIndex++;

    if (bpm > 20 && bpm < 200) {
      rates[rateSpot++ % RATE_SIZE] = (byte)bpm;
      avgBPM = 0;
      for (byte x : rates) avgBPM += x;
      avgBPM /= RATE_SIZE;
    }
  }

  float hrv = (rrIndex >= 10) ? calculateHRV() : 0;

  display.clearDisplay();
  display.setTextSize(1); display.setCursor(0,0); display.println("Heart Rate Monitor");
  display.setTextSize(2); display.setCursor(0,16);
  display.printf("%.0f BPM", avgBPM);
  display.setTextSize(1); display.setCursor(0,42);
  display.printf("HRV: %.1f ms", hrv);
  display.setCursor(0,54);
  display.print(irValue < 50000 ? "Place finger on sensor" : "Measuring...");
  display.display();
  delay(20);
}

Conclusion

HRV is used by elite athletes and sports scientists as the gold standard for readiness monitoring. For your report: measure HRV at rest, immediately after 2 minutes of exercise, and 5 minutes after recovery — plot the recovery curve and compare to published HRV recovery benchmarks. Next step: add a data logging mode that saves 5-minute HRV averages with timestamps to compare morning vs evening or weekday vs weekend stress levels.

Project 5 — Smart Parking Space Counter

Project Description

Ultrasonic sensors at each parking space detect whether a car is present. An ESP32 counts available spaces, displays them on an LED matrix sign, and updates a live web dashboard. A second ESP32 at the car park entrance shows "FULL" or "X SPACES FREE" on a large OLED. Hypothesis: "Can a low-cost sensor network reduce time spent searching for parking and therefore reduce emissions?"

Components: ESP32 (x2), 4 x HC-SR04 ultrasonic sensors, 8x8 MAX7219 LED matrix, 1.3" OLED, breadboard, jumper wires.

Circuit Description

ComponentESP32 #1 (sensor node) Pin
Space 1 HC-SR04 TRIG/ECHOGPIO 5 / GPIO 18
Space 2 HC-SR04 TRIG/ECHOGPIO 19 / GPIO 21
Space 3 HC-SR04 TRIG/ECHOGPIO 22 / GPIO 23
Space 4 HC-SR04 TRIG/ECHOGPIO 25 / GPIO 26
MAX7219 DIN/CLK/CSGPIO 13 / GPIO 14 / GPIO 15

Code

#include <WiFi.h>
#include <WebServer.h>
#include <MD_Parola.h>
#include <MD_MAX72xx.h>
#include <SPI.h>

#define HARDWARE_TYPE MD_MAX72XX::FC16_HW
#define MAX_DEVICES 4
#define DATA_PIN 13
#define CLK_PIN  14
#define CS_PIN   15
MD_Parola display = MD_Parola(HARDWARE_TYPE, DATA_PIN, CLK_PIN, CS_PIN, MAX_DEVICES);

const int TRIG[] = {5,  19, 22, 25};
const int ECHO[] = {18, 21, 23, 26};
#define TOTAL_SPACES 4
#define OCCUPIED_DIST 30 // cm: car present if distance < this

WebServer server(80);
const char* ssid     = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";

long getDistance(int t, int e) {
  digitalWrite(t, LOW); delayMicroseconds(2);
  digitalWrite(t, HIGH); delayMicroseconds(10);
  digitalWrite(t, LOW);
  return pulseIn(e, HIGH, 30000) / 58;
}

void handleRoot() {
  int free = 0;
  String rows = "";
  for (int i = 0; i < TOTAL_SPACES; i++) {
    long d = getDistance(TRIG[i], ECHO[i]);
    bool occupied = (d < OCCUPIED_DIST && d > 0);
    if (!occupied) free++;
    rows += "<tr><td>Space " + String(i+1) + "</td><td style='color:" +
            (occupied ? "#ef4444'>OCCUPIED" : "#22c55e'>FREE") + "</td></tr>";
  }
  String html = "<html><head><meta charset='UTF-8'>";
  html += "<meta http-equiv='refresh' content='3'><title>Parking</title>";
  html += "<style>body{font-family:sans-serif;max-width:400px;margin:auto;padding:20px;}";
  html += "table{width:100%;border-collapse:collapse;}td{padding:12px;border:1px solid #ddd;}</style></head><body>";
  html += "<h2>🅿️ Parking (" + String(free) + "/" + String(TOTAL_SPACES) + " free)</h2>";
  html += "<table>" + rows + "</table></body></html>";
  server.send(200, "text/html", html);
}

void setup() {
  Serial.begin(115200);
  for (int i = 0; i < TOTAL_SPACES; i++) {
    pinMode(TRIG[i], OUTPUT); pinMode(ECHO[i], INPUT);
  }
  display.begin(); display.setIntensity(5); display.displayClear();
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println("IP: " + WiFi.localIP().toString());
  server.on("/", handleRoot); server.begin();
}

void loop() {
  server.handleClient();
  int free = 0;
  for (int i = 0; i < TOTAL_SPACES; i++) {
    long d = getDistance(TRIG[i], ECHO[i]);
    if (d >= OCCUPIED_DIST || d == 0) free++;
  }
  String msg = free == 0 ? "FULL" : String(free) + " FREE";
  display.displayScroll(msg.c_str(), PA_CENTER, PA_SCROLL_LEFT, 50);
  while (!display.displayAnimate()) delay(10);
  delay(2000);
}

Conclusion

Smart parking systems are a real smart city technology — cities like Amsterdam and San Francisco use IoT sensor networks to guide drivers to free spaces, cutting congestion and emissions. For your report: measure how long it takes to find a space in your school or local car park without the system vs with it, calculate the average idle driving time saved, and multiply by a car's CO2/km figure to estimate environmental impact. Next step: add license plate recognition using the ESP32-CAM to log which cars use which spaces, or implement dynamic pricing using the occupancy data.


More ESP32 & Arduino Project Guides

Post a Comment

0 Comments