Skip to content
Buy 10+ on select items — save 10% auto-applied
Free US shipping on orders $35+
Order by 3pm ET — ships same-day from the US
Worldwide shipping via DHL
Skip to main content

ESP32 TF-Luna LiDAR: OLED Meter With Proximity Beeper

November 11, 2026 12 views

ESP32 TF-Luna LiDAR: OLED Meter With Proximity Beeper | ShillehTek
Project

Build an ESP32 TF-Luna LiDAR distance meter with SSD1306 OLED readout and a proximity beeper for fast, reliable obstacle sensing from ShillehTek.

1 hr Beginner to Intermediate10 parts

Project Overview

ESP32 TF-Luna LiDAR Distance Meter: Read the Benewake TF-Luna time-of-flight LiDAR over UART at 100 readings per second, show distance on an OLED, and sound a parking-sensor style beeper as objects get close. You will also crimp a clean Dupont lead so the sensor plugs straight into a breadboard or robot.

  • Time: about 1 hour
  • Skill level: Beginner to Intermediate
  • What you will build: A LiDAR rangefinder from 0.2 m to 8 m with signal-strength and temperature readout, a STOP/NEAR/CLEAR status and an audible warning, ready to drop into a robot as a forward obstacle sensor.
TF-Luna LiDAR distance sensor used on a Raspberry Pi robot build as a forward-facing obstacle sensor
The reference build, Lidar Bot 0.1: a Raspberry Pi Zero W rover with an RPLidar on top and forward-facing distance sensors, including a TF-Luna. Here the TF-Luna gets its own ESP32 so any robot can use it.

Parts List

From ShillehTek

External

  • The 6-pin cable supplied with the TF-Luna (1.25 mm connector on the sensor end)
  • USB-C data cable

Note: The TF-Luna is powered from 5 V but its UART runs at 3.3 V logic, so it connects directly to the ESP32. Its 850 nm laser is a Class 1 (eye-safe) source, but it is still good practice not to stare into it.

Step-by-Step Guide

Step 1 - Get to know the TF-Luna

Goal: Understand the pins, the two interfaces, and the data format.

What to do: The TF-Luna measures the time light takes to reach a target and come back, over a narrow 2 degree beam. Benewake specifies 0.2 m to 8 m on a 90% reflective target indoors, with accuracy of about plus or minus 6 cm out to 3 m. The 6-pin connector is numbered on the module:

  • Pin 1: +5 V supply
  • Pin 2: RXD (UART) / SDA (I2C)
  • Pin 3: TXD (UART) / SCL (I2C)
  • Pin 4: GND
  • Pin 5: CFG. Leave it open for UART mode, or tie it to GND for I2C mode (address 0x10)
  • Pin 6: MUX, an auxiliary output we do not need here

In UART mode the sensor streams 9-byte frames at 115200 baud, 100 times per second by default: two 0x59 header bytes, distance (cm, low byte first), signal strength, chip temperature, and a checksum that equals the low byte of the sum of the first eight bytes. Readings with a strength below 100, or exactly 65535 (saturated), should be ignored.

Expected result: You know which wire is which by pin number. Cable colours differ between suppliers, so always go by the numbers on the sensor.

Step 2 - Crimp a breadboard-friendly lead

Goal: Turn the sensor cable into clean female Dupont connectors.

What to do: If your cable ends in bare wires (or you want a different length), terminate it yourself:

  • Trim the wires to length with the flush cutters and strip about 2.5 mm of insulation with the automatic stripper.
  • Place a female Dupont terminal in the SN-58B so the small wings close on the bare copper and the larger wings close on the insulation, then squeeze until the ratchet releases.
  • Give each wire a gentle tug test, then push the terminals into housings until the latch clicks. A single 4-pin housing for pins 1 to 4 keeps power and data together.
  • Fold back pins 5 and 6, insulate them, and label the housing so it cannot go in backwards.

Expected result: A neat lead whose terminals hold firmly and slide onto header pins without wobbling.

Step 3 - Wire the circuit

Goal: Connect the LiDAR, the display, and the buzzer to the ESP32.

What to do: Make these connections:

  • TF-Luna pin 1 (+5 V) to ESP32 5V (VIN)
  • TF-Luna pin 4 (GND) to GND
  • TF-Luna pin 3 (TXD) to GPIO16 (RX2)
  • TF-Luna pin 2 (RXD) to GPIO17 (TX2)
  • TF-Luna pin 5 (CFG) leave unconnected for UART mode
  • OLED: VCC to 3V3, GND to GND, SDA to GPIO21, SCL to GPIO22
  • KY-006: S to GPIO25, minus to GND

