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);
}
}