Initial commit — car parking distance sensor

ESP32-C3 Zero + VL53L1X ToF sensor + 3x LEDs with WiFi AP web calibration UI,
rolling average smoothing, auto-sleep, and NVS persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 12:31:15 +02:00
commit b3deb0036f
4 changed files with 356 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.pio
.vscode

58
CLAUDE.md Normal file
View File

@@ -0,0 +1,58 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Car parking distance sensor using an ESP32-C3 Zero, VL53L1X Time-of-Flight laser sensor, and 3x 10mm LEDs (green/yellow/red). Wall-mounted in a garage, it measures distance to an approaching car and displays proximity via LED states. Includes a WiFi AP with web UI for remote calibration.
## Build & Flash Commands
- `pio run` — compile firmware
- `pio run -t upload` — flash to ESP32-C3 (port: `/dev/ttyACM0`)
- `pio device monitor` — serial monitor (115200 baud)
- `pio run -t upload && pio device monitor` — flash and monitor in one shot
Note: close the serial monitor before flashing, or the port will be busy.
## Architecture
Single-file firmware (`src/main.cpp`) using Arduino framework on PlatformIO. Three subsystems:
1. **Sensor** — VL53L1X on I2C (GPIO 5 SDA, GPIO 6 SCL), continuous mode at 50ms intervals
2. **LEDs** — 3x 10mm through-hole on GPIO 2 (green), GPIO 3 (yellow), GPIO 4 (red), active HIGH with 220Ω resistors
3. **WiFi/Web** — ESP32 runs as AP (SSID: `ParkingSensor`, pass: `carpark1`). Web server on `192.168.4.1` provides:
- Live distance readout (polls `/api/status` at 300ms)
- One-tap calibration (`POST /api/calibrate`) — sets current distance as the stop point
- Manual stop distance input (`POST /api/set?stop=N`)
## LED Behaviour
LEDs off when distance > stop + 1000mm (ZONE_GREEN + 200mm headroom). Within range, zones are calculated as offsets above the calibrated stop distance:
| Zone | Distance | LEDs |
|------|----------|------|
| Far | > stop + 800mm | Green |
| Approaching | stop + 400 stop + 800mm | Green + Yellow |
| Close | stop + 100 stop + 400mm | Yellow + Red |
| Very close | stop stop + 100mm | Red solid |
| STOP | < stop | Red flashing |
## Signal Processing
- **Rolling average**: 5-sample moving average of valid readings smooths jitter
- **Bad reading filter**: readings with `range_status != 0` are discarded (status 7 = wrap-around is common at range edges)
- **Zero filter**: distance 0 (no target) is discarded
- **Auto-sleep**: LEDs turn off after 30s of stable distance (30mm tolerance), wake on movement
## Calibration & Persistence
The stop distance is stored in NVS (ESP32 flash) via the `Preferences` library. It survives power cycles. Zone offsets are `#define` constants (`ZONE_RED` 100, `ZONE_YELLOW` 400, `ZONE_GREEN` 800) at the top of `src/main.cpp`. Stable tolerance (`STABLE_TOLERANCE` 30mm) and sleep timeout (`SLEEP_TIMEOUT` 30000ms) are also tuneable.
## Hardware
- **Board**: ESP32-C3 Zero (board target: `esp32-c3-devkitm-1`)
- **Sensor**: VL53L1X ToF (I2C, Pololu library `pololu/VL53L1X@^1.3.1`)
- **LEDs**: 10mm through-hole (green, yellow, red) with 220Ω resistors
- **Power**: USB-C
- **Build flags**: `-DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=1` (required for serial over USB on C3 Zero)

12
platformio.ini Normal file
View File

@@ -0,0 +1,12 @@
; PlatformIO Project Configuration File
; Car Parking Distance Sensor
; ESP32-C3 Zero + VL53L1X ToF sensor + 3x LEDs (green/yellow/red)
[env:esp32-c3-devkitm-1]
platform = espressif32
board = esp32-c3-devkitm-1
framework = arduino
lib_deps = pololu/VL53L1X@^1.3.1
monitor_speed = 115200
upload_speed = 921600
build_flags = -DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=1

284
src/main.cpp Normal file
View File

