Laser Triggered Stopwatch - ESP32

by andrewhyun in Circuits > Microcontrollers

27 Views, 1 Favorites, 0 Comments

Laser Triggered Stopwatch - ESP32

20260106_132312.jpg
20260108_132948.jpg

This is a Laser Triggered Stopwatch for timing races - humans, cars, Projectiles, hovercrafts, etc.

I was first asked to build this by our Science Olympiad coach for timing the hovercraft event. Purchasing a Laser-triggered stopwatch on Amazon or Alibaba can cost between $150-$400. This build can be done for less than $50(not including 3D print filament and batteries).

I looked at using Raspberry Pi or Arduino, but I chose to go with an ESP32 because I wanted the startline trigger and finish line trigger to be wireless, AND I wanted to be dirt cheap.

An ESP32 costs about 3 for $15 at Amazon at the time of this writing and the ESP-NOW protocol is simple and has very low latency.


The total cost for both the startline and finishline modules is less than $20.


Supplies

ESP32 - $15 for 3 - https://www.amazon.com/dp/B0F1MS5S8R

4-Digit 7-Segment Display LED with 74HC595 Driver - $10 for 5 - https://www.amazon.com/dp/B0DLMK52MP

LDR Photosensitive Sensor Module Light Dependent Resistor - $9 for 10 - https://www.amazon.com/dp/B099N5W9F7

Laser Pens - 6 for $10 - https://www.amazon.com/dp/B0B54VDG71

Wiring the Finishline

wiring2.png

First, we wire up the ESP32 to the LED display and the LDR Sensor module.

I found that the 3.3 V (pin 1) from the ESP32 was adequate to run both.

Wiring the Startline

wiring2.png

The Startline trigger is the same as the Finishline, except we don't need a LED Display.

Configure the Light Sensor

81R-cO9qWLL._SX522_.jpg

First, why this light sensor. I started off by using a laser detector from Amazon, and it ultimately did not work. These light sensor work great because there's an adjustment on them. In operation, you want to tune down the adjustment, so that it is not activated when a laser is not hitting it, but activates when you shine a laser at the sensor. The adjustment is great also for testing. While I was testing, I adjusted it so that I could just block the sensor with my finger to deactivate it, which was much easier to shining a laser at it constantly while testing. There is an LED light that turns on when the sensor is activated with light. THis also makes testing very easy.


You can test your Light Sensor with the ESP32 on the Startline module using this code:

/*************************************************
* Laser Module Tester
*************************************************/


/* ===================== PIN DEFINITIONS ===================== */

// Binary laser module output
#define LASER_PIN 34 // digital input
#define LED_PIN 2 // Built-in LED (usually GPIO 2)


/* ===================== LASER EDGE DETECTION ===================== */
/*
* Assumes:
* HIGH = beam present
* LOW = beam broken
*/
bool laserBroken() {
static bool last = LOW;
bool now = digitalRead(LASER_PIN);

if (last == LOW && now == HIGH) {
last = now;
return true;
}

last = now;
return false;
}


/* ===================== SETUP ===================== */

void setup() {
pinMode(LASER_PIN, INPUT);
pinMode(LED_PIN, OUTPUT);

}

/* ===================== MAIN LOOP ===================== */

void loop() {
if (laserBroken()) {
digitalWrite(LED_PIN, HIGH); // LED ON
delay(300);
digitalWrite(LED_PIN, LOW);
}
}



Setting Up the ESP-Now Signal

When the laser is triggered on the start line, you want to send a command to the Finishline ESP module to let it know to start the stop watch. To do this, you need to make sure to put in the MAC address of the Finishline ESP32 module, so that your startline knows where to send the signal.

In order to retrieve the MAC address of your Finishline ESP32 module, run the following code:

#include <WiFi.h>

void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);

}

void loop() {

Serial.print("ESP32 MAC Address: ");
Serial.println(WiFi.macAddress());
}


This is the code you use to send the signal from the StartLine to the Finishline ESP32 module. Make sure to replace the MAC Address with your own Finishline ESP32 Module MAC address.

