Speed-Gated Parking Sensors on a Suzuki Swift V — Reading Speed Straight off the CAN Bus
An ESP32-C3 quietly listens to the car’s CAN bus, decodes vehicle speed from the
ABS wheel-speed broadcast, and switches a relay that enables the aftermarket
parking controller — which in turn drives the front sensors and the
camera — only below 15 km/h. No splicing into the speedometer,
no aftermarket speed pulse — just the bus the car already talks on.
🔗 Source & schematics:
github.com/tema-mazy/mazy-iot — esp32/car/can_speed_control
The problem
Aftermarket front parking sensors have one annoying habit: they beep at everything.
Crawl up to a junction, sit behind a car at a light, edge through traffic — and the
front sensors chirp at the bumper in front of you the whole time. They are only
genuinely useful when you’re parking, i.e. moving slowly.
The fix most kits ship with is a manual button. I wanted it automatic:
the parking controller — and therefore its front sensors and camera —
enabled only when the car is moving slower than ~15 km/h, OFF otherwise.
Note the division of labour: the aftermarket parking controller
already owns the front sensors and the reversing camera. Our board doesn’t touch them
directly — it just gates the controller’s enable line. That needs one input the kit
doesn’t have — vehicle speed — and one output: a switched 12 V line into the
controller. The Swift already broadcasts speed on its high-speed CAN bus several times
a second. So the whole project reduces to: (1) tap the CAN bus read-only, (2) decode
speed, (3) drive a relay with hysteresis.
Here’s the full signal chain:
┌──────────────────────────────────────────────┐
OBD-II port │ ESP32-C3 │
┌───────────┐ │ │
│ CAN-H (6) │───┐ │ ┌──────────┐ decode ┌────────────┐ │
│ CAN-L(14) │───┤───►│──►│ TWAI/CAN │──0x1B8──► │ speed km/h │ │
└───────────┘ │ │ │ listen- │ └─────┬──────┘ │
500 kbps HS-CAN│ │ │ only │ │ │
│ │ └──────────┘ hysteresis gate │
SN65HVD230 ┘ │ (10 / 15 km/h) │
transceiver │ │ │
│ GPIO0 ─┴─► SSR ─► parking
└───────────────────────────────── controller ─► front sensors + camera
Step 1 — Sniffing the bus
The CAN tap is just the two differential wires at the OBD-II socket — pin 6
(CAN-H) and pin 14 (CAN-L) — through an SN65HVD230 transceiver into the
ESP32-C3’s TWAI controller. Listen-only, so the device is
electrically invisible to the car (more on why that matters below).
A first dump of everything on the bus, captured during a slow drive, looks like this:
I (12810) CAN: ID:3D0 DLC:8 40 00 00 00 00 00 00 00 I (12810) CAN: ID:3D6 DLC:8 00 00 00 00 00 00 F8 00 I (12820) CAN: ID:3D4 DLC:8 04 00 00 00 8C 00 00 00 I (12930) CAN: ID:3E8 DLC:8 01 25 03 70 00 00 00 00 I (14370) CAN: ID:1B8 DLC:8 3F FF 3F FF 3F FF 3F FF ...
Dozens of IDs scroll by. The job is to find the one that tracks road speed.
Finding the speed frame
Two false leads are worth recording so nobody repeats them:
0x180looks speed-ish but byte 3 jumps
in discrete steps — it follows engine load / throttle, not road speed.
Do not use it.0x1E8carries a speed-like value (bytes 0–1 ≈
km/h × 100) and works as a backup source.
The winner is 0x1B8 — the ABS wheel-speed broadcast.
Watch what it does as the car starts rolling:
I (14370) CAN: ID:1B8 DLC:8 3F FF 3F FF 3F FF 3F FF ← ABS not ready yet I (14610) CAN: ID:1B8 DLC:8 00 00 00 00 00 00 00 00 ← stopped, all four wheels 0 I (36740) CAN: ID:1B8 DLC:8 00 25 00 26 00 27 00 27 ← crawling forward I (37720) CAN: ID:1B8 DLC:8 00 1D 00 1D 00 1D 00 1D ← slowing I (39240) CAN: ID:1B8 DLC:8 00 12 00 0F 00 11 00 10 ← slower still I (40030) CAN: ID:1B8 DLC:8 00 0B 00 00 00 00 00 00 ← nearly stopped
The structure becomes obvious once you line up the bytes:
byte: 0 1 2 3 4 5 6 7
┌─────┬─────┬─────┬─────┐
0x1B8: │00 25│00 26│00 27│00 27│
└──┬──┴──┬──┴──┬──┴──┬──┘
FL wheel │ │ └─ RR wheel
│ └─ RL wheel
└─ FR wheel
each = big-endian uint16, unit 0.01 m/s
Four big-endian 16-bit values, one per wheel, all tracking together — exactly what
you’d expect from an ABS controller. And the 3F FF startup pattern is the
classic “sensor not ready” sentinel (0x3FFF), broadcast for the first
second or two before the wheel-speed sensors have a valid reading.
The decode math
The unit is 0.01 m/s per count. To km/h:
km/h = raw × 0.01 m/s × 3.6 = raw × 0.036
In fixed-point integer (no floats on the hot path):
km/h = raw × 36 / 1000
Worked examples from the log above:
| Frame bytes 0–1 | raw (dec) | m/s | km/h |
|---|---|---|---|
00 25 |
37 | 0.37 | 1.3 |
00 1D |
29 | 0.29 | 1.0 |
3F FF |
16383 | — | invalid → ignore |
01 A1 |
417 | 4.17 | 15.0 ← the cutoff |
So the 15 km/h threshold corresponds to roughly raw = 0x01A1. The
crawl speeds in the log (1–2 km/h) are exactly the parking-lot regime where we
want the sensors on.
Step 2 — The decode in firmware
The whole thing is a single ESP-IDF file, single task, no extra RTOS plumbing. The
parser logs every frame (handy for field debugging) and returns a speed only for
0x1B8:
#define CAN_ID_SWIFT_SPEED 0x1B8 // ABS wheel speeds, 4× uint16 BE, 0.01 m/s/count
#define WHEEL_SPEED_INVALID 0x3FFF // broadcast before ABS is ready
static int parse_broadcast(const twai_message_t *msg) {
// ... log the raw frame: "CAN Rx: ID=0x1B8 DLC=8 data=[00 25 00 26 ...]" ...
if (msg->identifier == CAN_ID_SWIFT_SPEED && msg->data_length_code >= 2) {
uint16_t raw = ((uint16_t)msg->data[0] << 8) | msg->data[1]; // big-endian, FL wheel
if (raw == WHEEL_SPEED_INVALID)
return -1; // ABS not ready → treat as no data
return (uint32_t)raw * 36 / 1000; // 0.01 m/s → km/h
}
return -1;
}
A few deliberate choices here:
- First wheel only (bytes 0–1). All four track within a count or
two; one wheel is plenty for a 15 km/h gate and avoids averaging logic. 0x3FFFis filtered, so the startup garbage never
reaches the relay logic.- Return
-1for “no speed”, which the main loop
distinguishes from0km/h (genuinely stopped).
Step 3 — The relay gate (hysteresis)
A naive if (speed > 15) off; else on; would chatter the relay every
time you hover right at 15 km/h. So the gate uses a dead-band:
turn OFF only above 15, turn ON only below 10, and hold in between.
relay
ON │███████████████████████┐ ┌██████████
│ sensors enabled │ hold last state │
│ │ ◄─── dead-band ───► │
OFF │ └───────────────────────┘
└──────────────┬───────────────┬───────────────┬──────► speed
0 10 km/h 15 km/h
SPEED_ON SPEED_OFF
#define SPEED_OFF_KMH 15
#define SPEED_ON_KMH 10
static void update_relay(int speed_kmh) {
if (speed_kmh > SPEED_OFF_KMH)
relay_set(false); // > 15 → sensors OFF
else if (speed_kmh < SPEED_ON_KMH)
relay_set(true); // < 10 → sensors ON
// 10..15 km/h: hold current state (hysteresis dead-band)
}
relay_set() drives GPIO0 (the SSR input) and mirrors the state on the
onboard LED, and only touches the pins when the state actually changes:
static void relay_set(bool on) {
if (on == relay_active) return; // no-op if unchanged → no relay chatter
relay_active = on;
gpio_set_level(RELAY_GPIO, on ? 1 : 0);
gpio_set_level(LED_GPIO, on ? 0 : 1); // LED is active-low
ESP_LOGI(TAG, "Relay %s — parking sensors %s",
on ? "ON " : "OFF", on ? "ENABLED" : "DISABLED");
}
Step 4 — Fail-safe by default
If the firmware crashes, the bus goes quiet, or the connector falls off, the
safe behaviour is sensors ON — you’d rather have them beeping
than silently dead while you reverse toward a wall. Two mechanisms enforce that:
- Boot: relay is ON before the first valid speed frame ever arrives.
- Stale-data watchdog: if no valid
0x1B8frame for
5 seconds, force the relay back ON.
#define SPEED_STALE_US (5000LL * 1000LL) // 5 s
while (1) {
int64_t now_us = esp_timer_get_time();
if (last_speed_us > 0 && (now_us - last_speed_us) > SPEED_STALE_US) {
ESP_LOGW(TAG, "Speed data stale — enabling sensors (safe default)");
relay_set(true);
last_speed_us = 0;
}
twai_message_t rx;
if (twai_receive(&rx, pdMS_TO_TICKS(15)) == ESP_OK) {
int s = parse_broadcast(&rx);
if (s >= 0) {
last_speed_us = now_us;
update_relay(s);
}
}
}
The state machine, end to end:
power on
│
▼
┌───────────────┐ valid speed < 10 ┌───────────────┐
│ SENSORS ON │ ◄─────────────────────── │ SENSORS OFF │
│ (relay ON) │ │ (relay OFF) │
│ - boot │ valid speed > 15 │ │
│ - speed<10 │ ────────────────────────►│ speed>15 │
│ - 5s no data │ │ │
└───────┬───────┘ └───────┬───────┘
│ 10–15 km/h: hold │
└─────────────── dead-band ────────────────┘
The hardware
Block diagram
CAR SIDE ESP32-C3 SuperMini
──────────────────────────────────────────────────────────────────────────
OBD-II pin 6 (CAN-H) ──┐
OBD-II pin 14 (CAN-L) ──┤ SN65HVD230 ── GPIO20 (TX, kept recessive)
│ transceiver ── GPIO21 (RX)
│ 3.3V / GND
OBD-II pin 4 (GND) ────────────────── GND
Fuse box IGN +12V ── F1(1A) ── LM2596 ── +5V ── 5V pin
(NOT OBD-II pin 16 — that's always-on battery)
Brake +12V wire ─────────────────────── SSR OUT+
SSR OUT− ── parking ctrl "brake in"
SSR IN+ ── R2(330Ω) ── GPIO0
SSR IN− ── GND
GPIO8 ── onboard LED
CAN interface
┌─────────────────────┐
GPIO20 (TX) ────────►│ TXD CANH ├──── OBD-II pin 6 (CAN-H)
GPIO21 (RX) ◄────────│ RXD CANL ├──── OBD-II pin 14 (CAN-L)
3.3 V ─────────│ VCC Rs │──── GND (normal-drive mode)
GND ──────────│ GND │
└─────────────────────┘
│ │
100 nF 120 Ω ← only if this node is a bus endpoint
│ │ (measure ~60 Ω CANH–CANL = already
GND CANL terminated, leave it out)
SSR — switching the parking-sensor enable line
Car +12 V brake wire ──────┐ ┌──────────────────────────┐
(from brake light switch) └──────────┤ OUT+ SSR (opto │
│ DC–DC │
parking ctrl "brake in" ─────────────┤ OUT− │
│ │
GPIO0 ── R2(330Ω) ────────────┤ IN+ │
GND ────────────────┤ IN− │
└──────────────────────────┘
Bill of materials
| Part | Role |
|---|---|
| ESP32-C3 SuperMini | MCU + TWAI/CAN controller |
| SN65HVD230 | 3.3 V CAN transceiver |
| DC–DC opto SSR | switches the 12 V parking-sensor enable line |
| LM2596 / MP1584 buck | 12 V → 5 V supply |
| 330 Ω, 100 nF, 10 µF, 1 A fuse | SSR current limit, decoupling, protection |
Gotchas worth their own section
These each cost real debugging time:
1. Stay in TWAI_MODE_LISTEN_ONLY. In normal mode the
TWAI controller transmits ACK bits for every frame on the bus. On a busy, already-ACKed
car bus, any timing mismatch makes the TX error counter climb until the controller goes
bus-off, resets, and repeats — a silent reset loop. Listen-only never
transmits, so the device is invisible to the ECU. (Switch to normal mode only
on the bench with a second node that can ACK.) The firmware logs the error counters
every 2 s so you can catch this early:
twai_get_status_info(&status);
if (status.tx_error_counter > 0 || status.state == TWAI_STATE_BUS_OFF)
ESP_LOGW(TAG, "CAN Status Error: state=%d, tx_err=%ld, ...", ...);
2. Power from an ignition-switched fuse tap, not OBD-II pin 16. Pin
16 is always-on battery — drawing from it parks a slow battery drain on your car. Use a
fuse that only has 12 V with the ignition on.
3. Don’t add CAN termination blindly. The OBD-II port usually sits
mid-bus, already terminated. Measure CANH–CANL with the ignition off:
~60 Ω = correctly terminated, add nothing. ~120 Ω means one
terminator is missing.
4. Splice the SSR in series with the parking controller’s brake-input wire
only — never cut the main brake-light wire.
5. 0x180 is not speed. It’s throttle/load. We confirmed
0x1B8 against a real drive log; don’t revert.
Result
A matchbox-sized board, tucked behind the dash, taps the OBD-II port read-only and
does exactly one thing well: it enables the parking controller — and with it the front
sensors and reversing camera — when you slow to a crawl, and cuts it the moment you’re
driving. No buttons, no false beeps in traffic, and if anything goes wrong it fails to
the safe, controller-on state.
🔗 Full source, firmware and schematics:
github.com/tema-mazy/mazy-iot/esp32/car/can_speed_control
Total moving parts: one CAN ID, one line of decode math
(raw × 36 / 1000), and a two-threshold hysteresis
gate. Sometimes the car has already done the hard sensing for you — you just have to
listen.
Firmware: ESP-IDF, target esp32c3.
The full source is a single main/main.c; wiring lives in
hardware/schematic.md. CAN decoding was derived from a real drive log
captured with the companion CAN logger.
