Handheld ESP32 24GHz Doppler Radar With OLED Display

by adresemailx in Workshop > Science

32 Views, 3 Favorites, 0 Comments

Handheld ESP32 24GHz Doppler Radar With OLED Display

IMG_20251215_113107901_AE.jpg
IMG_20251215_113107901_AE.jpg
IMG_20251215_102346119_AE.jpg
IMG_20251215_102321930_AE.jpg
IMG_20251215_102342034_AE.jpg

This project is a handheld Doppler radar built using an ESP32 microcontroller, a 24 GHz HLK-LD2451 radar module, and a 128×64 OLED display.


The device measures object speed, distance, angle, and movement direction in real time and shows the results on the screen.


It is powered by a single 18650 Li-Ion battery and is fully portable, making it suitable for handheld use.


The radar module is mounted inside a custom 3D-printed enclosure with a directional horn, which improves forward detection and reduces side reflections.


This project is intended for educational, experimental, and DIY purposes.


Downloads

Supplies

Electronics:

ESP32 development board (30-pin)

HLK-LD2451 24 GHz Doppler radar module

OLED SSD1306 128x64 display (I2C)

18650 Li-Ion battery

IP5305 charging and boost module

100 µF electrolytic capacitor

On/off power switch

Wiring & Assembly:

Jumper wires

Solder

Heat shrink tubing

Tools:

Soldering iron

Multimeter

3D printer

Computer with PlatformIO / VS Code

What the Display Shows:

The OLED display is designed to show all important radar information in a clear and simple way.


The large number in the center of the screen shows the current target speed in km/h.


At the top of the display:

- “frames” shows how many radar data frames have been processed since startup

- “targets” shows how many objects are currently detected


On the right side of the screen:

- D: distance to the target in meters

- A: angle relative to the radar axis in degrees

- Direction: shows whether the object is approaching or moving away


If no target is detected, the display shows zero values.


IMG_20251215_102010727_AE.jpg
IMG_20251215_113025467_AE.jpg
IMG_20251215_113107901_AE.jpg

Real-World Speed Measurement:

This step demonstrates that the device measures real movement and real speed, not simulated data.


As shown in the photos, the radar detects a moving person in real time.


In this example, the device measured:

- Speed: 11 km/h

- Distance: about 6 meters

- Angle: −29 degrees

- Direction: approaching


The values are calculated from Doppler radar data received directly from the HLK-LD2451 module.


The firmware includes basic filtering and sanity checks to reduce false readings and noise.

The firmware is provided as a PlatformIO project.

It can also be used with Arduino IDE by copying the code into a .ino file.

To use the code in Arduino IDE:

1. Create a new sketch.

2. Copy the contents of main.cpp into the sketch.

3. Select ESP32 board and correct COM port.

4. Upload the sketch.



IMG_20251215_102256614_AE.jpg
IMG_20251215_102311586_AE.jpg

3D Printed Enclosure and Radar Horn:

The device is housed in a fully 3D-printed enclosure designed for handheld use.


The enclosure consists of three main parts:

- a front radar horn

- a main electronics housing

- a handle


The radar horn narrows the field of view of the Doppler radar, which improves forward sensitivity and reduces side reflections.


This design helps to obtain more stable and repeatable measurements compared to using the radar module without a horn.


Power Supply and Portability:

The device is powered by a single 18650 Li-Ion battery, which makes it fully portable and suitable for handheld use.


A charging and boost module provides a stable voltage supply for the ESP32, radar module, and OLED display.


The device includes a physical on/off power switch that disconnects the battery, allowing safe transport and storage.


Thanks to battery power and compact size, the radar can be used without any external cables or power sources.

Software note:

The firmware is provided as a PlatformIO project.


If you are using Arduino IDE, you can copy the contents of the main.cpp file into a .ino file and upload it to the ESP32 in the same way as a standard Arduino sketch.



Disclaimer:

This project is intended for educational, experimental, and DIY purposes only.


It is not a certified speed measurement device and must not be used for legal, enforcement, or regulatory purposes.


All measurements shown are for demonstration and experimentation only.



Firmware Source Code:

Below is the full firmware source code used in this project.


The code is written for ESP32 using the Arduino framework and was originally developed as a PlatformIO project.