/*************************************************
* START LINE ESP32
* - Binary laser module (digital)
* - ESP-NOW sender
* - First beam break -> START
* - Second beam break -> RESET
*************************************************/

#include <WiFi.h>
#include <esp_now.h>

/* ===================== PIN DEFINITIONS ===================== */

// Binary laser module output
#define LASER_PIN 34 // digital input
#define LED_PIN 2 // Built-in LED (usually GPIO 2)

/* ===================== ESP-NOW COMMANDS ===================== */

enum Command : uint8_t {
CMD_TRIGGER = 1
};

/* ===================== RECEIVER MAC ADDRESS ===================== */
/*
* Replace this with the FINISH LINE ESP32 MAC address
* Find it using:
* Serial.println(WiFi.macAddress());
*/
uint8_t receiverMAC[] = {
0xD4, 0xE9, 0xF4, 0x65, 0x6F, 0x77 // <-- CHANGE THIS
};

/* ===================== LASER EDGE DETECTION ===================== */

bool laserBroken() {
static bool last = LOW;
bool now = digitalRead(LASER_PIN);

if (last == LOW && now == HIGH) {
last = now;
return true;
}

last = now;
return false;
}

/* ===================== ESP-NOW SEND ===================== */

void sendCommand(Command cmd) {
esp_err_t result = esp_now_send(receiverMAC,
(uint8_t*)&cmd,
sizeof(cmd));

// Optional: you can check result for ESP_OK
}

/* ===================== SETUP ===================== */

void setup() {
pinMode(LASER_PIN, INPUT);
pinMode(LED_PIN, OUTPUT);
// ---------- Wi-Fi / ESP-NOW ----------
WiFi.mode(WIFI_STA);

if (esp_now_init() != ESP_OK) {
// Fatal error — ESP-NOW failed
while (true);
}

esp_now_peer_info_t peerInfo = {};
memcpy(peerInfo.peer_addr, receiverMAC, 6);
peerInfo.channel = 0; // auto
peerInfo.encrypt = false;

esp_now_add_peer(&peerInfo);
}

/* ===================== MAIN LOOP ===================== */

void loop() {
if (laserBroken()) {
sendCommand(CMD_TRIGGER);
digitalWrite(LED_PIN, HIGH); // LED ON
// Simple debounce
delay(300);
digitalWrite(LED_PIN, LOW);
}
}



FinishLine - LED Display

81nNSnX7mFL._SL1500_.jpg

Next, we want to test the LED Display on the Finishline module.

Each number is defined is defined by which LED segments should be lit up as SEGMENTS[].

This is combined with which digit should be displayed under DIGITS[].

Note that there's a DP_BIT which is the decimal point bit, which is applied to the second digit.

We use the built in esp_timer library for the timing.



#include <Arduino.h>
#include <esp_timer.h>

/* ===================== PIN DEFINITIONS ===================== */

// 74HC595 integrated display
#define DATA_PIN 23
#define CLOCK_PIN 18
#define LATCH_PIN 5

/* ===================== DISPLAY MAPS (CONFIRMED WORKING) ===================== */

// abcdefg (ACTIVE-LOW at hardware)
const uint8_t SEGMENTS[10] = {
0b00111111, // 0
0b00000110, // 1
0b01011011, // 2
0b01001111, // 3
0b01100110, // 4
0b01101101, // 5
0b01111101, // 6
0b00000111, // 7
0b01111111, // 8
0b01101111 // 9
};

// LEFT → RIGHT digit order (your board)
const uint8_t DIGITS[4] = {
0b00001000,
0b00000100,
0b00000010,
0b00000001
};

#define DP_BIT 0b10000000 // decimal point (bit 7)

/* ===================== STOPWATCH STATE ===================== */

volatile uint32_t ticks = 0; // hundredths of a second (0–9999)
volatile bool running = true; // auto-start for testing

esp_timer_handle_t stopwatchTimer;

/* ===================== TIMER CALLBACK (10 ms) ===================== */

void onTimer(void* arg) {
if (running && ticks < 9999) {
ticks++;
}
}

/* ===================== DISPLAY FUNCTIONS ===================== */

