Instrument Control

Read BLHeli_32 ESC Telemetry in a Test

Learn how to read BLHeli_32 ESC telemetry in a TofuPilot test: parse the KISS frame, convert eRPM to rpm and record per-motor measurements with limits.

JJulien Buteau
advanced10 min readOctober 27, 2026

A BLHeli_32 ESC already measures speed, temperature, voltage and current for each motor it drives. Reading that telemetry in a test gives a per-motor speed without a tachometer and a per-channel temperature without a thermocouple. Learn how the KISS telemetry frame and bidirectional DShot eRPM work, how to convert eRPM to mechanical rpm, and how to parse frames in a TofuPilot plug and record them as per-motor measurements with limits.

Two Telemetry Channels

BLHeli_32 ESCs can report data back over two separate channels:

KISS serial telemetryBidirectional DShot eRPM
WireDedicated telemetry pad (TX from the ESC), one wire shared by the four channels of a 4-in-1 ESCThe DShot signal wire itself
Format10-byte frame, 115200 baud, 8N1Short reply after each DShot frame, decoded by the controller
ContentTemperature, voltage, current, consumption, eRPMeRPM only
RateOne frame per request, one motor at a timeEvery DShot frame, every motor
RequirementTelemetry request bit set in the DShot frameESC firmware with bidirectional DShot support, controller that sends inverted DShot and decodes the reply

Use KISS telemetry for temperature, voltage and a coarse current, and bidirectional DShot when you need speed at a high rate, for example during a ramp. Both report electrical speed, which you convert to mechanical rpm yourself.

The KISS Telemetry Frame

The frame layout below is the one Betaflight decodes in src/main/sensors/esc_sensor.c. One frame is 10 bytes, sent at 115200 baud, 8N1, multi-byte fields big-endian:

ByteFieldUnit
0Temperature°C
1-2Voltage0.01 V
3-4Current0.01 A
5-6ConsumptionmAh
7-8Electrical speed100 eRPM
9CRC-8 over bytes 0 to 8polynomial 0x07, initial value 0

The ESC sends a frame only when asked. The controller sets the telemetry request bit in one DShot frame addressed to one motor, and that ESC channel answers on the shared telemetry wire. On a 4-in-1 ESC the controller therefore polls the motors in turn: request motor 1, wait for its frame, request motor 2, and so on. A frame that fails its CRC is dropped, not corrected.

Field scaling is not identical across every ESC firmware and every telemetry mode. Check the speed field once against an optical tachometer and the voltage field against a meter before trusting the scaling on a new ESC model.

Bidirectional DShot eRPM

With bidirectional DShot, the controller sends an inverted DShot signal and the ESC replies on the same wire shortly after each frame. The reply carries the period of one electrical revolution in microseconds, in a compact exponent and mantissa format with its own 4-bit checksum, GCR encoded. The conversion is:

bidir-dshot.txt
eRPM = 60,000,000 / period_us

Decoding the reply is timing-critical: it must happen in the microcontroller that generates DShot, not on the station PC. The microcontroller then reports decoded eRPM values to the host over USB serial. It is the right source for a speed trace during a ramp, because it updates with every DShot frame instead of once per telemetry poll.

Convert eRPM to Mechanical rpm

Both channels report electrical revolutions. A brushless motor completes one electrical revolution per pole pair, so:

erpm-to-rpm.txt
pole_pairs = magnets / 2rpm        = eRPM / pole_pairs12N14P motor: 14 magnets, 7 pole pairs31,000 rpm  ->  217,000 eRPM  ->  KISS speed field = 2170

The "N" number in 12N14P counts stator slots and does not enter the formula; the "P" number counts magnets. Getting the pole pairs wrong scales every speed reading by a constant factor, and every unit still passes or fails together, so the error is easy to miss. A sanity check: no-load speed sits a little below KV x bus voltage. A 1250 KV motor on a 25.2 V bus gives at most 31,500 rpm, and the example motor measured 31,000 rpm at 100 % throttle without propeller.

Choose Where the Frame Is Parsed

Two wirings work with a TofuPilot station:

OptionWiringProsCons
A: host reads the telemetry wireTelemetry pad to the RX of a 3.3 V USB-UART adapter, common ground; the microcontroller sets the request bit on commandFrame parsing visible in Python, raw frames available for debuggingTwo serial ports, request and read must be coordinated
B: microcontroller forwards decoded valuesTelemetry pad to a UART of the microcontrollerOne serial port, telemetry polled continuouslyParsing lives in firmware

