Ștefan Donosă | 2025
The “Black box” problem
In the world of robotics, sensors are the eyes of the machine. For the development of a custom autonomous navigation algorithm, I selected the M1C1 Rotational LiDAR. It is a cost-effective sensor ideal for mass production, but it came with a significant caveat common to low-cost hardware: a near-total absence of technical documentation.
The lack of a datasheet or integration guide became a major roadmap blocker. Without knowing how to interpret the stream of bytes flowing from the serial port, the sensor was useless. Rather than switching to a more expensive, well-documented alternative, I decided to reverse engineer the communication protocol to integrate it into our navigation stack.
Step 0: Hardware reconnaiss…
Ștefan Donosă | 2025
The “Black box” problem
In the world of robotics, sensors are the eyes of the machine. For the development of a custom autonomous navigation algorithm, I selected the M1C1 Rotational LiDAR. It is a cost-effective sensor ideal for mass production, but it came with a significant caveat common to low-cost hardware: a near-total absence of technical documentation.
The lack of a datasheet or integration guide became a major roadmap blocker. Without knowing how to interpret the stream of bytes flowing from the serial port, the sensor was useless. Rather than switching to a more expensive, well-documented alternative, I decided to reverse engineer the communication protocol to integrate it into our navigation stack.
Step 0: Hardware reconnaissance (UART & baud rate)
Before I could write a single line of code, I had to establish a physical connection. The M1C1 lacks a user-friendly USB connector, opting instead for a raw UART interface (Universal Asynchronous Receiver-Transmitter).
Connecting the sensor to my development board (Raspberry Pi 5 8GB) involved identifying the TX (Transmit) and RX (Receive) lines. Once wired, the next challenge was “tuning in” to the right frequency. Since I didn’t have a datasheet, I didn’t know the communication speed (baud rate) or the correct startup parameters.
I engaged in a process of trial and error, listening to the address /dev/serial0. I cycled through standard rates—9600, 38400, 57600—seeing nothing but digital garbage. Finally, at 115200 baud, the random noise stabilized into a consistent stream of hexadecimal values. The connection was established; now I just had to understand what it was saying.
However, hitting the correct baud rate wasn’t the final piece of the puzzle. Initially, the line remained completely silent. I realized the sensor operates in a default “idle” mode. It requires a specific initialization command sent over the TX line to wake up—similar to an AT+START command used in modem protocols. Without sending this specific “Start scanning” byte sequence, the motor remains off and no data is transmitted.
Step 1: Data acquisition
To understand the language of the sensor, I first needed to listen to it. I wrote a Python script to capture the raw byte stream coming from the LiDAR over the serial port (/dev/serial0). The goal was to dump the hexadecimal data into a text file for static analysis.
Below is the extraction tool I developed:
#!/usr/bin/env python3
# -----------------------------------------------------------------
# Copyright (c) 2025 Ștefan Donosă
# Licensed under the MIT License.
# -----------------------------------------------------------------
import serial
import time
import os
import sys
# --- Configuration ---
SERIAL_PORT = "/dev/serial0"
BAUD_RATE = 115200
SCAN_DURATION_SECONDS = 30
OUTPUT_FILE = "lidar_raw_dump.txt"
BYTES_PER_LINE = 16 # For cleaner formatting in the text file
# --- Main program ---
def main():
buffer = bytearray()
total_bytes_written = 0
try:
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=0.1)
print(f"Serial port {SERIAL_PORT} opened. Reading raw data...")
# Open output file
with open(OUTPUT_FILE, "w") as f:
print(f"Saving all raw data to '{OUTPUT_FILE}' for {SCAN_DURATION_SECONDS} seconds...")
print("Press Ctrl+C to stop early.")
start_time = time.time()
while time.time() - start_time < SCAN_DURATION_SECONDS:
try:
if ser.in_waiting > 0:
# Read all available data
incoming_data = ser.read(ser.in_waiting)
for byte in incoming_data:
# Write byte in hex format
f.write(f'0x{byte:02x} ')
buffer.append(byte)
total_bytes_written += 1
# New line every 16 bytes for readability
if len(buffer) >= BYTES_PER_LINE:
f.write('\n')
buffer.clear()
# Display progress
print(f"\rBytes written: {total_bytes_written}...", end="")
except Exception as e_loop:
print(f"\nError in read loop: {e_loop}")
time.sleep(0.1)
print(f"\nCollection finished. Total {total_bytes_written} bytes written to file.")
except serial.SerialException as e:
print(f"ERROR: Could not open serial port: {e}")
except KeyboardInterrupt:
print(f"\nCollection interrupted by user. {total_bytes_written} bytes written.")
finally:
if 'ser' in locals() and ser.is_open:
ser.close()
print("Serial port closed.")
if __name__ == "__main__":
main()
Step 2: Pattern recognition
After running the script, I obtained a dump (lidar_raw_dump.txt) containing thousands of lines of hex data. Here is a snippet of what I found:
0xaa 0x55 0x01 0x01 0x67 0x00
0x67 0x00 0xab 0x54 0x00 0x00 0xaa 0x55 0x00 0x19 0xdb 0x00 0xe1 0x0b 0xd0 0x4e
0x24 0x08 0x00 0x08 0xf4 0x07 0xfc 0x07 0x04 0x08 0x10 0x08 0x18 0x08 0x20 0x08
0x24 0x08 0x1c 0x08 0x2c 0x08 0x6a 0x08 0x00 0x00 0x00 0x00 0xde 0x08 0xd0 0x08
0xc0 0x08 0xc4 0x08 0xd8 0x08 0xec 0x08 0x04 0x09 0x18 0x09 0x30 0x09 0x48 0x09
0x64 0x09 0xaa 0x55 0x00 0x19 0x41 0x0c 0x3b 0x17 0xa4 0x5b 0x80 0x09 0x98 0x09
0xb8 0x09 0xd4 0x09 0xf8 0x09 0x18 0x0a 0x3c 0x0a 0x60 0x0a 0x88 0x0a 0xb4 0x0a
0xdc 0x0a 0x08 0x0b 0x38 0x0b 0x68 0x0b 0x9c 0x0b 0xd4 0x0b 0x0c 0x0c 0x48 0x0c
0x84 0x0c 0xc8 0x0c 0x0c 0x0d 0x54 0x0d 0xa6 0x0d 0xf4 0x0d 0x4e 0x0e 0xaa 0x55
0x00 0x19 0xb1 0x17 0xaf 0x22 0x92 0x56 0xaa 0x0e 0x0a 0x0f 0x6e 0x0f 0xda 0x0f
0x4e 0x10 0xce 0x10 0x56 0x11 0xea 0x11 0x86 0x12 0x26 0x13 0xe2 0x13 0xaa 0x14
0x86 0x15 0x6e 0x16 0x72 0x17 0x8a 0x18 0xc6 0x19 0x26 0x1b 0xbe 0x1c 0x82 0x1e
0x6a 0x20 0x92 0x22 0x1a 0x25 0x4e 0x28 0x86 0x2b 0xaa 0x55 0x00 0x19 0x25 0x23
0x23 0x2e 0xc8 0x40 0x66 0x2d 0x3c 0x2d 0x30 0x2d 0x20 0x2d 0x1c 0x2d 0x10 0x2d
0x0c 0x2d 0x00 0x2d 0x04 0x2d 0xfc 0x2c 0x04 0x2d 0x08 0x2d 0x04 0x2d 0x10 0x2d
0x18 0x2d 0x24 0x2d 0x2c 0x2d 0x38 0x2d 0x50 0x2d 0x80 0x2d 0x00 0x00 0x66 0x06
0x58 0x06 0x54 0x06 0x5c 0x06 0xaa 0x55 0x00 0x19 0x97 0x2e 0x99 0x39 0xaa 0x5c
0x6c 0x06 0x88 0x06 0xa4 0x06 0xc8 0x06 0xfe 0x06 0x00 0x00 0xc6 0x05 0xa4 0x05
0x82 0x05 0x5a 0x05 0x2a 0x05 0xfe 0x04 0x00 0x00 0x6a 0x04 0x60 0x04 0x5c 0x04
0x54 0x04 0x4c 0x04 0x40 0x04 0x38 0x04 0x34 0x04 0x30 0x04 0x2c 0x04 0x28 0x04
0x20 0x04 0xaa 0x55 0x00 0x19 0x0f 0x3a 0x15 0x45 0xca 0x1e 0x0c 0x04 0x0c 0x04
0x10 0x04 0x18 0x04 0x20 0x04 0x28 0x04 0x38 0x04 0x4c 0x04 0x00 0x00 0x00 0x00
0x0e 0x07 0x42 0x07 0x76 0x07 0xf6 0x07 0x2a 0x08 0x54 0x08 0x00 0x00 0x00 0x00
0x26 0x31 0x78 0x30 0xec 0x2f 0x56 0x2e 0xc4 0x2d 0x4c 0x2d 0xd0 0x2d 0xaa 0x55
0x00 0x19 0x8b 0x45 0x89 0x50 0x00 0x7c 0x3c 0x2d 0xf0 0x2c 0x84 0x2c 0x00 0x2c
0x96 0x26 0xba 0x22 0x20 0x22 0xe4 0x21 0xb4 0x21 0x9c 0x21 0xd2 0x20 0xfa 0x1f
0x70 0x1f 0x2c 0x1f 0x00 0x1f 0xe0 0x1e 0xcc 0x1e 0xa0 0x1e 0x6c 0x1e 0x4c 0x1e
0x34 0x1e 0x14 0x1e 0xe0 0x1d 0xe0 0x1d 0xd8 0x1d 0xaa 0x55 0x00 0x19 0xff 0x50
0xfb 0x5b 0x1c 0x40 0xb8 0x1d 0xac 0x1d 0x9c 0x1d 0x9c 0x1d 0x00 0x00 0x74 0x09
0x88 0x09 0xc2 0x1b 0xe8 0x1b 0xb8 0x1b 0xac 0x1b 0xcc 0x1b 0xf4 0x1b 0x20 0x1c
0x48 0x1c 0x00 0x00 0x00 0x00 0x00 0x00 0x38 0x07 0x40 0x07 0x58 0x07 0x78 0x07
0x6c 0x07 0x00 0x00 0x00 0x00 0xaa 0x55 0x00 0x19 0x71 0x5c 0x6f 0x67 0xb4 0x77
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0xaa 0x55 0x00 0x19 0xe3 0x67 0xeb 0x72 0xa2 0x59 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0xaa 0x55
0x00 0x19 0x5f 0x73 0x61 0x7e 0x94 0x41 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0xaa 0x55 0x00 0x19 0xd7 0x7e
0xdd 0x89 0xa0 0xbb 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0xaa 0x55 0x00 0x19 0x53 0x8a 0x5b 0x95 0xa2 0x53
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0xaa 0x55 0x00 0x19 0xd1 0x95 0xd1 0xa0 0xaa 0x79 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0xaa 0x55
0x00 0x19 0x47 0xa1 0x53 0xac 0xbe 0x41 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0xaa 0x55 0x00 0x10 0xc9 0xac
0xad 0xb3 0xce 0x5a 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x00 0x00 0x00 0x00 0xaa 0x55 0x01 0x01 0x1b 0x00 0x1b 0x00 0xab 0x54 0x00 0x00
0xaa 0x55 0x00 0x19 0x91 0x00 0x97 0x0b 0x00 0x40 0x00 0x00 0x18 0x08 0xf4 0x07
0xf4 0x07 0xfc 0x07 0x08 0x08 0x14 0x08 0x1c 0x08 0x24 0x08 0x20 0x08 0x18 0x08
0x56 0x08 0x00 0x00 0x00 0x00 0x00 0x00 0xe2 0x08 0xc8 0x08 0xbc 0x08 0xcc 0x08
0xe0 0x08 0xf8 0x08 0x0c 0x09 0x24 0x09 0x3c 0x09 0x54 0x09
In serial communications, protocols typically use a “Header” or “Starter” — a unique sequence indicating the start of a new packet. A visual scan of the data revealed a recurring pattern: 0xAA 0x55.
The failed hypothesis
Initially, I assumed every packet starting with 0xAA 0x55 contained distance data. I attempted to parse every packet using the same logic.
The result? The autonomous navigation algorithm detected “ghost obstacles” — walls that didn’t exist. This prevented any reliable path planning and totally confused the localization system. I realized that the sensor was “speaking,” but I was misunderstanding the grammar.
Step 3: The breakthrough
By isolating the packets based on the byte length and the bytes at indices 2 and 3, I realized there were distinct Packet types. The sensor wasn’t just sending distance; it was sending status updates mixed with measurement data.
I classified the protocol into four distinct types:
Type A: The standard scan packet
-
Identifier:
0xAA 0x55 0x00 0x19 -
Length: Fixed at 40 bytes.
-
Content: This is the “gold mine.” It contains 14 measurements.
-
Data Encoding (Little-Endian): Each distance is stored in 2 bytes. Crucially, they are encoded in Little-Endian format (least significant byte first).
-
Example: If the raw bytes are
0x90 0x09, the value isn’t0x9009(which would be huge), but0x0990. -
Why? If read as Big-Endian, the values exceeded 8 meters (the sensor’s max range).
-
Formula:
Distance = (Byte_High << 8) | Byte_Low -
RPM & Angles: Bytes 4-5 store the Rotation Speed (RPM). Bytes 6-7 and 8-9 store the Start Angle and End Angle of the batch.
-
Checksum: The last two bytes are a checksum. For performance in the real-time navigation loop, I opted to verify packet integrity via structure rather than calculating the checksum for every frame.
Type B: The status packet (the “ghost” maker)
- Identifier:
0xAA 0x55 0x01 0x01 - Length: Fixed at 12 bytes.
- Function: This packet reports sensor health/status. It contains zero distance measurements.
- The Fix: My initial code was trying to read distance data from this packet, interpreting status flags as physical obstacles. Identifying and ignoring Type B packets solved the “ghost wall” problem immediately.
Type C: The null packet
A variant where measurement bytes are 0x00. This indicates no echo (infinite distance or absorption).
Type D: Variable scan
Similar to Type A, but the byte at position 3 indicates a variable number of measurements.
Conclusion
By strictly defining these packet types and handling the Little-Endian conversion correctly, I turned a stream of nonsense bytes into precise environmental data. This reverse-engineering effort was the foundation for the next step: visualizing the world in 2D and 3D.
In the next article, I will share the Python code used to turn these raw packets into a high-resolution 3D map of the robot’s environment.
Have you worked with undocumented sensors before? I’d love to hear your approach in the comments below!