If you want true, through-wall presence detection that doesn't false-trigger from pets or fail when you sit perfectly still, the best radar for Raspberry Pi integration is the Hi-Link HLK-LD2410C 24GHz mmWave sensor. Unlike cheap PIR sensors that only detect motion, the LD2410C uses Frequency-Modulated Continuous Wave (FMCW) radar to detect micro-movements like breathing, outputting exact target distances via UART.

The direct answer for a reliable build: use the HLK-LD2410C (not the Bluetooth-only LD2410B), wire it to the Pi's PL011 hardware UART pins (GPIO 14/15), and configure your serial port to 256,000 baud. Most online tutorials fail because they default to 115,200 baud or use the unstable mini-UART. Below is the exact hardware spec, wiring procedure, and robust Python parser to get you tracking distance gates in under an hour.

Hardware Spec Sheet and Component Selection

Before soldering, it helps to understand why we are choosing the LD2410C over other common presence sensors. The table below breaks down the real-world performance and interface differences.

Sensor Comparison: mmWave Radar vs. Doppler vs. PIR
Feature HLK-LD2410C (24GHz mmWave) RCWL-0516 (5.8GHz Doppler) Panasonic EKMB (PIR)
Detection Type FMCW Radar (Micro-motion/Breathing) Doppler Shift (Macro-motion only) Passive Infrared (Heat/Motion)
Interface UART (TX/RX) + GPIO Out Analog / Single Digital Pin Single Digital Pin (I2C variants exist)
Data Output Distance (cm), Energy, Gate mapping Binary High/Low Binary High/Low
Max Range 6m (Moving) / 4.5m (Static) 7m (Omni-directional, hard to shield) 12m (Depends on Fresnel lens)
Typical Cost ~$8.00 USD ~$2.00 USD ~$12.00 USD

Parts List and Pin Mapping

This build targets the Raspberry Pi 4 Model B (Rev 1.4) running Raspberry Pi OS (Bookworm). The LD2410C operates on 5V power but uses 3.3V logic for its UART lines, making it perfectly safe to connect directly to the Pi's GPIO without a logic level shifter.

📦 Required Components:
  • Raspberry Pi 4 Model B (or Pi 5 with UART mapping adjustments)
  • Hi-Link HLK-LD2410C mmWave Sensor Module (with pre-soldered 2.54mm headers)
  • 4x Female-to-Female Jumper Wires
  • MicroSD card with Raspberry Pi OS (64-bit, Bookworm)
LD2410C to Raspberry Pi UART Pin Mapping
LD2410C Pin Raspberry Pi GPIO Physical Pin # Function / Notes
VCC 5V Power Pin 2 or 4 Sensor requires ~70mA at 5V. Do not use 3.3V rail.
GND Ground Pin 6, 9, or 14 Common ground reference.
TX GPIO 15 (RXD) Pin 10 Sensor transmits 3.3V logic; safe for Pi RX.
RX GPIO 14 (TXD) Pin 8 Pi transmits 3.3V logic; safe for Sensor RX.

Wiring and OS Configuration Steps

The physical wiring is straightforward, but the Raspberry Pi OS requires specific configuration to route the hardware UART (PL011) to the GPIO header instead of the Bluetooth module.

  1. De-energize the Pi: Unplug the USB-C power supply before connecting jumper wires to the GPIO header to prevent accidental shorting of the 5V rail to data pins.
  2. Connect the UART Lines: Wire the sensor TX to Pi Pin 10 (RX), and sensor RX to Pi Pin 8 (TX). Remember: TX always connects to RX.
  3. Connect Power: Wire Sensor VCC to Pi Pin 2 (5V) and Sensor GND to Pi Pin 6 (GND).
  4. Edit the Boot Configuration: Boot the Pi and open a terminal. For Raspberry Pi OS Bookworm, the config file has moved. Run: sudo nano /boot/firmware/config.txt (use /boot/config.txt on older Bullseye releases).
  5. Enable Hardware UART: Add the following lines to the very bottom of the file:
    enable_uart=1
    dtoverlay=disable-bt
    Context: The disable-bt overlay detaches the Bluetooth module from the PL011 UART, giving your radar sensor the stable, high-baud-rate hardware serial port it needs. See the official Raspberry Pi UART documentation for deeper details on mini-UART vs PL011 routing.
  6. Disable the Serial Console: Run sudo raspi-config, navigate to Interface Options -> Serial Port. Select No for "login shell to be accessible over serial", and Yes for "serial port hardware to be enabled".
  7. Reboot: Run sudo reboot to apply the device tree overlays.

