Tutorial: Connect LDROBOT LD14P LiDAR to Raspberry Pi (Python)

Stream live distance data from an LDROBOT LD14P 2D LiDAR on a Raspberry Pi 4 or 5 using Python and pyserial. Wiring, UART setup, and full source code.

Our existing tutorials cover connecting the LDROBOT LD14P 2D LiDAR to an ESP32 with Arduino. This one is the Raspberry Pi counterpart: read live distance data on a Pi 4 or Pi 5 with a small Python script. No microcontroller in the middle.

Heads-up: this tutorial has not been bench-tested by us on the Raspberry Pi. The protocol-decoding logic is ported from our open-source Arduino LDS library, and the wiring follows the LD14P datasheet, but please verify on your hardware before relying on it in production. Bug reports welcome on our support forum.

When to use a Pi instead of an ESP32

ESP32Raspberry Pi
Cost$5–10$40–80
PowerBattery-friendly, ~200 mWWall-powered, ~3–5 W
ProcessingStreams raw data — heavy compute lives on a PCCan run SLAM, vision, ROS 2 locally
Mobile robot fitExcellentHeavier — needs a bigger battery
Standalone dev rigOK with PCExcellent — self-contained Linux box

Pick the Pi when you want a self-contained Linux node running Python (or ROS 2) right on the robot. Pick the ESP32 when you want low cost, low power, and a host PC doing the heavy lifting.

What you’ll need

  • LDROBOT LD14P LiDARavailable in our store, ships with the JST GH 4-pin breakout cable
  • Raspberry Pi 4 or Pi 5 running Raspberry Pi OS (Bookworm or newer)
  • Either GPIO UART wiring (no extra hardware), or a 3.3 V USB-to-serial adapter (FTDI / CH340 / CP2102)
  • 4 jumper wires (with the included DuPont breakout cable)

Wiring

The LD14P has 4 pins on its JST GH connector: TX (UART out from the LiDAR), RX (UART in — for optional speed-control commands), 5V, and GND. The breakout cable that ships with the LD14P labels them as shown below:

LDROBOT LD14P LiDAR with its 4-pin DuPont breakout cable, leads labeled TX, RX, 5V, and GND
LD14P with its 4-pin breakout cable. The RX lead is a UART input for optional speed-control commands — you can leave it disconnected and the LiDAR will start at its default ~6 Hz scan rate.

Option A: direct to Pi GPIO (UART)

LD14P cablePi 4 / Pi 5 GPIOBoard pin
5V5 Vpin 2 or 4
GNDGNDpin 6
TXGPIO 15 (RXD)pin 10
RXleave unconnected (or GPIO 14 / TXD on pin 8 if you want to send commands)

The LD14P starts streaming at its default ~6 Hz scan rate as soon as it’s powered — no initialization commands required. The RX pin only matters if you later want to change the rotation speed (or stop the motor) via UART commands; for the read-only tutorial here, leave it disconnected.

The Pi’s GPIO is 3.3 V-tolerant on inputs, and the LD14P’s TX is 3.3 V CMOS, so no level-shifter is needed.

Option B: USB-to-serial adapter

If you want to keep the Pi’s GPIO UART for something else (or just hate fiddling with raspi-config), use a 3.3 V USB-to-serial adapter:

LD14P cableUSB-to-serial adapter
5V5 V (from adapter or external supply)
GNDGND
TXRX
RXleave unconnected (or adapter TX if you want to send commands)

Plug it into one of the Pi’s USB ports. The device shows up as /dev/ttyUSB0.

One-time Pi setup

If using GPIO UART (Option A)

sudo raspi-config

Navigate to Interface Options → Serial Port and answer:

  • “Would you like a login shell to be accessible over serial?”No
  • “Would you like the serial port hardware to be enabled?”Yes

Reboot. The device will be available as /dev/serial0.

Install pyserial

sudo apt update
sudo apt install python3-serial

Or via pip if you prefer venvs:

pip install pyserial

The Python script

Save the following as ld14p_pi.py — or download it as a zip to skip the copy-paste:

#!/usr/bin/env python3
"""LDROBOT LD14P 2D LiDAR — Read & Print on Raspberry Pi."""

import sys
import struct
import argparse
import serial

PACKET_HEADER = 0x54
PACKET_VER_LEN = 0x2C  # version 1, 12 points per packet
POINTS_PER_PACKET = 12
PACKET_SIZE = 47
DEFAULT_BAUD = 230400

