A self-contained networked stopwatch built on the ESP32. Control it from any browser, see elapsed time on an LED matrix, and export results as CSV - no internet required.
| Component | Function | ESP32 GPIO |
|---|---|---|
| MAX7219 | CS (Chip Select) | GPIO 5 |
| Buzzer | Signal | GPIO 27 |
The ESP32 runs in SoftAP mode - it creates a standalone Wi-Fi access point. No router or internet connection is needed. Connect any phone or laptop directly to the device.
The ESP32 hosts a full HTML/CSS/JavaScript web interface stored in flash memory. Open a browser, navigate to the device IP, and you have a complete stopwatch UI with start, stop, reset, and result logging.
While the browser shows elapsed time, the MAX7219-driven LED matrix renders the same time in M:SS:HS format. This gives swimmers immediate visible feedback from poolside without needing a screen.
Each finished time is saved to the ESP32's SPIFFS filesystem as a CSV entry with the athlete name, pool, stroke, distance, and time. Download the CSV at any time or access results as JSON.
| Endpoint | Method | Description |
|---|---|---|
/ | GET | Web UI |
/start | GET | Start stopwatch |
/stop | GET | Stop stopwatch |
/reset | GET | Reset stopwatch |
/time | GET | Current time string |
/finish | GET | Record result and reset |
/download | GET | Download CSV |
/clear | POST | Clear stored results |
/results.json | GET | Results as JSON |
This project uses multiple header files to keep the firmware organized. The full repo is on GitHub. Below is the main sketch.
// ESP32 Wi-Fi Stopwatch
// Full source available on GitHub
#include <WiFi.h>
#include <WebServer.h>
#include <SPIFFS.h>
#include <SPI.h>
#include <Max72xxPanel.h>
#include "html.h"
#include "css.h"
#include "js.h"
// ---- Wi-Fi AP config ----
const char* AP_SSID = "ESP32-Stopwatch";
const char* AP_PASS = "stopwatch123";
// ---- Hardware pins ----
const int PIN_CS = 5;
const int PIN_BUZZ = 27;
const int PANELS = 4;
Max72xxPanel matrix = Max72xxPanel(PIN_CS, PANELS, 1);
WebServer server(80);
String resultLog = "";
// ---- Timing state ----
bool running = false;
unsigned long startMs = 0;
unsigned long elapsedMs = 0;
String formatTime(unsigned long ms) {
unsigned long total = ms / 10;
unsigned long hs = total % 100;
unsigned long sec = (total / 100) % 60;
unsigned long min = total / 6000;
char buf[16];
snprintf(buf, sizeof(buf), "%lu:%02lu:%02lu", min, sec, hs);
return String(buf);
}
void updateMatrix() {
unsigned long now = running ? elapsedMs + (millis() - startMs) : elapsedMs;
String t = formatTime(now);
matrix.fillScreen(0);
matrix.setCursor(0, 0);
matrix.print(t);
matrix.write();
}
void beep(int ms) {
digitalWrite(PIN_BUZZ, HIGH);
delay(ms);
digitalWrite(PIN_BUZZ, LOW);
}
void setup() {
Serial.begin(115200);
pinMode(PIN_BUZZ, OUTPUT);
// Matrix init
matrix.setIntensity(5);
matrix.setRotation(0, 1);
matrix.fillScreen(0);
matrix.write();
// SPIFFS
if (!SPIFFS.begin(true)) {
Serial.println("SPIFFS mount failed");
} else {
File f = SPIFFS.open("/results.csv", "r");
if (f) { resultLog = f.readString(); f.close(); }
}
// Wi-Fi AP
WiFi.softAP(AP_SSID, AP_PASS);
Serial.print("AP IP: "); Serial.println(WiFi.softAPIP());
// Routes
server.on("/", handleRoot);
server.on("/app.css", [](){ server.send_P(200, "text/css", APP_CSS); });
server.on("/app.js", [](){ server.send_P(200, "application/javascript", APP_JS); });
server.on("/start", []() {
if (!running) { running = true; startMs = millis(); beep(100); }
server.send(200, "text/plain", "ok");
});
server.on("/stop", []() {
if (running) { elapsedMs += millis() - startMs; running = false; }
server.send(200, "text/plain", "ok");
});
server.on("/reset", []() {
running = false; elapsedMs = 0;
server.send(200, "text/plain", "ok");
});
server.on("/time", []() {
unsigned long now = running ? elapsedMs + (millis() - startMs) : elapsedMs;
server.send(200, "text/plain", formatTime(now));
});
server.on("/finish", []() {
if (running) { elapsedMs += millis() - startMs; running = false; }
String name = server.arg("name");
String pool = server.arg("pool");
String stroke = server.arg("stroke");
String distance = server.arg("distance");
String t = formatTime(elapsedMs);
String entry = name + "," + pool + "," + stroke + "," + distance + "," + t + "\n";
resultLog += entry;
File f = SPIFFS.open("/results.csv", "a");
if (f) { f.print(entry); f.close(); }
elapsedMs = 0;
server.sendHeader("Location", "/"); server.send(303);
});
server.on("/download", []() {
server.sendHeader("Content-Disposition", "attachment; filename=results.csv");
server.send(200, "text/csv", resultLog);
});
server.on("/clear", HTTP_POST, []() {
resultLog = "";
SPIFFS.remove("/results.csv");
server.send(200, "text/plain", "ok");
});
server.on("/results.json", []() {
server.send(200, "application/json", "[" + resultLog + "]");
});
server.begin();
Serial.println("Server started");
}
void loop() {
server.handleClient();
updateMatrix();
}
This project is part of the free Introduction to Arduino course on DevSTEM.
Start the Course