Hardware Hacking ยท LiDAR ยท ESP32-S3

Reverse Engineering a Dead Robot's LiDAR

๐Ÿ—“ March 2026 โฑ ~12 hours of debugging ๐Ÿ“ Jakarta, Indonesia

The 360 S7 robot vacuum had a toasted mainboard. But its LiDAR module โ€” a spinning laser rangefinder โ€” was perfectly intact. This is the story of salvaging it, reverse engineering its undocumented UART protocol, and getting 190 valid scan points per sweep on a bare ESP32-S3. No datasheet. No schematic. Just a logic analyzer, a lot of patience, and serial hex dumps.

190
Points per sweep
230ยฐ
Scan arc
ยฑ5mm
Distance accuracy
350ms
Sweep period

The Hardware

The LDS (Laser Distance Sensor) module pulled from a 360 S7 robot vacuum. The main PCB is marked Mtre1ss2018/12/13_V2.0. It has a spinning laser assembly driven by a small DC motor through a 4:1 belt-and-pulley reduction, a slip ring for passing signals to the rotating laser board, and a 6-pin JST connector (J2) that was the original interface to the robot's RK3308 mainboard.

Key Components on PCB

EG393 โ€” motor driver IC ยท 220ยตF 10V cap โ€” bulk decoupling on 5V rail ยท Inductor (331) โ€” part of buck/boost converter ยท J2 header โ€” 6 pins, only 3 used externally

LiDAR PCB internal view showing main board components
LiDAR PCB (Mtre1ss2018/12/13_V2.0) โ€” EG393 motor driver, 220ยตF capacitor, inductor, and J2 connector

The J2 connector exposes only three wires to the outside world:

J2 connector pinout
Pin 1  5V       โ€” Power input (needs 5V, 5.2V from 18650 stepdown works)
Pin 2  GND      โ€” Common ground
Pin 3  TX       โ€” UART data out @ 115200 baud, 3.3V logic
Pin 4-6 NC      โ€” Not connected externally
GPIO1 Warning โ€” ESP32-S3

GPIO1 is the USB Serial/JTAG peripheral on ESP32-S3. Connecting any external signal to it will cause immediate instability. Use GPIO8 or any other clean pin instead.

The Problems โ€” One by One

Problem 1
ESP32 goes haywire on TX connect
GPIO1 = USB JTAG. Any external signal fights the USB CDC peripheral. Root cause: wrong pin assignment in firmware.
Problem 2
rst:0x8 (TG1WDT_SYS_RST) boot loop
NimBLE stack init starves the watchdog before loop() starts. The PSRAM setting (OPI vs disabled) was also crashing the board before any code ran.
Problem 3
Wrong packet structure assumed
Initially assumed LD06/LD19 protocol (header 0x54). The 360 S7 uses a custom protocol: sync 55 AA 03 08, 34-byte packets, 8 samples each.
Problem 4
Angle field stuck at 199ยฐ
Bytes 4-5 (assumed angle) = motor speed controller setpoint, not angle. Locks to ~19881 at nominal RPM. Bytes 6-7 are the actual angle field.
Resolved
190 valid points per 350ms sweep
Correct byte offset, Mini-360 angle formula, running average per slot, 230ยฐ arc coverage.

Reverse Engineering the Protocol

No datasheet exists for this LDS. The approach was pure serial sniffing โ€” capture raw bytes, find the sync pattern, map fields by watching values change as the LDS was moved around and powered on/off.

Finding the Sync Bytes

The first hex dump immediately revealed a repeating pattern:

Raw UART dump โ€” 128 bytes
76 55 AA 03 08 A9 4D 6C DF 8D 01 19 95 01 D6 00
80 00 9B 01 07 8F 01 D9 8B 01 2B 87 01 19 88 01
58 FD E0 08 78 55 AA 03 08 A9 4D 36 E1 8D 01 51
00 80 00 00 80 00 00 80 00 0D 03 75 00 80 00 00
80 00 00 80 00 C1 E2 B6 13 55 AA 03 08 A8 4D FA
E2 BE 01 62 00 80 00 00 80 00 00 80 00 EB 02 D1
EC 02 A7 00 80 00 00 80 00 89 E4 66 59 55 AA 03
08 A8 4D C2 E4 00 80 00 00 80 00 8F 01 62 00 80

55 AA 03 08 repeating every ~34 bytes. Sync confirmed. The 00 80 00 triplets are the no-return marker (0x8000 little-endian distance + 0 intensity).

The Motor Speed Trap