void getDigits(uint32_t t, uint8_t d[4]) {
d[0] = (t / 1000) % 10; // tens seconds
d[1] = (t / 100) % 10; // ones seconds
d[2] = (t / 10) % 10; // tenths
d[3] = t % 10; // hundredths
}

void displayTicks(uint32_t t) {
uint8_t d[4];
getDigits(t, d);

for (int i = 0; i < 4; i++) {
uint8_t seg = SEGMENTS[d[i]];

// Decimal point after 2nd digit → SS.hh
if (i == 1) {
seg |= DP_BIT;
}

digitalWrite(LATCH_PIN, LOW);

// ACTIVE-LOW segments
shiftOut(DATA_PIN, CLOCK_PIN, MSBFIRST, ~seg);
shiftOut(DATA_PIN, CLOCK_PIN, MSBFIRST, DIGITS[i]);

digitalWrite(LATCH_PIN, HIGH);
delayMicroseconds(1500);
}
}

/* ===================== SETUP ===================== */

void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("esp_timer stopwatch + LED");

pinMode(DATA_PIN, OUTPUT);
pinMode(CLOCK_PIN, OUTPUT);
pinMode(LATCH_PIN, OUTPUT);

// ---- esp_timer setup ----
esp_timer_create_args_t timerArgs = {
.callback = &onTimer,
.arg = nullptr,
.dispatch_method = ESP_TIMER_TASK,
.name = "stopwatch"
};

esp_timer_create(&timerArgs, &stopwatchTimer);

// 10 ms = 10,000 µs → hundredths
esp_timer_start_periodic(stopwatchTimer, 10000);
}

/* ===================== LOOP ===================== */

void loop() {
displayTicks(ticks);

// Debug print (optional)
static uint32_t last = 0;
if (millis() - last > 1000) {
last = millis();
Serial.printf("ticks=%lu time=%.2f\n",
ticks,
ticks / 100.0);
}
}

FinishLine Code

Now, we put it all together.

A few things to note. I put in an LED debugger, so that it blinks once when the startline is triggered the first time. It blinks twice when the finish line is crossed. It blinks three times when the system is reset (by crossing the startline again).


I use two variables to control the logic: finished and running.

In the reset state, finished and running both equals false.

The finishline trigger does nothing at this point.

However, if the startline is crossed, then it sets running=true and the stopwatch starts. Until finished=true, the startline will ignore any other signals.


If running=true and finished=false, then the finishline trigger is looking for the sensor to trigger. When it does, it sets finished=true and running=false and stops the stopwatch.


At this point, the startline sensor can now again be triggered and it will set finished=false which will reset the display to 00.00.


There is a serial monitor output, so you can see what is happening and also debug as necessary.


#include <WiFi.h>
#include <esp_now.h>
#include <esp_timer.h>

/* ===================== PIN DEFINITIONS ===================== */

// 74HC595 integrated display
#define DATA_PIN 23
#define CLOCK_PIN 18
#define LATCH_PIN 5

// Binary laser module
#define LASER_PIN 34 // digital input

// LED
#define LED_PIN 2 // Built-in LED (usually GPIO 2)

/* ===================== DISPLAY MAPS ===================== */

// abcdefg
const uint8_t SEGMENTS[10] = {
0b00111111, // 0
0b00000110, // 1
0b01011011, // 2
0b01001111, // 3
0b01100110, // 4
0b01101101, // 5
0b01111101, // 6
0b00000111, // 7
0b01111111, // 8
0b01101111 // 9
};

#define DP_BIT 0b10000000

// One-hot digit select

const uint8_t DIGITS[4] = {
0b00001000,
0b00000100,
0b00000010,
0b00000001
};



/* ===================== STOPWATCH STATE ===================== */

volatile uint16_t ticks = 0;
volatile bool running = false;

esp_timer_handle_t stopwatchTimer;

volatile bool finished=false;
volatile bool blinkLED=false;

void onStopwatchTimer(void* arg) {
if (running && ticks < 9999) {
ticks++;
}
}


/* ===================== ESP-NOW RECEIVE CALLBACK ===================== */