Python UART Code for Presence and Distance Tracking

The LD2410C streams data frames continuously. The basic reporting frame starts with the header F4 F3 F2 F1 and ends with F8 F7 F6 F5. A naive readline() approach will fail because the sensor outputs binary data, not ASCII newline-terminated strings. We must use a sliding-window byte parser.

Prerequisite: Install the pyserial library via pip install pyserial.

import serial
import time
import sys

# Target Board: Raspberry Pi 4 Model B (Rev 1.4)
# Sensor: Hi-Link HLK-LD2410C
UART_PORT = '/dev/ttyAMA0'
# CRITICAL: LD2410 default baud is 256000, not the standard 115200
BAUD_RATE = 256000 

HEADER = bytes([0xF4, 0xF3, 0xF2, 0xF1])
FOOTER = bytes([0xF8, 0xF7, 0xF6, 0xF5])

def parse_basic_frame(data):
    """
    Parses the LD2410 basic reporting mode frame.
    Returns a dict with target state, distances, and energy levels.
    """
    try:
        # Data structure offsets for Basic Mode (Data Type 0x02 or 0x01)
        # Index 8: Target State (0=None, 1=Moving, 2=Static, 3=Both)
        target_state = data[8]
        
        # Index 9-10: Moving Distance (cm, little-endian)
        move_dist = int.from_bytes(data[9:11], byteorder='little')
        move_energy = data[11]
        
        # Index 12-13: Static Distance (cm, little-endian)
        static_dist = int.from_bytes(data[12:14], byteorder='little')
        static_energy = data[14]
        
        return {
            "state": target_state,
            "move_dist_cm": move_dist,
            "move_energy": move_energy,
            "static_dist_cm": static_dist,
            "static_energy": static_energy
        }
    except (IndexError, TypeError) as e:
        print(f"[Parser Error] Malformed frame data: {e}")
        return None

def main():
    try:
        # timeout=1 prevents the script from hanging indefinitely on port read
        ser = serial.Serial(UART_PORT, BAUD_RATE, timeout=1)
        print(f"Successfully opened {UART_PORT} at {BAUD_RATE} baud.")
    except serial.SerialException as e:
        print(f"[FATAL] Could not open serial port: {e}")
        sys.exit(1)

    buffer = bytearray()
    
    try:
        while True:
            # Read available bytes into buffer
            if ser.in_waiting > 0:
                buffer.extend(ser.read(ser.in_waiting))
            
            # Sliding window search for Header and Footer
            while HEADER in buffer and FOOTER in buffer:
                header_idx = buffer.find(HEADER)
                footer_idx = buffer.find(FOOTER, header_idx)
                
                if footer_idx != -1 and footer_idx > header_idx:
                    # Extract complete frame
                    frame = buffer[header_idx:footer_idx + len(FOOTER)]
                    
                    # Basic frames are typically around 20-30 bytes long
                    if len(frame) >= 15:
                        result = parse_basic_frame(frame)
                        if result and result["state"] != 0:
                            print(f"Target Detected | State: {result['state']} | "
                                  f"Moving: {result['move_dist_cm']}cm (E:{result['move_energy']}) | "
                                  f"Static: {result['static_dist_cm']}cm (E:{result['static_energy']})")
                    
                    # Remove processed frame from buffer
                    buffer = buffer[footer_idx + len(FOOTER):]
                else:
                    # Footer not found yet, wait for more data
                    break
            
            # Prevent buffer overflow if sensor spews garbage data
            if len(buffer) > 256:
                buffer.clear()
                
            time.sleep(0.05) # 50ms polling rate
            
    except KeyboardInterrupt:
        print("\nStopping radar tracking.")
    except serial.SerialException as e:
        print(f"[ERROR] Serial connection lost: {e}")
    finally:
        if 'ser' in locals() and ser.is_open:
            ser.close()