Expected result: TX goes to RX and RX goes to TX, and everything shares one ground.

Step 4 - Upload the sketch

Goal: Parse the frames, filter the readings, and drive the display and buzzer.

What to do: Install Adafruit SSD1306 and Adafruit GFX from the Library Manager, select ESP32 Dev Module, and upload. The parser syncs on the two header bytes and rejects any frame with a bad checksum, so a lost byte only costs one reading. A median of the last five readings removes single-frame spikes.

Code:

// ShillehTek ESP32 + TF-Luna LiDAR: UART frames, OLED readout and a proximity beeper
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

const int LIDAR_RX   = 16;          // ESP32 RX2 <- TF-Luna pin 3 (TXD)
const int LIDAR_TX   = 17;          // ESP32 TX2 -> TF-Luna pin 2 (RXD)
const int BUZZER_PIN = 25;          // KY-006 passive buzzer
const uint16_t STOP_CM = 30;        // solid tone below this distance
const uint16_t WARN_CM = 120;       // beeps get faster from here down to STOP_CM
const uint16_t MIN_STRENGTH = 100;  // weaker returns are not trustworthy

Adafruit_SSD1306 display(128, 64, &Wire, -1);
HardwareSerial Lidar(2);

uint8_t frame[9];
uint8_t framePos = 0;
uint16_t distCm = 0, strength = 0;
float tempC = 0;
bool valid = false;
uint16_t history[5];
uint8_t histCount = 0, histIdx = 0;
uint32_t framesOk = 0, framesBad = 0, framesThisSecond = 0, frameRate = 0;
unsigned long lastSecond = 0, lastDraw = 0, lastBeep = 0;
bool toneOn = false;

// Feed one byte; returns true when a complete frame with a good checksum arrives
bool parseByte(uint8_t b) {
  if (framePos == 0) {
    if (b == 0x59) frame[framePos++] = b;
    return false;
  }
  if (framePos == 1) {
    if (b == 0x59) frame[framePos++] = b;
    else framePos = 0;
    return false;
  }
  frame[framePos++] = b;
  if (framePos < 9) return false;
  framePos = 0;
  uint8_t sum = 0;
  for (uint8_t i = 0; i < 8; i++) sum += frame[i];
  if (sum != frame[8]) {
    framesBad++;
    return false;
  }
  distCm   = frame[2] | (frame[3] << 8);
  strength = frame[4] | (frame[5] << 8);
  tempC    = (frame[6] | (frame[7] << 8)) / 8.0f - 256.0f;
  valid    = strength >= MIN_STRENGTH && strength != 65535 && distCm > 0;
  framesOk++;
  framesThisSecond++;
  return true;
}

uint16_t medianDistance() {           // median of the last 5 valid readings
  uint16_t s[5];
  for (uint8_t i = 0; i < histCount; i++) s[i] = history[i];
  for (uint8_t i = 1; i < histCount; i++) {
    uint16_t v = s[i];
    int8_t j = i - 1;
    while (j >= 0 && s[j] > v) { s[j + 1] = s[j]; j--; }
    s[j + 1] = v;
  }
  return histCount ? s[histCount / 2] : 0;
}

void sendCommand(const uint8_t *cmd, uint8_t len) {   // appends the checksum byte
  uint8_t sum = 0;
  for (uint8_t i = 0; i < len; i++) {
    Lidar.write(cmd[i]);
    sum += cmd[i];
  }
  Lidar.write(sum);
}

void setFrameRate(uint16_t hz) {      // 1..250 Hz, not saved unless you send the save command
  const uint8_t cmd[5] = { 0x5A, 0x06, 0x03, (uint8_t)(hz & 0xFF), (uint8_t)(hz >> 8) };
  sendCommand(cmd, 5);
}

void updateBeeper(uint16_t d, bool ok) {
  if (!ok || d >= WARN_CM) {
    if (toneOn) { noTone(BUZZER_PIN); toneOn = false; }
    return;
  }
  if (d < STOP_CM) {
    if (!toneOn) { tone(BUZZER_PIN, 2500); toneOn = true; }
    return;
  }
  unsigned long period = map(d, STOP_CM, WARN_CM, 100, 700);   // closer = faster
  if (millis() - lastBeep >= period) {
    lastBeep = millis();
    tone(BUZZER_PIN, 2500, 40);
    toneOn = false;
  }
}

