Community project
Dual-Gate Speed Monitor
The Dual-Gate Speed Monitor uses two infrared vehicle detectors positioned at a known distance apart to measure how fast vehicles pass through. By timing the interval between detections at each gate, the ESP32 calculates the vehicle's speed and triggers an alarm if it exceeds the configured limit. This guide provides the wiring diagram, parts list, complete firmware, and step-by-step assembly instructions to build a working speed enforcement system.
Readers will learn how to wire the active-low IR detectors to the ESP32, configure the detection logic with debouncing and travel-time validation, and display real-time speed measurements and speeding event counts on the built-in screen. The firmware handles edge cases like noise rejection and timeout recovery, making the monitor reliable for continuous operation.
Wiring diagram

Gather all the parts
| Qty | Component |
|---|---|
| 1 | 3.3 V Active-Low IR Vehicle Detector A 3.3 V active-low output The first infrared detector marks when a vehicle enters the 10 cm measurement zone. |
| 1 | 3.3 V Active-Low IR Vehicle Detector B 3.3 V active-low output The second infrared detector marks when a vehicle leaves the 10 cm measurement zone. |
Assemble it in 5 steps
1. Place the two infrared detectors
Mount ir_sensor_a where the vehicle enters the sensing area and ir_sensor_b 10 cm farther along the vehicle path. Measure from the center of one infrared window to the center of the other, and point both sensors at the same part of each passing vehicle.
- Use a stiff strip of wood, plastic, or a baseboard so vibration cannot change the 10 cm spacing.
- Turn each module’s small adjustment screw until its indicator changes cleanly as a model car passes.
- An incorrect center-to-center distance makes every displayed speed incorrect.
2. Connect power to both detectors
At the CardPuter Grove/GPIO breakout, connect each detector VCC to 3.3 V (power) and each detector GND to GND (ground). Both detectors share the same 3.3 V and ground connections.
- A Grove splitter or breakout board makes it easier to share the 3.3 V and GND connections.
- Use red wire for 3.3 V and black wire for GND so the power wires are easy to check.
- Do not connect a detector output that reaches 5 V directly to the CardPuter; more than 3.3 V can damage its input pins.
3. Connect the two detection signals
Connect ir_sensor_a DO to Grove G1 / GPIO1 (first timing signal). Connect ir_sensor_b DO to Grove G2 / GPIO2 (second timing signal). Each detector signal briefly goes low when it sees a vehicle.
- Keep the two signal wires separate and label them A and B.
- The car must pass sensor A first, then sensor B.
- If the signal wires are swapped, the CardPuter waits for the wrong order and will not record a normal pass.
4. Use the built-in screen and alarm
Do not wire a separate display or buzzer. The CardPuter’s built-in color display shows when it is ready, when it is waiting for the second detector, the last speed, travel time, and speeding-event count. Its built-in speaker sounds when speed is above 35 cm/s.
- Press the space bar to reset only the speeding-event count; the last speed remains visible.
- If a vehicle triggers sensor A but never reaches sensor B, the node returns to ready after five seconds.
- Do not connect wires to the CardPuter’s internal screen or speaker connections; they are already used inside the device.
5. Test a vehicle pass
Move a test object through ir_sensor_a first and then ir_sensor_b. After sensor B sees it, the screen shows the speed calculated from the 10 cm distance and measured travel time. A green result is within the limit; a red result and sound mean it was faster than 35 cm/s.
- Start with a slow hand-held object and confirm that each sensor module’s own indicator responds.
- Keep fingers, mounting brackets, and cable ties out of the infrared windows to avoid false triggers.
- A vehicle moving extremely close to the sensors or a loose sensor mount can cause inconsistent readings.
Review all connections
1. Connections between "ir_sensor_a" and "ESP32"
| Function | ir_sensor_a | ESP32 |
|---|---|---|
| power | VCC | 3V3 |
| ground | GND | GND |
| digital | DO | GPIO 1 |
2. Connections between "ir_sensor_b" and "ESP32"
| Function | ir_sensor_b | ESP32 |
|---|---|---|
| power | VCC | 3V3 |
| ground | GND | GND |
| digital | DO | GPIO 2 |
Deploy the firmware
#include <Arduino.h>
#include <M5Cardputer.h>
// Hoisted type definitions
enum GateState : uint8_t { ARMED, WAITING_FOR_B };
void IRAM_ATTR onSensorA();
void IRAM_ATTR onSensorB();
void drawStaticScreen();
void drawStatus();
void drawMeasurement();
constexpr int SENSOR_A_PIN = 1; // Grove G1 / white wire
constexpr int SENSOR_B_PIN = 2; // Grove G2 / yellow wire
constexpr float BASELINE_CM = 10.0f;
constexpr float SPEED_LIMIT_CM_S = 35.0f;
constexpr uint32_t DEBOUNCE_US = 15000;
constexpr uint32_t MIN_TRAVEL_US = 3000; // Reject impossible noise pulses.
constexpr uint32_t MAX_TRAVEL_US = 5000000; // Reset an unfinished pass after 5 seconds.
constexpr uint16_t ALARM_TONE_HZ = 1800;
constexpr uint16_t ALARM_DURATION_MS = 300;
volatile GateState gateState = ARMED;
volatile uint32_t sensorATimeUs = 0;
volatile uint32_t lastAEdgeUs = 0;
volatile uint32_t lastBEdgeUs = 0;
volatile uint32_t travelTimeUs = 0;
volatile bool speedReady = false;
float lastSpeedCmS = 0.0f;
float lastTimeMs = 0.0f;
uint32_t speedingEvents = 0;
bool hasMeasurement = false;
bool overSpeed = false;
bool statusDirty = true;
bool measurementDirty = true;
void IRAM_ATTR onSensorA() {
const uint32_t now = micros();
if (now - lastAEdgeUs < DEBOUNCE_US) return;
lastAEdgeUs = now;
// Ignore another hit on A until B completes the current pass.
if (gateState == ARMED) {
sensorATimeUs = now;
gateState = WAITING_FOR_B;
}
}
void IRAM_ATTR onSensorB() {
const uint32_t now = micros();
if (now - lastBEdgeUs < DEBOUNCE_US) return;
lastBEdgeUs = now;
if (gateState == WAITING_FOR_B) {
const uint32_t elapsed = now - sensorATimeUs;
gateState = ARMED;
if (elapsed >= MIN_TRAVEL_US && elapsed <= MAX_TRAVEL_US) {
travelTimeUs = elapsed;
speedReady = true;
}
}
}
void drawStaticScreen() {
auto &display = M5Cardputer.Display;
display.fillScreen(TFT_BLACK);
display.setTextSize(2);
display.setTextColor(TFT_CYAN, TFT_BLACK);
display.setCursor(7, 5);
display.print("STREET SPEED");
display.drawFastHLine(0, 27, display.width(), TFT_DARKGREY);
display.setTextSize(1);
display.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
display.setCursor(7, 112);
display.print("10 cm gate | Limit 35.0 cm/s");
display.setCursor(7, 125);
display.print("SPACE: clear event count");
}
void drawStatus() {
auto &display = M5Cardputer.Display;
display.fillRect(7, 34, 230, 17, TFT_BLACK);
display.setTextSize(1);
display.setCursor(7, 37);
GateState state;
noInterrupts();
state = gateState;
interrupts();
if (state == WAITING_FOR_B) {
display.setTextColor(TFT_YELLOW, TFT_BLACK);
display.print("MEASURING: waiting for gate B");
} else if (!hasMeasurement) {
display.setTextColor(TFT_GREEN, TFT_BLACK);
display.print("READY: pass vehicle through A then B");
} else {
display.setTextColor(overSpeed ? TFT_RED : TFT_GREEN, TFT_BLACK);
display.print(overSpeed ? "SPEEDING EVENT RECORDED" : "LAST PASS: within limit");
}
}
void drawMeasurement() {
auto &display = M5Cardputer.Display;
display.fillRect(7, 53, 232, 56, TFT_BLACK);
display.setTextSize(1);
display.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
display.setCursor(7, 55);
display.print("LAST SPEED");
display.setTextSize(3);
display.setTextColor(hasMeasurement ? (overSpeed ? TFT_RED : TFT_GREEN) : TFT_DARKGREY, TFT_BLACK);
display.setCursor(7, 67);
if (hasMeasurement) {
display.printf("%.1f", lastSpeedCmS);
} else {
display.print("--.-");
}
display.setTextSize(1);
display.print(" cm/s");
if (hasMeasurement) {
display.setCursor(145, 79);
display.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
display.printf("%.1f ms", lastTimeMs);
}
display.fillRect(155, 55, 83, 17, TFT_BLACK);
display.setCursor(155, 57);
display.setTextColor(TFT_YELLOW, TFT_BLACK);
display.printf("Events: %lu", static_cast<unsigned long>(speedingEvents));
}
void setup() {
auto cfg = M5.config();
M5Cardputer.begin(cfg, true);
M5Cardputer.Display.setRotation(1);
pinMode(SENSOR_A_PIN, INPUT_PULLUP);
pinMode(SENSOR_B_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(SENSOR_A_PIN), onSensorA, FALLING);
attachInterrupt(digitalPinToInterrupt(SENSOR_B_PIN), onSensorB, FALLING);
drawStaticScreen();
drawStatus();
drawMeasurement();
statusDirty = false;
measurementDirty = false;
}
void loop() {
M5Cardputer.update();
bool measured = false;
uint32_t elapsedUs = 0;
bool timedOut = false;
noInterrupts();
if (speedReady) {
elapsedUs = travelTimeUs;
speedReady = false;
measured = true;
}
if (gateState == WAITING_FOR_B && micros() - sensorATimeUs > MAX_TRAVEL_US) {
gateState = ARMED;
timedOut = true;
}
interrupts();
if (measured) {
lastTimeMs = static_cast<float>(elapsedUs) / 1000.0f;
lastSpeedCmS = BASELINE_CM * 1000000.0f / static_cast<float>(elapsedUs);
overSpeed = lastSpeedCmS > SPEED_LIMIT_CM_S;
hasMeasurement = true;
if (overSpeed) {
speedingEvents++;
M5Cardputer.Speaker.tone(ALARM_TONE_HZ, ALARM_DURATION_MS);
}
statusDirty = true;
measurementDirty = true;
}
if (timedOut) {
statusDirty = true;
}
if (M5Cardputer.Keyboard.isKeyPressed(' ')) {
speedingEvents = 0;
statusDirty = true;
measurementDirty = true;
delay(180); // One key press clears the count once.
}
if (statusDirty) {
drawStatus();
statusDirty = false;
}
if (measurementDirty) {
drawMeasurement();
measurementDirty = false;
}
delay(3);
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