if __name__ == "__main__":
    main()

Debugging: Port Errors and Data Dropouts

When working with hardware UART on the Pi, things rarely work perfectly on the first boot. If your script crashes or outputs nothing, here are the first three things to check:

  1. Verify the UART overlay: Run vcgencmd dtdev or check dmesg | grep tty. You must see /dev/ttyAMA0 listed. If you only see /dev/ttyS0, the Bluetooth module is still hogging the PL011 hardware UART, and your baud rate will drift.
  2. Check physical TX/RX crossing: If you get no data, swap the TX and RX wires. It is the most common bench mistake.
  3. Verify Baud Rate: The LD2410C defaults to 256,000 baud. If you previously used the manufacturer's HLKRadarTool app to change it to 115,200, you must update the BAUD_RATE variable in the Python script to match.

Common Error Strings and Ranked Causes

⚠️ Error: serial.serialutil.SerialException: [Errno 2] could not open port '/dev/ttyAMA0': [Errno 2] No such file or directory
  • Cause 1 (Most Likely): You forgot to add enable_uart=1 to config.txt and reboot.
  • Cause 2: You are running the script without sudo or your user is not in the dialout group. Fix: sudo usermod -a -G dialout $USER (requires logout/login).
⚠️ Error: serial.serialutil.SerialException: device reports readiness to read but returned no data (device disconnected or multiple access on port?)
  • Cause 1 (Most Likely): Another process (like the default serial console getty) is locking the port. Disable it via sudo systemctl disable serial-getty@ttyAMA0.service.
  • Cause 2: Baud rate mismatch causing the UART hardware to drop the buffer. Ensure Python is set to 256000.

For deeper protocol debugging, such as configuring engineering mode to read individual gate energies, refer to the ESPHome LD2410 component documentation, which contains the most comprehensive public reverse-engineering of the sensor's UART command structure.

Scaling the Build: Extensions and Simplifications

Depending on your end goal, you might need to scale this hardware setup up or down.

How to Simplify the Build

If you only need binary room occupancy (is someone in the room yes/no) and don't care about exact distance mapping or breathing detection, drop the LD2410C and use the RCWL-0516 5.8GHz Doppler radar. It costs about $2, requires only a single GPIO pin (no UART configuration headaches), and can be read with a simple GPIO.input() loop. The trade-off is that it will false-trigger from ceiling fans and moving curtains, and it cannot detect a stationary human.

How to Extend the Build

To turn this into a production-ready smart home node:

  • Add MQTT Integration: Install paho-mqtt and publish the parsed JSON dictionary to a Home Assistant MQTT broker. This allows you to create automations based on exact distance gates (e.g., "Turn on desk lamp only if static target is between 40cm and 80cm").
  • Implement Engineering Mode: The basic mode only gives you the target with the highest energy. By sending the hex command FF FB 04 00 62 00 00 00 04 00 FD FC via the serial port, you can switch the sensor to Engineering Mode. This mode outputs the movement and static energy for all 8 distance gates simultaneously, allowing you to map a room's geometry and ignore static reflections from walls or furniture.
  • Multi-Sensor Fusion: Combine the LD2410C with an I2C BME280 environmental sensor. Since the radar consumes ~70mA and slightly warms its immediate vicinity, mounting them on opposite sides of a custom PCB prevents the radar's thermal signature from skewing the BME280's temperature readings.