If you are using Arduino IDE, you can copy the code below into a .ino file and upload it as a standard Arduino sketch.

#include <Arduino.h>
#include <Wire.h>
#include <U8g2lib.h>

/* ============================================================
HANDHELD RADAR (ESP32 + HLK-LD2451 + OLED SSD1306)

WHAT THIS PROGRAM DOES (high level):
1) Initializes OLED display (I2C) and shows a short "Matrix" boot animation.
2) Initializes UART on ESP32 and configures the HLK-LD2451 radar module.
3) Continuously reads binary frames coming from the radar.
4) Parses frames using a simple state machine (header -> length -> payload -> tail).
5) Decodes target parameters from the payload (speed, distance, angle, direction).
6) Applies sanity checks + (optional) anti-glitch filtering.
7) Displays the last stable measurement on OLED.
8) Optionally prints decoded frames + HEX payload to Serial Monitor.

IMPORTANT:
- The code below is intentionally "stable" and tested.
- To keep behavior unchanged, ONLY comments were added/expanded.

============================================================
QUICK SETTINGS YOU CAN CHANGE (BEGINNER FRIENDLY)

A) WIRING (pins) — if you use different ESP32 pins:
- I2C_SDA / I2C_SCL
- RADAR_RX / RADAR_TX

B) RADAR BEHAVIOR:
- RADAR_MAX_DIST : max detection distance (meters, 0..255)
- RADAR_DIR : direction mode (both / only approach / only away)
- RADAR_MIN_SPEED : ignore slower targets (km/h)
- RADAR_NO_TARGET_DELAY : how long radar waits before "no target" (seconds)
- RADAR_TRIG_COUNT : trigger count (radar sensitivity behavior)
- RADAR_SNR_LEVEL : signal-to-noise threshold (higher = less noise)

C) DISPLAY BEHAVIOR:
- OLED_REFRESH_MS : OLED redraw interval (ms)
- TARGET_HOLD_MS : keep last value visible after target disappears (ms)

D) FILTERING / STABILITY:
- REQUIRED_HITS : number of similar frames required to accept target
1 = show every measurement (fastest)
2..N = smoother display, less flicker, more delay

E) DEBUG OUTPUT (Serial Monitor):
- DEBUG_FRAMES : 1=print frames, 0=quiet
- DEBUG_ONLY_WHEN_TARGET : 1=print only if target present
- DEBUG_HEX_LIMIT : limit number of bytes printed per frame

============================================================ */

/* ==============================
OLED DISPLAY (SSD1306, 128x64)
==============================
Using U8g2 library in "full buffer" mode:
- clearBuffer() -> draw -> sendBuffer()
*/
U8G2_SSD1306_128X64_NONAME_F_HW_I2C oled(U8G2_R0, U8X8_PIN_NONE);

/* ==============================
I2C PINS (ESP32)
==============================
Default ESP32 I2C pins are often 21/22, but you can change them here.
*/
#define I2C_SDA 21
#define I2C_SCL 22

/* ==============================
RADAR UART PINS (ESP32)
==============================
HLK-LD2451 uses UART (TX/RX).
ESP32 has multiple hardware serial ports.
Here we use HardwareSerial(2), often mapped to UART2.
*/
#define RADAR_RX 16
#define RADAR_TX 17
#define RADAR_BAUD 115200
HardwareSerial Radar(2);

/* ============================================================
RADAR PARAMETERS (sent to the module during configureRadar())
============================================================ */
const uint8_t RADAR_MAX_DIST = 100; // max distance in meters (0..255)
const uint8_t RADAR_DIR = 0x02; // 0x02 = both directions (approach + away)
const uint8_t RADAR_MIN_SPEED = 0; // km/h (ignore slower targets if >0)
const uint8_t RADAR_NO_TARGET_DELAY = 2; // seconds before radar reports "no target"
const uint8_t RADAR_TRIG_COUNT = 1; // radar trigger count (module-specific)
const uint8_t RADAR_SNR_LEVEL = 1; // radar SNR level (module-specific)

/* ============================================================
OLED/UI TIMING
============================================================
OLED_REFRESH_MS:
- How often we redraw the screen.
- Lower = smoother but more CPU usage.
- Higher = less CPU but slower updates.

TARGET_HOLD_MS:
- When target disappears for a moment, we keep last reading on screen.
- Prevents blinking/zeroing if target is intermittent.
*/
static const uint32_t OLED_REFRESH_MS = 80;
static const uint32_t TARGET_HOLD_MS = 800;