# CRC-8 table from the LDROBOT SDK (poly 0x4D)
CRC_TABLE = bytes([
    0x00, 0x4d, 0x9a, 0xd7, 0x79, 0x34, 0xe3, 0xae, 0xf2, 0xbf, 0x68, 0x25,
    0x8b, 0xc6, 0x11, 0x5c, 0xa9, 0xe4, 0x33, 0x7e, 0xd0, 0x9d, 0x4a, 0x07,
    0x5b, 0x16, 0xc1, 0x8c, 0x22, 0x6f, 0xb8, 0xf5, 0x1f, 0x52, 0x85, 0xc8,
    0x66, 0x2b, 0xfc, 0xb1, 0xed, 0xa0, 0x77, 0x3a, 0x94, 0xd9, 0x0e, 0x43,
    0xb6, 0xfb, 0x2c, 0x61, 0xcf, 0x82, 0x55, 0x18, 0x44, 0x09, 0xde, 0x93,
    0x3d, 0x70, 0xa7, 0xea, 0x3e, 0x73, 0xa4, 0xe9, 0x47, 0x0a, 0xdd, 0x90,
    0xcc, 0x81, 0x56, 0x1b, 0xb5, 0xf8, 0x2f, 0x62, 0x97, 0xda, 0x0d, 0x40,
    0xee, 0xa3, 0x74, 0x39, 0x65, 0x28, 0xff, 0xb2, 0x1c, 0x51, 0x86, 0xcb,
    0x21, 0x6c, 0xbb, 0xf6, 0x58, 0x15, 0xc2, 0x8f, 0xd3, 0x9e, 0x49, 0x04,
    0xaa, 0xe7, 0x30, 0x7d, 0x88, 0xc5, 0x12, 0x5f, 0xf1, 0xbc, 0x6b, 0x26,
    0x7a, 0x37, 0xe0, 0xad, 0x03, 0x4e, 0x99, 0xd4, 0x7c, 0x31, 0xe6, 0xab,
    0x05, 0x48, 0x9f, 0xd2, 0x8e, 0xc3, 0x14, 0x59, 0xf7, 0xba, 0x6d, 0x20,
    0xd5, 0x98, 0x4f, 0x02, 0xac, 0xe1, 0x36, 0x7b, 0x27, 0x6a, 0xbd, 0xf0,
    0x5e, 0x13, 0xc4, 0x89, 0x63, 0x2e, 0xf9, 0xb4, 0x1a, 0x57, 0x80, 0xcd,
    0x91, 0xdc, 0x0b, 0x46, 0xe8, 0xa5, 0x72, 0x3f, 0xca, 0x87, 0x50, 0x1d,
    0xb3, 0xfe, 0x29, 0x64, 0x38, 0x75, 0xa2, 0xef, 0x41, 0x0c, 0xdb, 0x96,
    0x42, 0x0f, 0xd8, 0x95, 0x3b, 0x76, 0xa1, 0xec, 0xb0, 0xfd, 0x2a, 0x67,
    0xc9, 0x84, 0x53, 0x1e, 0xeb, 0xa6, 0x71, 0x3c, 0x92, 0xdf, 0x08, 0x45,
    0x19, 0x54, 0x83, 0xce, 0x60, 0x2d, 0xfa, 0xb7, 0x5d, 0x10, 0xc7, 0x8a,
    0x24, 0x69, 0xbe, 0xf3, 0xaf, 0xe2, 0x35, 0x78, 0xd6, 0x9b, 0x4c, 0x01,
    0xf4, 0xb9, 0x6e, 0x23, 0x8d, 0xc0, 0x17, 0x5a, 0x06, 0x4b, 0x9c, 0xd1,
    0x7f, 0x32, 0xe5, 0xa8,
])


def crc8(data):
    crc = 0
    for b in data:
        crc = CRC_TABLE[crc ^ b]
    return crc


def parse_packet(packet):
    speed, start_a = struct.unpack_from('<HH', packet, 2)
    end_a, ts = struct.unpack_from('<HH', packet, 6 + POINTS_PER_PACKET * 3)
    span = end_a - start_a
    if span < 0:
        span += 36000
    step = span / (POINTS_PER_PACKET - 1)
    points = []
    for i in range(POINTS_PER_PACKET):
        dist_mm, intensity = struct.unpack_from('<HB', packet, 6 + i * 3)
        angle_deg = ((start_a + i * step) % 36000) / 100.0
        points.append((angle_deg, dist_mm, intensity))
    return speed, start_a / 100.0, end_a / 100.0, ts, points