The most expensive mistake: assuming bytes 4-5 were angle. They're the motor speed controller's PID output โ€” it locks to exactly 0x4DA9 = 19881 once the motor reaches nominal RPM. Watch what happens during spinup and you see the truth:

Serial Monitor โ€” power cycle observation
[LIDAR] pkts:54893  pts:59   raw:[0-65535]      โ† full sweep during spinup
[LIDAR] pkts:55936  pts:80   raw:[20927-65535]  โ† still sweeping
[LIDAR] pkts:56979  pts:43   raw:[15394-19460]  โ† converging
[LIDAR] pkts:58022  pts:17   raw:[19927-21406]  โ† nearly settled
[LIDAR] pkts:61151  pts:2    raw:[19865-19883]  โ† LOCKED at 199ยฐ forever

Bytes 4-5 = motor speed. Bytes 6-7 = actual angle. The correct field increments consistently by ~458 counts per packet, confirmed from four consecutive raw packets:

Bytes 6-7 analysis (Python)
Packet 0: bytes[6:8] = 6C DF โ†’ mini360_angle = 162.36ยฐ
Packet 1: bytes[6:8] = 36 E1 โ†’ mini360_angle = 166.94ยฐ  +4.58ยฐ
Packet 2: bytes[6:8] = FA E2 โ†’ mini360_angle = 171.46ยฐ  +4.52ยฐ
Packet 3: bytes[6:8] = C2 E4 โ†’ mini360_angle = 176.02ยฐ  +4.56ยฐ

Full Packet Layout

Bytes Hex (example) Field Notes
0-1 55 AA Sync Fixed magic bytes
2-3 03 08 Type + Count Type=0x03, 8 samples
4-5 A9 4D Motor Speed PID output, locks at ~19881 at nominal RPM
6-7 6C DF Angle Mini-360 format: (raw & 0x7FFF) โˆ’ 0x2000, ร—0.01 = degrees
8-31 8D 01 19 ... 8ร— Samples dist_L dist_H intensity (3 bytes each). 0x8000 = no return
32-33 FD E0 Checksum CRC16 (not verified, ignored)

The ESP32-S3 Parser

The final parser runs on UART2 (GPIO8 RX) at 115200 baud. A 4-byte shift-register hunts for the sync header. Once found, 30 remaining bytes are collected then parsed. The angle formula converts bytes 6-7 to degrees. Each valid distance reading is averaged into a 360-slot array indexed by integer degree.

C++ / Arduino parseLdsPacket()
// Confirmed packet layout from raw UART capture
// bytes 4-5 = motor speed (ignore)
// bytes 6-7 = angle, Mini-360 format
void parseLdsPacket(uint8_t* p) {
    uint16_t rawAngle = p[6] | (p[7] << 8);
    uint16_t masked   = rawAngle & 0x7FFF;

    if (masked < 0x2000) { parsedPackets++; return; }

    float startDeg = fmod((masked - 0x2000) * 0.01f, 360.0f);
    float arcStep  = 4.58f / (float)LDS_SAMPLES;  // ~4.58ยฐ per packet

    for (int i = 0; i < LDS_SAMPLES; i++) {
        uint8_t*  s    = &p[8 + i * 3];
        uint16_t  dist = s[0] | (s[1] << 8);

        // Filter: no-return, noise, out of range
        if (dist == 0x8000 || dist == 0x0080 ||
            dist == 0 || dist < 100 || dist > 6000) continue;

        float anglef = startDeg + arcStep * i;
        if (anglef >= 360.0f) anglef -= 360.0f;
        int slot = (int)anglef;

        // Running average per degree slot
        if (scanHits[slot] == 0) scanDist[slot] = dist;
        else scanDist[slot] = (scanDist[slot] + dist) / 2;
        if (scanHits[slot] < 255) scanHits[slot]++;
    }
    parsedPackets++;
}

The sync hunt uses a 4-byte shift register โ€” efficient and robust against partial packet captures at startup:

C++ / Arduino Sync state machine in loop()
while (LidarSerial.available() > 0) {
    uint8_t b = LidarSerial.read();
    if (!ldsSynced) {
        // Shift register โ€” hunt for 55 AA 03 08
        ldsBuf[0] = ldsBuf[1]; ldsBuf[1] = ldsBuf[2];
        ldsBuf[2] = ldsBuf[3]; ldsBuf[3] = b;
        if (ldsBuf[0] == 0x55 && ldsBuf[1] == 0xAA &&
            ldsBuf[2] == 0x03 && ldsBuf[3] == 0x08) {
            ldsBufIdx = 4;
            ldsSynced = true;
        }
    } else {
        ldsBuf[ldsBufIdx++] = b;
        if (ldsBufIdx >= LDS_PKT_SIZE) {
            parseLdsPacket(ldsBuf);
            ldsBufIdx = 0; ldsSynced = false;
            ldsBuf[0] = ldsBuf[1] = ldsBuf[2] = ldsBuf[3] = 0;
        }
    }
}