This guide uses option A. The microcontroller that generates DShot accepts a TLM <motor> command over its own USB serial link, sets the telemetry bit in the next frame for that motor, and replies OK.

Step 1: Parse the Frame

Keep the parser free of I/O, so it can be unit-tested with recorded frames:

plugs/kiss.py
25 lines
FRAME_SIZE = 10def crc8(data):    crc = 0    for byte in data:        crc ^= byte        for _ in range(8):            crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF    return crcdef parse_frame(frame, pole_pairs):    """Return a dict for a valid 10-byte KISS frame, None if the CRC fails."""    if len(frame) != FRAME_SIZE or crc8(frame[:9]) != frame[9]:        return None    erpm = ((frame[7] << 8) | frame[8]) * 100    return {        "temperature_c": frame[0],        "voltage_v": ((frame[1] << 8) | frame[2]) / 100,        "current_a": ((frame[3] << 8) | frame[4]) / 100,        "consumption_mah": (frame[5] << 8) | frame[6],        "erpm": erpm,        "rpm": erpm / pole_pairs,    }

The CRC loop is a direct port of the updateCrc8 function in Betaflight: XOR the byte in, then eight shifts with polynomial 0x07.

Step 2: Write the Telemetry Plug

The plug owns the telemetry serial port and returns plain dicts, which cross the plug process boundary without trouble:

requirements.txt
pyserial>=3.5
plugs/esc_telemetry.py
21 lines
import serialfrom plugs.kiss import FRAME_SIZE, parse_frameclass EscTelemetry:    def __init__(self, port, pole_pairs):        self.pole_pairs = pole_pairs        self.ser = serial.Serial(port, 115200, timeout=0.05)    def __del__(self):        if hasattr(self, "ser"):            self.ser.close()    def flush(self):        self.ser.reset_input_buffer()    def read_frame(self):        """Return the parsed frame, or None on timeout or CRC failure."""        raw = self.ser.read(FRAME_SIZE)        return parse_frame(raw, self.pole_pairs)

Plugs cannot call each other, so the phase coordinates the two: it flushes the telemetry port, asks the motor driver plug to request a frame, then reads. The operating system buffers the incoming bytes in between, so the frame is not lost if it arrives before read_frame() is called.

Step 3: Record Per-Motor Measurements With Limits

Run each motor at a fixed throttle, collect a series of frames and record one set of measurements per motor. One function serves the four phases through with::

procedure.yaml
81 lines
name: ESC Telemetry Checkversion: 0.1.0execution:  workers: 1plugs:  - name: Motor Driver    key: motor    python: plugs.motor:MotorDriver    scope: station    with:      port: /dev/ttyACM0      pole_pairs: 7  - name: ESC Telemetry    key: esc_telemetry    python: plugs.esc_telemetry:EscTelemetry    scope: station    with:      port: /dev/ttyUSB0      pole_pairs: 7main:  - name: Telemetry Motor 1    key: telemetry_m1    python: phases.telemetry:telemetry_check    timeout: 45s    with:      motor_index: 1      throttle_pct: 70      frames: 50    measurements: &telemetry_measurements      - name: Motor Speed        key: motor_speed        unit: rpm        description: Mean of valid KISS frames at 70 % throttle, no propeller, eRPM / 7        validators:          - operator: ">="            expected_value: 21000          - operator: "<="            expected_value: 24500      - name: ESC Temperature        key: esc_temperature        unit: °C        validators:          - operator: "<="            expected_value: 60      - name: ESC Voltage        key: esc_voltage        unit: V        validators:          - operator: ">="            expected_value: 21.0          - operator: "<="            expected_value: 25.5      - name: ESC Current        key: esc_current        unit: A        description: ESC-reported, uncalibrated. Recorded for cross-check, no limits.      - name: Frame Error Rate        key: frame_error_rate        unit: "%"        validators:          - operator: "<="            expected_value: 5  - name: Telemetry Motor 2    key: telemetry_m2    python: phases.telemetry:telemetry_check    timeout: 45s    with:      motor_index: 2      throttle_pct: 70      frames: 50    measurements: *telemetry_measurements  # Motors 3 and 4 follow the same patternteardown:  - name: Stop All Motors    python: phases.telemetry:stop_all

