Micolink Protocol
Micolink is the serial protocol MicoAir's optical flow and range sensors use to report measurements. It is one of several the sensors can speak — the others being MAVLink for ArduPilot and PX4, and MSP for INAV — and it is the one to choose when you are writing your own software rather than feeding a flight controller.
The reason to use it is simplicity. A complete parser is a few dozen lines, it carries exactly the sensor's data and nothing else, and it has no version negotiation, no dialects and no message registry to consult.
If you just want the sensor working on a flight controller, you do not need this page — use MAVLink or MSP and follow the Optical Flow Setup Guide. This is for reading the sensor from a microcontroller, a companion computer, or a test rig of your own.
Frame layout
| Offset | Size | Field | Value for a range sensor |
|---|---|---|---|
| 0 | 1 | Frame head | 0xEF |
| 1 | 1 | Device ID | 0x0F for the MTF sensors |
| 2 | 1 | System ID | 0x00 |
| 3 | 1 | Message ID | 0x51 — range sensor |
| 4 | 1 | Sequence | increments each frame, wraps at 255 |
| 5 | 1 | Payload length | 0x14 — 20 bytes |
| 6 | len | Payload | see below |
| 6 + len | 1 | Checksum | see below |
So a complete message 0x51 frame is 27 bytes: six header, twenty payload, one checksum.
Checksum
The 8-bit sum of every byte from the frame head through the last payload byte, with overflow discarded. Nothing clever — add the bytes into a uint8_t and let it wrap.
checksum = (byte[0] + byte[1] + ... + byte[5 + len]) & 0xFF
It is a sum, not a CRC, so it will not catch every corruption. On a short direct wire that is fine. On a long or noisy link, treat an occasional bad frame as normal and simply drop it.
The 0x51 payload
20 bytes, little-endian, with no padding between fields. In C that means the struct needs packing — the compiler would otherwise insert alignment bytes and every field after the first would be read from the wrong place.
| Offset | Type | Field | Unit | Notes |
|---|---|---|---|---|
| 0 | uint32 | time_ms | ms | sensor uptime |
| 4 | uint32 | distance | mm | 0 means no valid reading |
| 8 | uint8 | strength | — | returned signal strength |
| 9 | uint8 | precision | — | range precision indicator |
| 10 | uint8 | tof_status | — | 1 = range data usable |
| 11 | uint8 | reserved | — | |
| 12 | int16 | flow_vel_x | cm/s @ 1 m | signed |
| 14 | int16 | flow_vel_y | cm/s @ 1 m | signed |
| 16 | uint8 | flow_quality | — | higher is more trustworthy |
| 17 | uint8 | flow_status | — | 1 = flow data usable |
| 18 | uint16 | reserved | — |
The two status bytes are independent
A sensor can have good flow and no valid range, or the reverse. Flying over a textured floor beyond the rangefinder's limit gives you the first; pointing at a blank white wall a metre away gives you the second.
So check them separately, and check them rather than testing the distance against some minimum — the status byte is what the sensor is telling you, and a distance of 0 is the explicit "no reading" value.
"cm/s @ 1 m" is not a typo
This is the part worth understanding before you use the numbers for anything.
An optical flow camera does not measure speed. It measures how fast the image moves across its sensor, which is an angular rate. The same ground speed produces less image movement the higher you are, because everything is further away.
So the reported value is normalised: the speed you would be doing if you were exactly one metre above the ground.
actual speed (cm/s) = reported value × height above ground (m)
Two consequences for anyone using this data:
- You need a height source. That is why these sensors pair a rangefinder with the flow camera in the same package, and why the same frame carries both.
- Height errors become speed errors, proportionally. A 10% height error gives a 10% speed error. Over uneven ground, or with the aircraft tilted so the beam is not measuring vertical height, that is where the error comes from.
A reference parser in C
A byte-at-a-time state machine, because that is how serial data arrives. Feed it every received byte; it returns true on the byte that completes a valid frame.
This version accumulates the checksum as bytes arrive and reads the payload field by field, so it does not depend on struct layout or on your compiler honouring a packing pragma.
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#define MICOLINK_HEAD 0xEF
#define MICOLINK_MAX_PAYLOAD 64
#define MICOLINK_MSG_RANGE 0x51
typedef struct {
uint8_t dev_id;
uint8_t sys_id;
uint8_t msg_id;
uint8_t seq;
uint8_t len;
uint8_t payload[MICOLINK_MAX_PAYLOAD];
/* parser state — not part of the wire format */
uint8_t state;
uint8_t index;
uint8_t sum;
} micolink_parser_t;
/* Returns true on the byte that completes a frame with a good checksum. */
bool micolink_feed(micolink_parser_t *p, uint8_t b)
{
switch (p->state) {
case 0: /* wait for the start byte */
if (b == MICOLINK_HEAD) {
p->sum = b;
p->state = 1;
}
break;
case 1: p->dev_id = b; p->sum += b; p->state = 2; break;
case 2: p->sys_id = b; p->sum += b; p->state = 3; break;
case 3: p->msg_id = b; p->sum += b; p->state = 4; break;
case 4: p->seq = b; p->sum += b; p->state = 5; break;
case 5: /* payload length */
p->sum += b;
p->len = b;
p->index = 0;
if (b > MICOLINK_MAX_PAYLOAD) {
p->state = 0; /* implausible — resynchronise */
} else {
p->state = (b == 0) ? 7 : 6; /* empty payload skips straight on */
}
break;
case 6: /* payload bytes */
p->sum += b;
p->payload[p->index++] = b;
if (p->index == p->len) {
p->state = 7;
}
break;
case 7: /* checksum */
p->state = 0;
return (b == p->sum);
default:
p->state = 0;
break;
}
return false;
}
Reading the payload out. Little-endian assembly by hand, so it is correct on any target and does not care how the compiler lays out structs:
static uint16_t rd_u16(const uint8_t *b) {
return (uint16_t)b[0] | ((uint16_t)b[1] << 8);
}
static uint32_t rd_u32(const uint8_t *b) {
return (uint32_t)b[0] | ((uint32_t)b[1] << 8) |
((uint32_t)b[2] << 16) | ((uint32_t)b[3] << 24);
}
typedef struct {
uint32_t time_ms;
uint32_t distance_mm;
uint8_t strength;
uint8_t precision;
uint8_t tof_status;
int16_t flow_vel_x; /* cm/s at 1 m */
int16_t flow_vel_y; /* cm/s at 1 m */
uint8_t flow_quality;
uint8_t flow_status;
} micolink_range_t;
bool micolink_read_range(const micolink_parser_t *p, micolink_range_t *out)
{
if (p->msg_id != MICOLINK_MSG_RANGE || p->len < 20) {
return false;
}
const uint8_t *d = p->payload;
out->time_ms = rd_u32(d + 0);
out->distance_mm = rd_u32(d + 4);
out->strength = d[8];
out->precision = d[9];
out->tof_status = d[10];
out->flow_vel_x = (int16_t)rd_u16(d + 12);
out->flow_vel_y = (int16_t)rd_u16(d + 14);
out->flow_quality = d[16];
out->flow_status = d[17];
return true;
}
Using it, from a UART receive loop:
static micolink_parser_t parser;
void on_uart_byte(uint8_t b)
{
if (!micolink_feed(&parser, b)) {
return;
}
micolink_range_t r;
if (!micolink_read_range(&parser, &r)) {
return;
}
if (r.tof_status == 1 && r.distance_mm != 0) {
float height_m = r.distance_mm / 1000.0f;
if (r.flow_status == 1) {
/* normalised flow -> real ground speed */
float vx_cms = r.flow_vel_x * height_m;
float vy_cms = r.flow_vel_y * height_m;
use_velocity(vx_cms, vy_cms);
}
use_height(height_m);
}
}
0xEF costs one frameA byte-at-a-time parser with a one-byte start delimiter cannot do better. If noise puts a 0xEF in the stream just before a real frame, the parser starts there, reads the next six bytes as a header, and the length or the checksum then fails — by which point the real frame has gone past. It resynchronises on the following frame.
At 50 Hz that is a 20 ms gap, which is not worth extra code to avoid on an embedded target. The Python version below can do better, because it holds a buffer it can back up through, and it does: on a bad checksum or an implausible length it retries from the next candidate byte rather than discarding the whole window.
p->len < 20, not != 20Checking for at least the fields you read, rather than an exact length, means a future firmware that appends fields to the end of the payload will still work with your parser instead of silently going quiet. It costs nothing to write it that way.
The same thing in Python
For bench work, logging and analysis, where you are reading from a USB-to-serial adapter rather than a microcontroller:
import struct
import serial
HEAD = 0xEF
MSG_RANGE = 0x51
MAX_PAYLOAD = 64
def frames(port, baud=115200):
"""Yield (msg_id, payload) for every frame with a valid checksum."""
ser = serial.Serial(port, baud, timeout=1)
buf = bytearray()
while True:
buf.extend(ser.read(64) or b"")
while True:
start = buf.find(HEAD)
if start < 0: # nothing that could start a frame
buf.clear()
break
del buf[:start] # drop everything before it
if len(buf) < 6: # header not complete yet
break
length = buf[5]
if length > MAX_PAYLOAD:
del buf[0] # implausible — that 0xEF was not a frame start
continue
total = 6 + length + 1
if len(buf) < total: # frame not complete yet
break
frame = bytes(buf[:total])
if sum(frame[:-1]) & 0xFF == frame[-1]:
del buf[:total]
yield frame[3], frame[6:-1]
else:
del buf[0] # bad checksum — resync from the next 0xEF
def read_range(payload):
(time_ms, distance_mm, strength, precision, tof_status, _r1,
flow_x, flow_y, flow_quality, flow_status, _r2) = struct.unpack(
"<IIBBBBhhBBH", payload[:20])
return dict(time_ms=time_ms, distance_mm=distance_mm,
strength=strength, precision=precision,
tof_status=tof_status, flow_x=flow_x, flow_y=flow_y,
flow_quality=flow_quality, flow_status=flow_status)
for msg_id, payload in frames("COM7"):
if msg_id != MSG_RANGE:
continue
r = read_range(payload)
if r["tof_status"] != 1 or r["distance_mm"] == 0:
continue
height_m = r["distance_mm"] / 1000.0
if r["flow_status"] == 1:
print(f"{height_m:6.2f} m "
f"{r['flow_x'] * height_m:7.1f} / {r['flow_y'] * height_m:7.1f} cm/s "
f"q={r['flow_quality']}")
else:
print(f"{height_m:6.2f} m flow unavailable")
The <IIBBBBhhBBH format string is the payload table read straight down: < for little-endian, then two uint32, four uint8, two int16, two uint8 and a uint16. It sums to 20 bytes, which is a useful thing to assert if you change it.
Easily confused points
The checksum covers the header too, starting at the 0xEF.
It is a sum, not a CRC. Occasional undetected corruption is possible on a noisy link.
The payload is packed. A plain C struct will have alignment padding and read every field after the first from the wrong offset.
Flow velocities are normalised to 1 m. They are not speeds until multiplied by height.
The two status bytes are independent. Do not gate flow on range validity or the reverse.
Distance 0 means no reading, not zero distance.
The sequence number wraps at 255. Use it to detect dropped frames, not as a message counter.
Related guides
- MicoAssistant Guide — selecting the Micolink protocol on a sensor
- MTF-01 · MTF-01P · MTF-02 / MTF-02P
- MT-06 Laser Rangefinder · MT-01P
- Optical Flow Setup Guide — the flight controller route, if you do not need to parse anything yourself
Where to buy
Sensors that speak this protocol, shipped from Canada with free Canada-wide shipping:
- MicoAir MTF-01 — optical flow and 8 m rangefinder
- MicoAir MTF-01P — optical flow and 12 m laser rangefinder
- MicoAir MTF-02P — 1.5 g micro optical flow sensor
Written and maintained by the Robofusion engineering team.