@@ -0,0 +1,284 @@
#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <VL53L1X.h>
// --- Pin definitions (ESP32-C3 Zero) ---
#define SDA_PIN 5
#define SCL_PIN 6
#define LED_GREEN 2
#define LED_YELLOW 3
#define LED_RED 4
// --- WiFi AP settings ---
#define AP_SSID "ParkingSensor"
#define AP_PASS "carpark1"
// --- Zone offsets above stop distance (mm) ---
#define ZONE_RED 100 // stop → stop+100 = red solid
#define ZONE_YELLOW 400 // stop+100 → stop+400 = yellow+red
#define ZONE_GREEN 800 // stop+400 → stop+800 = green+yellow
// > stop+800 = green only
// --- Sleep timeout ---
#define STABLE_TOLERANCE 30 // mm — jitter threshold
#define SLEEP_TIMEOUT 30000 // ms — LEDs off after 30s of no movement
// --- Globals ---
VL53L1X sensor;
WebServer server(80);
Preferences prefs;
uint16_t stopDist = 100; // default stop distance (mm), loaded from flash
volatile uint16_t currentDist = 0;
unsigned long lastFlash = 0;
bool flashState = false;
uint16_t lastStableDist = 0;
unsigned long lastMovement = 0;
bool sleeping = false;
// Rolling average buffer
#define AVG_SIZE 5
uint16_t avgBuf[AVG_SIZE] = {0};
uint8_t avgIdx = 0;
uint8_t avgCount = 0;
uint16_t getSmoothed(uint16_t newVal) {
avgBuf[avgIdx] = newVal;
avgIdx = (avgIdx + 1) % AVG_SIZE;
if (avgCount < AVG_SIZE) avgCount++;
uint32_t sum = 0;
for (uint8_t i = 0; i < avgCount; i++) sum += avgBuf[i];
return sum / avgCount;
}
void setLEDs(bool green, bool yellow, bool red) {
digitalWrite(LED_GREEN, green);
digitalWrite(LED_YELLOW, yellow);
digitalWrite(LED_RED, red);
}
void loadSettings() {
prefs.begin("parking", true);
stopDist = prefs.getUShort("stopDist", 100);
prefs.end();
Serial.printf("Loaded stop distance: %d mm\n", stopDist);
}
void saveSettings() {
prefs.begin("parking", false);
prefs.putUShort("stopDist", stopDist);
prefs.end();
Serial.printf("Saved stop distance: %d mm\n", stopDist);
}
// --- Web handlers ---
const char HTML_PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Parking Sensor</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 20px auto; padding: 0 15px; background: #1a1a1a; color: #eee; }
h1 { font-size: 1.3em; }
.dist { font-size: 3em; font-weight: bold; text-align: center; padding: 20px; border-radius: 12px; margin: 15px 0; }
.zone { text-align: center; padding: 8px; margin: 4px 0; border-radius: 6px; font-size: 0.9em; }
.btn { display: block; width: 100%; padding: 14px; margin: 10px 0; border: none; border-radius: 8px; font-size: 1.1em; cursor: pointer; }
.btn-cal { background: #e74c3c; color: white; }
.btn-set { background: #3498db; color: white; }
input[type=number] { width: 80px; padding: 8px; font-size: 1em; border-radius: 6px; border: 1px solid #555; background: #333; color: #eee; }
label { display: block; margin-top: 12px; }
</style>
</head>
<body>
<h1>Parking Sensor</h1>
<div class="dist" id="dist">-- mm</div>
<div class="zone" id="zones"></div>
<button class="btn btn-cal" onclick="calibrate()">Set Current Distance as STOP</button>
<hr style="border-color:#333; margin:20px 0">
<label>Stop distance (mm):
<input type="number" id="sd" min="10" max="3000" value="%STOP%">
<button class="btn-set btn" style="display:inline;width:auto;padding:8px 16px" onclick="setManual()">Set</button>
</label>
<div id="zones-detail" style="margin-top:20px; font-size:0.85em; color:#999"></div>
<script>
function update() {
fetch('/api/status').then(r=>r.json()).then(d=>{
let el = document.getElementById('dist');
el.textContent = d.distance + ' mm';
el.style.background = d.color;
el.style.color = d.color === '#f1c40f' ? '#000' : '#fff';
document.getElementById('sd').value = d.stop;
document.getElementById('zones-detail').innerHTML =
'Flashing red: &lt; ' + d.stop + 'mm<br>' +
'Red solid: ' + d.stop + ' - ' + (d.stop+%ZONE_RED%) + 'mm<br>' +
'Yellow+Red: ' + (d.stop+%ZONE_RED%) + ' - ' + (d.stop+%ZONE_YELLOW%) + 'mm<br>' +
'Green+Yellow: ' + (d.stop+%ZONE_YELLOW%) + ' - ' + (d.stop+%ZONE_GREEN%) + 'mm<br>' +
'Green: &gt; ' + (d.stop+%ZONE_GREEN%) + 'mm';
});
}
function calibrate() {
if(confirm('Set current distance as the STOP point?'))
fetch('/api/calibrate',{method:'POST'}).then(()=>update());
}
function setManual() {
let v = document.getElementById('sd').value;
fetch('/api/set?stop='+v,{method:'POST'}).then(()=>update());
}
setInterval(update, 300);
update();
</script>
</body>
</html>
)rawliteral";
String getColor() {
uint16_t d = currentDist;
if (d > stopDist + ZONE_GREEN) return "#2ecc71"; // green
if (d > stopDist + ZONE_YELLOW) return "#f1c40f"; // yellow
if (d > stopDist + ZONE_RED) return "#e67e22"; // orange
if (d > stopDist) return "#e74c3c"; // red
return "#c0392b"; // dark red (flashing)
}
void handleRoot() {
String html = String(HTML_PAGE);
html.replace("%STOP%", String(stopDist));
html.replace("%ZONE_RED%", String(ZONE_RED));
html.replace("%ZONE_YELLOW%", String(ZONE_YELLOW));
html.replace("%ZONE_GREEN%", String(ZONE_GREEN));
server.send(200, "text/html", html);
}
void handleStatus() {
String json = "{\"distance\":" + String(currentDist) +
",\"stop\":" + String(stopDist) +
",\"color\":\"" + getColor() + "\"}";
server.send(200, "application/json", json);
}
void handleCalibrate() {
stopDist = currentDist;
saveSettings();
server.send(200, "application/json", "{\"ok\":true,\"stop\":" + String(stopDist) + "}");
}
void handleSet() {
if (server.hasArg("stop")) {
stopDist = server.arg("stop").toInt();
if (stopDist < 10) stopDist = 10;
saveSettings();
}
server.send(200, "application/json", "{\"ok\":true,\"stop\":" + String(stopDist) + "}");
}
void setup() {
Serial.begin(115200);
pinMode(LED_GREEN, OUTPUT);
pinMode(LED_YELLOW, OUTPUT);
pinMode(LED_RED, OUTPUT);
// Startup test
setLEDs(true, true, true);
delay(500);
setLEDs(false, false, false);
// Load saved calibration
loadSettings();
// I2C + sensor
Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(400000);
sensor.setTimeout(500);
if (!sensor.init()) {
Serial.println("ERROR: VL53L1X not found!");
while (true) {
digitalWrite(LED_RED, !digitalRead(LED_RED));
delay(200);
}
}
sensor.setDistanceMode(VL53L1X::Long);
sensor.setMeasurementTimingBudget(50000);
sensor.startContinuous(50);
// WiFi AP
WiFi.softAP(AP_SSID, AP_PASS);
Serial.printf("WiFi AP: %s IP: %s\n", AP_SSID, WiFi.softAPIP().toString().c_str());
// Web server routes
server.on("/", handleRoot);
server.on("/api/status", handleStatus);
server.on("/api/calibrate", handleCalibrate);
server.on("/api/set", handleSet);
server.begin();
Serial.println("Parking sensor ready.");
}
void loop() {
server.handleClient();
uint16_t distance = sensor.read();
uint8_t status = sensor.ranging_data.range_status;
if (sensor.timeoutOccurred()) {
Serial.println("TIMEOUT");
setLEDs(false, false, false);
return;
}
// Ignore bad readings (0 = no target, status != 0 = out of range/error)
if (distance == 0 || status != 0) {
Serial.printf("Dist: %5d mm Status: %d (ignored)\n", distance, status);
return; // don't update LEDs or average — just skip
}
uint16_t smoothed = getSmoothed(distance);
Serial.printf("Dist: %5d mm Avg: %5d mm Status: %d\n", distance, smoothed, status);
currentDist = smoothed;
// Detect movement — reset sleep timer if distance changed
if (abs((int)smoothed - (int)lastStableDist) > STABLE_TOLERANCE) {
lastStableDist = smoothed;
lastMovement = millis();
sleeping = false;
}
// Sleep if no movement for SLEEP_TIMEOUT
if (millis() - lastMovement > SLEEP_TIMEOUT) {
sleeping = true;
setLEDs(false, false, false);
return;
}
uint16_t ledsOffDist = stopDist + ZONE_GREEN + 200;
if (smoothed > ledsOffDist) {
setLEDs(false, false, false);
} else if (smoothed > stopDist + ZONE_GREEN) {
setLEDs(true, false, false);
} else if (smoothed > stopDist + ZONE_YELLOW) {
setLEDs(true, true, false);
} else if (smoothed > stopDist + ZONE_RED) {
setLEDs(false, true, true);
} else if (smoothed > stopDist) {
setLEDs(false, false, true);
} else {
unsigned long now = millis();
if (now - lastFlash > 150) {
flashState = !flashState;
lastFlash = now;
}
setLEDs(false, false, flashState);
}
}