The YAML anchor (&telemetry_measurements) and alias (*telemetry_measurements) are standard YAML and keep the four phases on one set of limits. The speed window brackets the 22,600 rpm measured at 70 % on the example motor; the temperature limit is an example value. Set yours from known-good units.

phases/telemetry.py
37 lines
import timeRAMP_RATE_PCT_S = 20def telemetry_check(motor, esc_telemetry, measurements, phase, log, motor_index, throttle_pct, frames):    motor.ramp_to(motor_index, throttle_pct, RAMP_RATE_PCT_S)    time.sleep(throttle_pct / RAMP_RATE_PCT_S + 2.0)  # ramp, then settle    valid, errors = [], 0    try:        for _ in range(frames):            esc_telemetry.flush()            motor.request_telemetry(motor_index)            frame = esc_telemetry.read_frame()            if frame is None:                errors += 1            else:                valid.append(frame)            time.sleep(0.02)    finally:        motor.ramp_to(motor_index, 0, RAMP_RATE_PCT_S)    measurements.frame_error_rate = 100 * errors / frames    if not valid:        phase.fail(f"No valid telemetry frame from motor {motor_index} in {frames} requests")    n = len(valid)    measurements.motor_speed = sum(f["rpm"] for f in valid) / n    measurements.esc_temperature = max(f["temperature_c"] for f in valid)    measurements.esc_voltage = sum(f["voltage_v"] for f in valid) / n    measurements.esc_current = sum(f["current_a"] for f in valid) / n    log.info(f"Motor {motor_index}: {n} valid frames, {errors} errors")def stop_all(motor):    motor.stop(50)

The motor driver plug is the one that generates DShot on the bench; this phase assumes it exposes ramp_to, stop and request_telemetry, the last one sending TLM <motor> to the firmware. The finally block ramps the motor down even when a read raises, and the teardown phase stops every motor on any other exit.

Each run then carries speed, temperature, voltage, current and frame error rate per motor, tied to the serial number of the unit. On the dashboard, a channel whose temperature drifts up across units, or a frame error rate that climbs on one station, shows up in the control chart of that measurement before units start failing.

What Each Measurement Is Good For

MeasurementTrust it forDo not use it for
Motor speed (from eRPM)Speed at a given throttle, desynchronization, wrong pole countAbsolute accuracy before one tachometer check
ESC temperatureChannel-to-channel comparison, thermal drift over a runThe motor winding temperature
ESC voltageBus sag at the ESC, cross-check against a meterCalibrated bus voltage
ESC currentGross faults, cross-check against a shuntPass/fail limits, especially at low current
Frame error rateWiring, ground and baud rate problemsMotor quality

ESC current is reported with a 0.01 A field, but the sensing inside the ESC is not calibrated per unit and the offset dominates at the few amps a motor draws without propeller. Measure current with a shunt when it matters.

Common Pitfalls

Using the wrong pole count. 12N14P has 7 pole pairs, not 6 and not 12. A wrong value scales every rpm by a constant and survives unnoticed.

Requesting telemetry from several motors at once. A 4-in-1 ESC has one telemetry wire. Two answers at once collide and fail the CRC. Poll one motor at a time.

Reading the frame without flushing first. Old bytes in the buffer shift the frame, and every frame fails the CRC. Flush, request, read.

Forgetting the common ground. Without it, the USB-UART adapter reads noise and the frame error rate climbs.

Driving the telemetry pad with a 5 V adapter. The pad is a low-voltage logic output. Use a 3.3 V adapter.

Putting limits on ESC current. Record it and compare, but judge current with a shunt.

Leaving the phases in parallel. Set workers: 1: two phases polling the same telemetry wire corrupt each other's frames.

Key Points

  • KISS telemetry is a 10-byte frame at 115200 baud: temperature, voltage (0.01 V), current (0.01 A), consumption (mAh), speed (100 eRPM) and a CRC-8 with polynomial 0x07.
  • The ESC answers only when the telemetry bit is set for its motor; on a 4-in-1 ESC, poll the motors one at a time.
  • Bidirectional DShot gives eRPM with every frame and is decoded in the microcontroller; eRPM = 60,000,000 / period in µs.
  • Mechanical rpm = eRPM / pole pairs, 7 for a 12N14P motor.
  • Record speed, temperature, voltage and frame error rate per motor with limits; record ESC current without limits and measure current with a shunt.

For the wider picture of motor and actuator test benches, see Motor and Actuator Testing with TofuPilot.

More Guides

Put this guide into practice