def stream(port, baud):
    with serial.Serial(port, baud, timeout=1) as ser:
        buf = bytearray()
        while True:
            chunk = ser.read(256)
            if not chunk:
                continue
            buf.extend(chunk)
            while len(buf) >= PACKET_SIZE:
                if buf[0] != PACKET_HEADER or buf[1] != PACKET_VER_LEN:
                    found = -1
                    for i in range(1, len(buf) - 1):
                        if buf[i] == PACKET_HEADER and buf[i + 1] == PACKET_VER_LEN:
                            found = i
                            break
                    if found == -1:
                        del buf[:-1]
                        break
                    del buf[:found]
                    if len(buf) < PACKET_SIZE:
                        break
                packet = bytes(buf[:PACKET_SIZE])
                if crc8(packet[:-1]) != packet[-1]:
                    del buf[0]
                    continue
                yield parse_packet(packet)
                del buf[:PACKET_SIZE]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('port', nargs='?', default='/dev/serial0')
    ap.add_argument('--baud', type=int, default=DEFAULT_BAUD)
    ap.add_argument('--raw', action='store_true',
                    help='Print one line per measurement point')
    args = ap.parse_args()
    print(f"LD14P: opening {args.port} @ {args.baud} baud  (Ctrl-C to stop)",
          file=sys.stderr)
    try:
        if args.raw:
            print(f"{'angle':>7}  {'dist_mm':>7}  {'intensity':>9}")
            for _, _, _, _, points in stream(args.port, args.baud):
                for angle, dist, inten in points:
                    if dist == 0:
                        continue
                    print(f"{angle:7.2f}  {dist:7d}  {inten:9d}")
        else:
            print(f"{'pkt_start':>10}  {'pkt_end':>8}  {'rpm':>5}  "
                  f"{'min_mm':>7}  {'max_mm':>7}  {'n_valid':>7}")
            for speed_dps, start_deg, end_deg, _, points in stream(args.port, args.baud):
                valid = [d for _, d, _ in points if d > 0]
                if not valid:
                    continue
                print(f"{start_deg:10.2f}  {end_deg:8.2f}  {speed_dps / 6.0:5.1f}  "
                      f"{min(valid):7d}  {max(valid):7d}  {len(valid):7d}")
    except KeyboardInterrupt:
        print("\nStopped.", file=sys.stderr)


if __name__ == '__main__':
    main()

How the parser works

The LD14P emits 47-byte binary packets on its UART at 230 400 baud. Each packet contains:

BytesField
1Header (0x54)
1Version + N points (0x2C = v1, 12 points)
2Speed (°/sec, little-endian)
2Start angle (0.01° units, LE)
3612 × { distance_mm (LE), intensity }
2End angle (0.01° units, LE)
2Timestamp (ms, LE)
1CRC-8 (polynomial 0x4D)

The parser:

  1. Reads bytes into a rolling buffer
  2. Searches for the 0x54 0x2C header pair
  3. Validates the CRC-8 against the 256-entry table from LDROBOT’s SDK
  4. Decodes the 12 measurements per packet, interpolating angles linearly between start and end (handling 360° wraparound)
  5. Yields tuples of (angle, distance_mm, intensity) to the caller

A distance of 0 mm indicates an invalid measurement (the laser didn’t get a confident return at that angle) — the script skips those.

Run it

# Summarized output (one line per 12-point packet)
python3 ld14p_pi.py

# Raw output (one line per measurement)
python3 ld14p_pi.py --raw

# Or with a USB-to-serial adapter
python3 ld14p_pi.py /dev/ttyUSB0 --raw

Expected default output (one line per packet, ~330 lines/sec):

 pkt_start   pkt_end    rpm   min_mm   max_mm  n_valid
     12.34     18.97    5.9      842     1923       12
     19.20     25.83    5.9      795     1881       12
     26.05     32.68    6.0      743     1840       11
 ...

With --raw, each individual angle / distance / intensity is printed (~4 000 lines/sec).

Troubleshooting

No output / hangs at “opening port”:

  • Confirm the LiDAR is spinning audibly. If not, check that 5V and GND are connected and the supply can deliver ~300 mA. The motor starts automatically — no PWM or commands required.
  • ls /dev/serial* should show /dev/serial0. If missing, re-run raspi-config.

PermissionError on /dev/serial0:

  • Add your user to the dialout group: sudo usermod -aG dialout $USER then log out and back in.

Lots of bad CRC (data appears but corrupted):

  • Wrong baud rate. LD14P is 230 400 by default — confirm with --baud 230400.
  • Loose ground connection. The LiDAR draws ~300 mA pulsed; a flaky GND wire causes noisy serial.
  • On long jumper wires, voltage sag on VCC can corrupt the UART timing. Use shorter wires or a beefier 5 V supply.

Output but distances all zero:

  • The laser sees no reflection (pointed at glass, into open space, etc.). Try a wall ~1 m away.

Pi UART being eaten by Bluetooth (Pi 4 specifically):

  • Pi 4 wires the “good” PL011 UART to Bluetooth by default. raspi-config swaps them when you enable serial hardware. If you skipped that step, edit /boot/config.txt (or /boot/firmware/config.txt on Pi 5) and add dtoverlay=disable-bt, then reboot.

Next steps

This script gives you a stream of (angle, distance, intensity) triples. From there you might:

  • Visualize — pipe into matplotlib for a live polar plot
  • Detect obstacles — threshold on distance < 500 mm in a forward angle window
  • Stream to ROS 2 — wrap the loop in a LaserScan publisher node
  • Log to disk — pipe stdout to a CSV for offline analysis

Wire up your LiDAR, run the script, and you should see distance data flowing within a couple of seconds. Happy mapping!

Leave a Reply

Your email address will not be published. Required fields are marked *