/* ============================================================
BOOT SCREEN: "MATRIX" STYLE (0/1 rain)
============================================================
Pure visual effect. You can shorten or disable it:
- MATRIX_BOOT_MS = 0 -> it ends immediately.
*/
static const uint16_t MATRIX_BOOT_MS = 3000;
static const uint8_t MATRIX_COL_W = 6;
static const uint8_t MATRIX_ROW_H = 8;

// random 0/1 character for Matrix animation
static inline char randBit() { return (random(0, 2) == 0) ? '0' : '1'; }

/*
matrixBoot(durationMs):
- draws fake "digital rain" animation on OLED for durationMs milliseconds
- does NOT affect radar logic; it's just startup visuals
*/
static void matrixBoot(uint16_t durationMs = MATRIX_BOOT_MS) {
// number of columns/rows of characters on 128x64 with given char spacing
const uint8_t cols = 128 / MATRIX_COL_W;
const uint8_t rows = 64 / MATRIX_ROW_H;

// arrays define each column "rain drop" head position, speed, tail length, and gap randomness
int8_t head[32];
uint8_t speed[32];
uint8_t tail[32];
uint8_t gapChance[32];

// initialize the rain columns
for (uint8_t c = 0; c < cols; c++) {
head[c] = random(-rows, 0); // start above screen
speed[c] = random(1, 4); // falling speed
tail[c] = random(5, 14); // tail length
gapChance[c] = random(8, 18); // probability of "missing" characters
}

oled.setFont(u8g2_font_5x7_tf);

uint32_t t0 = millis();
uint32_t last = 0;

while (millis() - t0 < durationMs) {
uint32_t now = millis();
// cap animation fps (roughly 25fps)
if (now - last < 40) continue;
last = now;

oled.clearBuffer();

for (uint8_t c = 0; c < cols; c++) {
int8_t h = head[c];

// draw tail characters from tail length down to head
for (int8_t k = (int8_t)tail[c]; k >= 0; k--) {
int8_t r = h - k;
if (r < 0 || r >= (int8_t)rows) continue;

// create gaps in the rain
if (k != 0 && (random(0, gapChance[c]) == 0)) continue;

int x = c * MATRIX_COL_W;
int y = (r + 1) * MATRIX_ROW_H - 1;

char ch = randBit();

// head of the drop: draw a filled box for contrast
if (k == 0) {
oled.setDrawColor(1);
oled.drawBox(x, y - (MATRIX_ROW_H - 1), MATRIX_COL_W - 1, MATRIX_ROW_H);

oled.setDrawColor(0);
oled.setCursor(x, y);
oled.print(ch);

oled.setDrawColor(1);
} else {
oled.setCursor(x, y);
oled.print(ch);
}
}

// move head down based on its speed
head[c] += speed[c];

// if it passed the screen (and some extra), restart that column
if (head[c] > (int8_t)(rows + tail[c] + random(0, rows))) {
head[c] = random(-rows, 0);
speed[c] = random(1, 4);
tail[c] = random(5, 14);
gapChance[c] = random(8, 18);
}
}

oled.sendBuffer();
delay(1);
}

// clear screen after animation
oled.clearBuffer();
oled.sendBuffer();
}

/* ============================================================
ACK PARSER (for radar configuration commands)
============================================================
HLK-LD2451 responds to config commands with a frame starting:
FD FC FB FA ...
We are not decoding content; we just verify that a response arrives.

If your Serial shows "[CFG] ACK fail", it means the module did not
respond in time OR wiring/baud is wrong.
*/
static bool readAck(uint16_t timeoutMs = 500) {
uint32_t t0 = millis();
const uint8_t H[4] = {0xFD,0xFC,0xFB,0xFA};
uint8_t stt = 0;

while (millis() - t0 < timeoutMs) {
while (Radar.available()) {
uint8_t b = Radar.read();

// simple header matching state machine
if (b == H[stt]) {
stt++;
if (stt == 4) {
// after header, radar sends payload length (2 bytes)
while (Radar.available() < 2 && millis() - t0 < timeoutMs) delay(1);
if (Radar.available() < 2) return false;

uint16_t len = (uint8_t)Radar.read();
len |= (uint16_t)((uint8_t)Radar.read()) << 8;

// read remaining bytes: payload + tail (here treated as "len + 4")
uint32_t need = (uint32_t)len + 4;
uint32_t got2 = 0;
while (got2 < need && millis() - t0 < timeoutMs) {
if (Radar.available()) { Radar.read(); got2++; }
}
return (got2 == need);
}
} else {
// restart header match
stt = (b == H[0]) ? 1 : 0;
}
}
delay(1);
}
return false;
}