void onReceive(const esp_now_recv_info_t* info,
const uint8_t* data,
int len) {
if (len < 1) return;
if (running==false){
if (finished==true){
// RESET
finished=false;
ticks=0;
blinkLED=3;
} else {
// Start
ticks=0;
running=true;
blinkLED=1;
}
}

}

/* ===================== LASER EDGE DETECTION ===================== */
/*
* HIGH = beam present
* LOW = beam broken
*/
bool laserBroken() {
static bool last = LOW;
bool now = digitalRead(LASER_PIN);

if (last == LOW && now == HIGH) {
last = now;
return true;
}

last = now;
return false;
}

/* ===================== DISPLAY FUNCTIONS ===================== */

void getDigits(uint16_t t, uint8_t d[4]) {
d[0] = (t / 1000) % 10;
d[1] = (t / 100) % 10;
d[2] = (t / 10) % 10;
d[3] = t % 10;
}

void displayTicks(uint16_t t) {
uint8_t d[4];
getDigits(t, d);

for (int i = 0; i < 4; i++) {
digitalWrite(LATCH_PIN, LOW);

uint8_t seg = SEGMENTS[d[i]];

// Turn decimal point ON after 2nd digit
if (i == 1) {
seg |= DP_BIT; // mark DP as ON (before inversion)
}

// Try SEGMENTS first, DIGITS second
//shiftOut(DATA_PIN, CLOCK_PIN, MSBFIRST, SEGMENTS[d[i]]);
shiftOut(DATA_PIN, CLOCK_PIN, MSBFIRST, ~seg);
shiftOut(DATA_PIN, CLOCK_PIN, MSBFIRST, DIGITS[i]);

digitalWrite(LATCH_PIN, HIGH);
delayMicroseconds(1500);
}
}




/* ===================== SETUP ===================== */

void setup() {
Serial.begin(115200); // Start serial at 115200 baud
delay(1000); // Give time for monitor to connect
Serial.println("ESP32 started");
pinMode(DATA_PIN, OUTPUT);
pinMode(CLOCK_PIN, OUTPUT);
pinMode(LATCH_PIN, OUTPUT);

pinMode(LASER_PIN, INPUT);

pinMode(LED_PIN, OUTPUT);

// -------- Hardware Timer (10 ms = 0.01 s) --------
esp_timer_create_args_t timerArgs = {
.callback = &onStopwatchTimer,
.arg = nullptr,
.dispatch_method = ESP_TIMER_TASK,
.name = "stopwatch"
};

esp_timer_create(&timerArgs, &stopwatchTimer);

// 10 ms period = 0.01 s
esp_timer_start_periodic(stopwatchTimer, 10000);


// -------- ESP-NOW --------
WiFi.mode(WIFI_STA);
esp_now_init();
esp_now_register_recv_cb(onReceive);

ticks = 0;
running = false;
}

/* ===================== MAIN LOOP ===================== */

void loop() {
if (blinkLED > 0) {
int i = 0;
while (i<blinkLED){
digitalWrite(LED_PIN, HIGH);
delay(150);
digitalWrite(LED_PIN, LOW);
delay(150);
i++;
}
blinkLED = 0;
}
if (finished==false && running && laserBroken()) {
Serial.println("laserbroken");
running = false;
finished= true;
blinkLED=2;
}

displayTicks(ticks);
static uint32_t last = 0;
if (millis() - last > 1000) {
last = millis();
Serial.printf("ticks=%d running=%d finished=%d\n", ticks, running, finished);
}

}


Case

ESP32 Holder.png
Counter LED Holder.png
Sensor Holder.png
20260106_132309.jpg
20260106_132329.jpg
20260106_132348.jpg
20260106_132417.jpg

I created three cases using OpenSCAD that stack on top of each other to hold 1. ESP32 2. Sensor, and 3. LED.

For the Startline, you can just use the #1 and #2 because you don't need the LED.


The pegs are designed to lock the components in place. Once you snap them in, getting them back out may be difficult without destroying the case.


$fn=100;

//counter();
//sensor();
esp32Holder();


