Free worldwide shiping on $150+

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
| ESP32 | Raspberry Pi | |
|---|---|---|
| Cost | $5–10 | $40–80 |
| Power | Battery-friendly, ~200 mW | Wall-powered, ~3–5 W |
| Processing | Streams raw data — heavy compute lives on a PC | Can run SLAM, vision, ROS 2 locally |
| Mobile robot fit | Excellent | Heavier — needs a bigger battery |
| Standalone dev rig | OK with PC | Excellent — 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 LiDAR — available 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:

Option A: direct to Pi GPIO (UART)
| LD14P cable | Pi 4 / Pi 5 GPIO | Board pin |
|---|---|---|
| 5V | 5 V | pin 2 or 4 |
| GND | GND | pin 6 |
| TX | GPIO 15 (RXD) | pin 10 |
| RX | leave 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 cable | USB-to-serial adapter |
|---|---|
| 5V | 5 V (from adapter or external supply) |
| GND | GND |
| TX | RX |
| RX | leave 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:
| Bytes | Field |
|---|---|
| 1 | Header (0x54) |
| 1 | Version + N points (0x2C = v1, 12 points) |
| 2 | Speed (°/sec, little-endian) |
| 2 | Start angle (0.01° units, LE) |
| 36 | 12 × { distance_mm (LE), intensity } |
| 2 | End angle (0.01° units, LE) |
| 2 | Timestamp (ms, LE) |
| 1 | CRC-8 (polynomial 0x4D) |
The parser:
- Reads bytes into a rolling buffer
- Searches for the
0x54 0x2Cheader pair - Validates the CRC-8 against the 256-entry table from LDROBOT’s SDK
- Decodes the 12 measurements per packet, interpolating angles linearly between start and end (handling 360° wraparound)
- 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-runraspi-config.
PermissionError on /dev/serial0:
- Add your user to the
dialoutgroup:sudo usermod -aG dialout $USERthen 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-configswaps them when you enable serial hardware. If you skipped that step, edit/boot/config.txt(or/boot/firmware/config.txton Pi 5) and adddtoverlay=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 mmin a forward angle window - Stream to ROS 2 — wrap the loop in a
LaserScanpublisher 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!