/* Send a raw command packet to the radar UART */
static void sendCmd(const uint8_t* data, size_t n) {
Radar.write(data, n);
Radar.flush(); // ensure bytes are transmitted
}

/* ============================================================
RADAR CONFIGURATION SEQUENCE
============================================================
The radar supports a configuration mode.
We send:
1) enable config
2) set detection parameters (distance, direction, min speed, no-target delay)
3) set sensitivity parameters (trigger count, SNR level)
4) end config

These bytes are module-specific (HLK-LD2451 protocol).
*/
static void configureRadar() {
// 1) Enter config mode
const uint8_t enableCfg[] = {
0xFD,0xFC,0xFB,0xFA,
0x04,0x00,
0xFF,0x00,
0x01,0x00,
0x04,0x03,0x02,0x01
};
sendCmd(enableCfg, sizeof(enableCfg));
Serial.println("[CFG] enable config"); Serial.println(readAck() ? "[CFG] ACK ok" : "[CFG] ACK fail");

// 2) Set detection parameters
const uint8_t setDetect[] = {
0xFD,0xFC,0xFB,0xFA,
0x06,0x00,
0x02,0x00,
RADAR_MAX_DIST, RADAR_DIR, RADAR_MIN_SPEED, RADAR_NO_TARGET_DELAY,
0x04,0x03,0x02,0x01
};
sendCmd(setDetect, sizeof(setDetect));
Serial.println("[CFG] set detect params"); Serial.println(readAck() ? "[CFG] ACK ok" : "[CFG] ACK fail");

// 3) Set sensitivity parameters
const uint8_t setSens[] = {
0xFD,0xFC,0xFB,0xFA,
0x06,0x00,
0x03,0x00,
RADAR_TRIG_COUNT, RADAR_SNR_LEVEL, 0x00,0x00,
0x04,0x03,0x02,0x01
};
sendCmd(setSens, sizeof(setSens));
Serial.println("[CFG] set sensitivity"); Serial.println(readAck() ? "[CFG] ACK ok" : "[CFG] ACK fail");

// 4) Exit config mode
const uint8_t endCfg[] = {
0xFD,0xFC,0xFB,0xFA,
0x02,0x00,
0xFE,0x00,
0x04,0x03,0x02,0x01
};
sendCmd(endCfg, sizeof(endCfg));
Serial.println("[CFG] end config"); Serial.println(readAck() ? "[CFG] ACK ok" : "[CFG] ACK fail");
}

/* ============================================================
RADAR DATA FRAME PARSER (runtime frames, NOT config ACK)
============================================================
The radar continuously streams frames that start with header:
F4 F3 F2 F1
Then:
length (2 bytes, little-endian)
payload bytes (length)
Then tail:
F8 F7 F6 F5

This code uses a state machine to reconstruct each frame.
*/
static const uint16_t PAYLOAD_MAX = 256;
static uint8_t payload[PAYLOAD_MAX]; // buffer where payload bytes are stored

// Parser states: read header, read length, read payload, read tail
enum class RxState : uint8_t { H0,H1,H2,H3,L0,L1,PAY,T0,T1,T2,T3 };
static RxState st = RxState::H0;

static uint16_t frameLen = 0, got = 0; // frameLen=payload length, got=how many payload bytes collected
static uint32_t frames = 0; // total frames received (counter for debugging/UI)

/* ============================================================
SERIAL DEBUG SETTINGS
============================================================
If you want clean Serial output, set DEBUG_FRAMES to 0.
*/
#define DEBUG_FRAMES 1
#define DEBUG_ONLY_WHEN_TARGET 0
#define DEBUG_HEX_LIMIT 160