The Result

After flashing the corrected parser, the Serial Monitor showed what we'd been working toward:

Serial Monitor โ€” final firmware
[KODA] BLE Advertising STARTED
[KODA] LiDAR:  GPIO8 RX, UART2, 115200

[LIDAR] pkts:100  pts:192  closest:211mm @105deg  raw:[41094-63693]=1-227deg
[LIDAR] pkts:192  pts:188  closest:211mm @105deg  raw:[41193-63970]=2-230deg
[LIDAR] pkts:283  pts:186  closest:211mm @105deg  raw:[41011-63909]=1-229deg
[LIDAR] pkts:374  pts:182  closest:211mm @105deg  raw:[40974-63746]=0-228deg
[LIDAR] pkts:648  pts:192  closest:208mm @112deg  raw:[41033-63799]=1-228deg
What those numbers mean

pts:192 โ€” 192 unique degree slots populated per 350ms sweep. raw:[1-230deg] โ€” genuine 229ยฐ angular coverage. closest:211mm โ€” an iPad placed at exactly 210mm from the sensor. Accuracy within 1mm.

BLE Output Format

The firmware pushes scan data over BLE as compact binary pairs โ€” 4 bytes per reading: [angle_L][angle_H][dist_L][dist_H]. Chunked into 176-byte BLE notifications to stay under MTU limits. The Flutter app receives these and reconstructs the polar scan for rendering and median filtering.

Dart / Flutter BLE notification parser
// On BLE notification received from LIDAR_CHAR_UUID:
void onLidarData(List<int> bytes) {
  for (int i = 0; i + 3 < bytes.length; i += 4) {
    final angle = bytes[i] | (bytes[i + 1] << 8);
    final dist  = bytes[i + 2] | (bytes[i + 3] << 8);
    readings.add(LidarPoint(angle: angle, dist: dist));
  }
  // Median filter per degree bucket for noise reduction
  updateScanMap(readings);
}

Lessons Learned

1. Never trust assumed byte offsets

The motor speed field and the angle field looked identical in isolation โ€” both were 2-byte little-endian values. Only watching them change over time (and across a power cycle) revealed which was which.

2. Power separation matters

The LDS spin motor draws enough current on startup to brownout an ESP32 powered from USB alone. Always power the sensor from a separate rail with shared GND.

3. The Mini-360-Lidar repo is the key reference

Vidicon's reverse engineering of the AliExpress mini LiDAR (github.com/Vidicon/Mini-360-Lidar) uses the same angle encoding formula โ€” (raw & 0x7FFF) โˆ’ 0x2000 โ€” even though the packet structure differs. Cross-referencing helped crack the angle field.

4. CH343P is the right serial chip for ESP32-S3 devkits

The left USB port (native USB CDC) and external power rails fight each other on the ESP32-S3-WROOM devkit. Use the right USB port (CH343P chip) for Serial monitoring and power from the 5V header pin. Requires the WCH CH343 driver on Windows.


What's Next

This LiDAR is now one subsystem of Koda โ€” a companion robot

Koda robot prototype with 4WD chassis, ESP32-S3, and LiDAR
Koda prototype โ€” 4WD robot chassis with ESP32-S3 motor driver, breadboard electronics, and tablet for BLE control
built around a Samsung Galaxy S21 FE as the AI brain. The S21 FE runs Gemini for reasoning, receives the scan data over BLE from the ESP32-S3, and feeds it into a world state string that gets injected into the LLM context. The robot's motor driver handles 4WD movement commanded back over BLE.

Next steps: implement SLAM on the S21 FE using the 230ยฐ scan arc, integrate wheel odometry from the motor encoders for dead reckoning between scans, and build the Flutter Brain Mode visualization that renders the live polar scan map.

Full firmware source

The complete ESP32-S3 firmware โ€” motor driver, BLE stack, LiDAR parser โ€” is part of the Koda build. All three files (koda_firmware.ino, motor.h, commands.h) are self-contained and compile under Arduino IDE with NimBLE and ArduinoJson libraries.