void draw(uint16_t d, bool ok) {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("TF-Luna  ");
  display.print(frameRate);
  display.print(" Hz  ");
  display.print(tempC, 0);
  display.print("C");
  display.setCursor(0, 18);
  if (!ok) {
    display.setTextSize(2);
    display.print("NO SIGNAL");
  } else {
    display.setTextSize(3);
    if (d < 100) {
      display.print(d);
      display.setTextSize(2);
      display.print(" cm");
    } else {
      display.print(d / 100.0f, 2);
      display.setTextSize(2);
      display.print(" m");
    }
  }
  display.setTextSize(1);
  display.setCursor(0, 48);
  display.print(!ok ? "check aim / range" : d < STOP_CM ? "STOP" : d < WARN_CM ? "NEAR" : "CLEAR");
  uint8_t bar = constrain(map(strength, 0, 3000, 0, 60), 0, 60);   // signal strength bar
  display.drawRect(66, 48, 62, 8, SSD1306_WHITE);
  display.fillRect(67, 49, bar, 6, SSD1306_WHITE);
  display.display();
}

void setup() {
  Serial.begin(115200);
  Lidar.begin(115200, SERIAL_8N1, LIDAR_RX, LIDAR_TX);
  pinMode(BUZZER_PIN, OUTPUT);
  Wire.begin();
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println("OLED not found");
  delay(200);
  setFrameRate(100);                  // the TF-Luna default; change to taste
  Serial.println("dist_cm,strength,temp_c,valid");
}

void loop() {
  while (Lidar.available()) {
    if (parseByte(Lidar.read()) && valid) {
      history[histIdx] = distCm;
      histIdx = (histIdx + 1) % 5;
      if (histCount < 5) histCount++;
    }
  }
  if (millis() - lastSecond >= 1000) {
    lastSecond = millis();
    frameRate = framesThisSecond;
    framesThisSecond = 0;
    if (frameRate == 0) { valid = false; histCount = 0; }   // sensor silent: show NO SIGNAL
  }
  uint16_t d = medianDistance();
  bool ok = valid && histCount > 0;
  updateBeeper(d, ok);
  if (millis() - lastDraw >= 100) {   // 10 Hz screen and serial updates
    lastDraw = millis();
    draw(d, ok);
    Serial.print(d); Serial.print(',');
    Serial.print(strength); Serial.print(',');
    Serial.print(tempC, 1); Serial.print(',');
    Serial.println(ok ? 1 : 0);
  }
}

Expected result: The OLED shows the distance in centimetres (or metres past 1 m), the frame rate near 100 Hz, the sensor temperature, and a strength bar. The Serial Monitor prints CSV ten times per second.

Step 5 - Test it like a parking sensor

Goal: Check range, accuracy, and the warning zones.

What to do: Point the sensor at a wall and walk toward it. Beyond 1.2 m the status reads CLEAR and the buzzer is silent. Between 1.2 m and 30 cm it beeps faster as you get closer, and below 30 cm it holds a steady tone and shows STOP. Try a dark jacket, a shiny surface, and a sunny window to see the strength bar drop. When it falls below the threshold the display switches to NO SIGNAL instead of showing a bogus number.

Expected result: Readings track a tape measure to within a few centimetres on matte targets. Closer than 20 cm the sensor is in its blind zone, so mount it at least that far back from the front of a robot.

Step 6 - Put it on a robot

Goal: Reuse the reader as an obstacle sensor.

What to do: The parseByte() function and the median filter are self-contained, so copy them into your robot sketch and stop the motors whenever the median distance is under your threshold. For slower updates, change setFrameRate(100) to anything from 1 to 250 Hz. If you would rather share an I2C bus, tie pin 5 to GND, power-cycle the sensor, and read six bytes starting at register 0x00 from address 0x10: distance, strength, and temperature, each low byte first.

Expected result: A fast, narrow-beam distance input that sees much farther than an ultrasonic sensor and is not fooled by soft or angled surfaces in the same way.

Conclusion

You connected a TF-Luna LiDAR to an ESP32 over UART, crimped a clean Dupont lead for it, decoded its 100 Hz frames with checksum and strength checks, and built an OLED distance meter with a proximity beeper. The same code gives a robot a reliable forward obstacle sensor out to several metres.

Want the exact parts used in this build? Grab them from ShillehTek.com. If you want help customizing this project or building something for your product, check out our IoT consulting services.

Credits

All photos and images in this tutorial are credited to dingo27 Mobile on Hackster.io (MIT license). The original Lidar Bot 0.1 build by dingo27 Mobile served as the reference for this ShillehTek version. We thank them for their excellent work in the maker community.

Parts for this build

Everything used in this tutorial. Uncheck what you already have.

All 10 in stock
0 parts selected $0.00