module counter(){
difference(){
cube([40, 63, 20]);
translate([2, 2, 15]){
cube([36, 59, 50]);
}
translate([3, 2, 2]){
cube([34, 59, 50]);
}
//16x 34
translate([20-7.5, 8, -2]){
cube([16, 31, 50]);
}
translate([20-5, -2, 10]){
cube([10, 10, 12]);
}
}
translate([20-8.5, 8+30+2.5, 0]){
cylinder(14, 1,1);
}
translate([20-8.5+17+1.5, 8+30+2.5, 0]){
cylinder(14, 1,1);
}
translate([20-8.5, 6, 0]){
cylinder(14, 1,1);
}
translate([20-8.5+17+1.5, 6, 0]){
cylinder(14, 1,1);
}
}



module sensor(){
difference(){
translate([0,0,0]){
cube([36, 59, 20]);
translate([13, -6,0]){
cube([10, 6,12]);
}
}
translate([2, 2, 15]){
cube([32, 55, 50]);
}
translate([3, 2, 2]){
cube([30, 55, 50]);
}
translate([18,-10,7]){
rotate([-90,0,0]){
cylinder(50,3,3);
}
}
translate([2, 40, -1]){
cube([31, 17,10]);
}
translate([-1,30,8]){
rotate([0,90,0]){
cylinder(40, 4, 4);
}
}
}
translate([18, 27, 0]){
cylinder(10, 1.5, 1.5);
}
}


module esp32Holder(){
difference(){
cube([32, 55, 40]);
translate([1, 1, 2]){
cube([30, 53, 50]);
}
translate([1+1+9, -1, 2]){
cube([9, 10, 50]);
}
translate([6, 8, -2]){
cube([20,40,5]);
}
}


//pegs
translate([1+1+2, 1+1+2.25, 1]){
cylinder(5,1,1.4);
}
translate([1+1+25, 1+1+2.25, 1]){
cylinder(5,1,1.4);
}
translate([1+1+2, 1+1+48.75, 1]){
cylinder(5,1,1.4);
}
translate([1+1+25, 1+1+48.75, 1]){
cylinder(5,1,1.4);
}
translate([0,0,5]){
translate([1+1+2, 1+1+2.25, 1]){
cylinder(10,1.5,1);
}
translate([1+1+25, 1+1+2.25, 1]){
cylinder(10,1.5,1);
}
translate([1+1+2, 1+1+48.75, 1]){
cylinder(10,1.5,1);
}
translate([1+1+25, 1+1+48.75, 1]){
cylinder(10,1.5,1);
}
}
}



These is a Laser Pen Holder I created:

difference(){
translate([-10,-12,0]){
cube([20, 22, 40]);
}

translate([0,0,-3]){
cylinder(100,6.8,6.8,$fn=100);
}
translate([-2.5,-6,28]){
rotate([20,0,0]){
cube([5,12,15]);
}
}
}
difference(){
translate([8,10,0]){
cube([2,20,40]);
translate([-18,0,0]){
cube([2,20,40]);
}
}
translate([-40,25,20]){
rotate([0,90,0]){
cylinder(100,3.2,3.2,$fn=100);
}
}

}


Finally, I used a 3D printed tripod from Thingiverse:

https://www.thingiverse.com/thing:1165406


I made a few modifications to it because I couldn't find the m4 nuts and bolts. I used m5 instead:

difference(){

translate([0,1,10]){

cube([45, 32, 15]);

}

translate([24,7,18]){

sphere(17.9, $fn=100);

}

translate([-6,7,18]){

rotate([0,90,0]){

cylinder(10, 4.7, 4.7, $fn=6);

cylinder(100,2.8,2.8,$fn=100);

}

}


translate([24,30,18]){

rotate([90,0,0]){

cylinder(10, d=12.8, $fn=6);

translate([0,0,-20]){

cylinder(50, d=6.4, $fn=100);

}

}

}

}


difference(){

cylinder(6, 14, 14, $fn=6);

translate([0,0,3]){

cylinder(10, 4.7, 4.7, $fn=6);

}

translate([0,0,-3]){

cylinder(100,2.8,2.8,$fn=100);

}

}