static void printHexByte(uint8_t b) {
if (b < 0x10) Serial.print('0');
Serial.print(b, HEX);
}

/* Print payload bytes in HEX (useful for reverse engineering protocol) */
static void dumpFrameHex(uint16_t len, const uint8_t* p) {
Serial.print("[FRM] len=");
Serial.print(len);
Serial.print(" payload: ");

uint16_t n = (len > DEBUG_HEX_LIMIT) ? DEBUG_HEX_LIMIT : len;
for (uint16_t i = 0; i < n; i++) {
Serial.print("0x");
printHexByte(p[i]);
if (i != n - 1) Serial.print(' ');
}
if (n < len) Serial.print(" ...");
Serial.println();
}

/*
Print decoded values for quick inspection.
NOTE: The decoding here matches your existing payload layout:
p[0] = count
p[1] = alarm
p[2] = angle (offset)
p[3] = distance
p[4] = direction
p[5] = speed
*/
static void dumpFrameDecoded(uint16_t len, const uint8_t* p) {
if (len < 2) { Serial.println("[FRM] too short"); return; }

uint8_t count = p[0];
uint8_t alarm = p[1];

Serial.print("[FRM] count=");
Serial.print(count);
Serial.print(" alarm=");
Serial.print(alarm);

// if at least one target exists and payload is long enough
if (count >= 1 && len >= 7) {
int angle = (int)p[2] - 0x80;
int dist = p[3];
int dir = p[4];
int spd = p[5];

Serial.print(" | T1: A=");
Serial.print(angle);
Serial.print(" D=");
Serial.print(dist);
Serial.print("m V=");
Serial.print(spd);
Serial.print("km/h DIR=");
Serial.print((dir == 0x01) ? "approach" : "away");
}

Serial.println();
}

/* Decide when to print debug output */
static void logFrame(uint16_t len, const uint8_t* p) {
#if DEBUG_FRAMES
// ignore empty payload frames
if (len == 0) return;

// optional: print only when radar reports at least one target
if (DEBUG_ONLY_WHEN_TARGET && len >= 1 && p[0] == 0) return;

dumpFrameDecoded(len, p);
dumpFrameHex(len, p);
#endif
}

/* ============================================================
TARGET STRUCTURE (decoded values)
============================================================ */
struct Target {
int angle_deg = 0; // angle in degrees (approx), relative to radar axis
uint8_t dist_m = 0; // distance in meters
bool approaching = false; // true = approaching, false = moving away
uint8_t speed_kmh = 0; // speed in km/h
};

/* ============================================================
SANITY CHECK (reject impossible values)
============================================================
Helps against noise frames / corrupt data.
You can widen these limits if needed.
*/
static const uint8_t SPEED_MAX = 250; // km/h
static const int ANGLE_MIN = -90; // degrees
static const int ANGLE_MAX = 90; // degrees

static bool saneTarget(const Target& t) {
if (t.dist_m > RADAR_MAX_DIST) return false;
if (t.speed_kmh > SPEED_MAX) return false;
if (t.angle_deg < ANGLE_MIN || t.angle_deg > ANGLE_MAX) return false;
return true;
}

/* ============================================================
ANTI-GLITCH FILTER (frame consistency)
============================================================
REQUIRED_HITS controls how many similar frames must appear in a row
before we accept them as "stable" and show them.

- REQUIRED_HITS = 1 -> fastest response, shows every measurement
- REQUIRED_HITS = 2+ -> smoother but adds delay
*/
static const uint8_t REQUIRED_HITS = 1;

/*
similar(a,b):
Defines what "similar measurements" mean.
If radar readings jump slightly, we still treat them as same target.
*/
static bool similar(const Target& a, const Target& b) {
return abs(a.angle_deg - b.angle_deg) <= 6 &&
abs((int)a.dist_m - (int)b.dist_m) <= 3 &&
abs((int)a.speed_kmh - (int)b.speed_kmh) <= 10 &&
a.approaching == b.approaching;
}

// Candidate target used for filtering
static Target candT;
static uint8_t candCount = 0;
static uint8_t candHits = 0;

/* ============================================================
PAYLOAD DECODING (common fields)
============================================================
parseCommon():
- Reads payload and extracts:
count : number of detected targets
alarm : boolean
t1 : first target (angle, distance, dir, speed)
hasT1 : whether target1 exists

RETURNS:
- true = payload format ok
- false = invalid/corrupt payload or failed sanity check
*/
static bool parseCommon(const uint8_t* p, uint16_t len,
uint8_t &count, bool &alarm, Target &t1, bool &hasT1)
{
hasT1 = false;

// len=0 frames exist in some cases -> treat as "no target"
if (len == 0) { count = 0; alarm = false; return true; }

// minimum bytes required for count + alarm
if (len < 2) return false;

count = p[0];
alarm = (p[1] == 0x01);

// if no targets -> nothing else to decode
if (count == 0) return true;

// require enough bytes for target1 fields
if (len < 7) return false;

// extract raw fields (module-specific layout)
uint8_t a = p[2];
uint8_t d = p[3];
uint8_t dir = p[4];
uint8_t spd = p[5];

// convert to human-friendly values
t1.angle_deg = (int)a - 0x80; // module uses 0x80 offset
t1.dist_m = d;
t1.approaching = (dir == 0x01);
t1.speed_kmh = spd;

// discard values that don't make sense
if (!saneTarget(t1)) return false;

hasT1 = true;
return true;
}

/* ============================================================
OLED DRAW FUNCTION
============================================================
drawOLED():
- Updates the entire screen:
frames count, number of targets, speed, distance, angle, direction

NOTE:
- This function only draws what you pass in.
- It does NOT read radar directly.
*/
static void drawOLED(uint32_t framesLocal, uint8_t count, const Target& t, bool showTarget) {
oled.clearBuffer();

// Top line: frames + target count
oled.setFont(u8g2_font_6x12_tf);
oled.setCursor(0, 12);
oled.print("frames:");
oled.print(framesLocal);

oled.setCursor(78, 12);
oled.print("targets:");
oled.print((int)count);

// Big speed number in the center-left
oled.setFont(u8g2_font_logisoso32_tn);
oled.setCursor(0, 56);
oled.print(showTarget ? (int)t.speed_kmh : 0);

// Right side details
oled.setFont(u8g2_font_6x12_tf);
oled.setCursor(78, 30);
oled.print("km/h");

oled.setCursor(78, 42);
oled.print("D:");
oled.print(showTarget ? (int)t.dist_m : 0);
oled.print("m");

oled.setCursor(78, 53);
oled.print("A:");
if (showTarget) {
oled.print(t.angle_deg);
oled.print((char)176); // degree symbol
} else {
oled.print("0");
oled.print((char)176);
}

oled.setCursor(78, 62);
if (showTarget) oled.print(t.approaching ? "approach" : "away");
else oled.print("direction");

oled.sendBuffer();
}

/* ============================================================
HOLD FUNCTIONALITY
============================================================
We store last stable target in lastT/lastCount.
If target disappears briefly, we still show it for TARGET_HOLD_MS.
*/
static Target lastT;
static uint8_t lastCount = 0;
static uint32_t lastGoodMs = 0;

/* ============================================================
SETUP()
============================================================
Runs once after reset/power-on.
*/
void setup() {
// Serial for debugging (PlatformIO Monitor)
Serial.begin(115200);
delay(150);

// I2C init for OLED
Wire.begin(I2C_SDA, I2C_SCL);

// OLED init
oled.begin();
oled.clearBuffer();
oled.sendBuffer();

// seed RNG for Matrix animation randomness
randomSeed(esp_random());

// startup visual effect
matrixBoot(MATRIX_BOOT_MS);

// Radar UART init
Radar.begin(RADAR_BAUD, SERIAL_8N1, RADAR_RX, RADAR_TX);
Radar.setTimeout(50);

// configure radar module parameters
configureRadar();

// small delay, then clear any leftover bytes from UART buffer
delay(200);
while (Radar.available()) Radar.read();

// initialize "last good time" so UI doesn't flicker at startup
lastGoodMs = millis();
}

/* ============================================================
LOOP()
============================================================
Runs repeatedly forever:
- read bytes from radar UART
- reconstruct frames
- decode payload into target info
- update last stable target
- refresh OLED at fixed interval
*/
void loop() {
// Read all available radar bytes and feed the state machine
while (Radar.available()) {
uint8_t b = (uint8_t)Radar.read();

switch (st) {
/* ---- HEADER MATCHING: expect F4 F3 F2 F1 ---- */
case RxState::H0: st = (b==0xF4)?RxState::H1:RxState::H0; break;
case RxState::H1: st = (b==0xF3)?RxState::H2:RxState::H0; break;
case RxState::H2: st = (b==0xF2)?RxState::H3:RxState::H0; break;
case RxState::H3: st = (b==0xF1)?RxState::L0:RxState::H0; break;

/* ---- LENGTH (2 bytes, little-endian) ---- */
case RxState::L0:
frameLen = b; // low byte
st = RxState::L1;
break;

case RxState::L1:
frameLen |= (uint16_t)b << 8; // high byte
got = 0;

// safety: if length too big -> reset parser
if (frameLen > PAYLOAD_MAX) { st = RxState::H0; break; }

// if payload length is 0, skip directly to tail
st = (frameLen == 0) ? RxState::T0 : RxState::PAY;
break;

/* ---- PAYLOAD BYTES ---- */
case RxState::PAY:
payload[got++] = b; // store payload byte
if (got >= frameLen) st = RxState::T0; // done -> read tail
break;

/* ---- TAIL MATCHING: expect F8 F7 F6 F5 ---- */
case RxState::T0: st = (b==0xF8)?RxState::T1:RxState::H0; break;
case RxState::T1: st = (b==0xF7)?RxState::T2:RxState::H0; break;
case RxState::T2: st = (b==0xF6)?RxState::T3:RxState::H0; break;

case RxState::T3:
// final tail byte
if (b == 0xF5) {
// frame complete!
frames++;

// optional debug print
logFrame(frameLen, payload);

// decode the payload into useful values
uint8_t count = 0;
bool alarm = false;
Target t{};
bool hasT = false;

bool ok = parseCommon(payload, frameLen, count, alarm, t, hasT);

/*
Filtering logic:
- if ok and target exists -> compare with previous candidate
- if similar -> increase hits
- once hits >= REQUIRED_HITS -> accept as stable and save to lastT
*/
// REQUIRED_HITS = 1 -> immediate update
if (ok && hasT) {
if (candHits == 0) {
candT = t;
candCount = count;
candHits = 1;
} else {
if (similar(candT, t)) candHits++;
else { candT = t; candCount = count; candHits = 1; }
}

if (candHits >= REQUIRED_HITS) {
// store as last stable target (used by UI)
lastT = candT;
lastCount = candCount;
lastGoodMs = millis();
candHits = 0;
}
}
}

// after tail processing, reset parser to search for next header
st = RxState::H0;
break;
}
}

/* ==========================================================
UI UPDATE (timed)
==========================================================
- showTarget is true if last stable measurement is recent enough
- otherwise display zeros / "no target"
*/
uint32_t now = millis();
bool showTarget = (now - lastGoodMs) <= TARGET_HOLD_MS;
uint8_t uiCount = showTarget ? lastCount : 0;

// Redraw OLED only every OLED_REFRESH_MS milliseconds (prevents flicker)
static uint32_t lastOledMs = 0;
if (now - lastOledMs >= OLED_REFRESH_MS) {
lastOledMs = now;
drawOLED(frames, uiCount, lastT, showTarget);
}
}



PlatformIO configuration (platformio.ini):

[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200

lib_deps =
olikraus/U8g2@^2.36.5

Downloads

Radar.png

Wiring and Connections:

This device uses very simple wiring and consists of three main electronic modules:

- ESP32 development board (30-pin)

- HLK-LD2451 Doppler radar module

- 2.4" OLED display (I2C)


All modules share a common ground (GND).

OLED display (I2C connections):

SDA → ESP32 GPIO21

SCL → ESP32 GPIO22

VCC → ESP32 3.3V

GND → ESP32 GND


Radar module (UART connections):

TX → ESP32 GPIO16 (RX)

RX → ESP32 GPIO17 (TX)

VCC → 5V

GND → GND


Power supply:

The ESP32 is powered via the VIN pin using 5V from a step-up converter.


Power path:

- One 18650 Li-Ion battery

- Step-up converter (boost) to 5V

- Power switch in the handle

- 5V connected to ESP32 VIN pin


The 18650 battery has a dedicated charging module located in the lower part of the handle.

Charging is independent from the main power switch.