Custom CRSF module for ESP32-C3 — half-duplex telemetry in a JR bay
So I had this idea — plug an ESP32-C3 into the JR bay of my EdgeTX radio, read RC channels, send telemetry back. One signal wire, how hard can it be? Spoiler: two days of debugging to get a single telemetry value on screen. Here’s the ride.
CRSF is a serial protocol. 420000 baud, 8N1. Frames start with a sync byte (0xC8), followed by length, type, payload, CRC8. Straightforward stuff.
Wired GPIO 21 to the JR bay signal pin, HardwareSerial.begin(), and… garbage. Built an auto-detect that scans baud rates and validates CRC — nothing at any rate. Hmm.
Turns out the signal is inverted! EdgeTX radios have a hardware inverter IC on the S.PORT line. The CRSF spec even says it: “Half duplex connections are typically INVERTED polarity (idle low).” Would’ve been nice to read that first ;)
The fix is a one-liner. ESP32-C3 can invert the RX signal in hardware:
uart_set_line_inverse(UART_NUM_1, UART_SIGNAL_RXD_INV);
Zero CPU cost. Boom — 150 packets/second of clean RC channel data.
Same pin, same wire. Radio sends RC data, then the module gets a brief window to respond with telemetry. STM32 (what radios use) has native half-duplex UART. ESP32-C3? Nope.
Oh boy, I tried everything:
uart_set_line_inverse with both RX+TX inversion — killed RX permanentlySerial.end() + begin() to reinit after TX — data lost during reinitgpio_matrix_out + SIG_GPIO_OUT_IDX detach combos — pin stays drivingWhat finally worked: GPIO matrix switching, modeled after the mLRS project. ESP32’s GPIO matrix lets you dynamically route peripheral signals to pins:
// TX mode: disconnect RX, connect TX (inverted)
gpio_matrix_in(MATRIX_DETACH_IN_LOW, U1RXD_IN_IDX, true);
gpio_set_direction(pin, GPIO_MODE_OUTPUT);
gpio_matrix_out(pin, U1TXD_OUT_IDX, true, false);
// Back to RX mode
gpio_reset_pin(pin); // <-- crucial on ESP32-C3!
gpio_set_direction(pin, GPIO_MODE_INPUT);
gpio_matrix_in(pin, U1RXD_IN_IDX, true);
Heads up: on ESP32-C3, gpio_set_direction(INPUT) alone does NOT release the TX output. You need gpio_reset_pin() to fully detach. Wasted hours on this. The original ESP32 behaves differently here.
After sending, uart_wait_tx_done() blocks until the last byte is clocked out (~167μs for a 7-byte frame), then gpio_reset_pin() fully releases the TX output before re-enabling RX.
I could see my flight mode value “ON” show up on the radio. YES! Then the radio stopped sending RC data. Dead. Required a full power cycle to recover.
This was the most frustrating part. Half-duplex was working. Frame format valid. CRC correct. But the radio froze after receiving just one telemetry frame. Every. Single. Time.
Root cause: Buried in the EdgeTX source I found the CRSF handshake protocol. When the radio receives any valid telemetry from the external module bay, it enters a module initialization sequence:
This is completely undocumented outside the source code. The fix is almost comically simple:
if (frameType == CRSF_FRAMETYPE_DEVICE_PING) {
sendDeviceInfo("CRSFDude");
}
Voila! Radio stays happy. RC channels keep flowing, telemetry shows up, no freezes.
A reusable CRSFDude library for ESP32-C3 that handles all the gnarly bits:
The actual application? ~40 lines:
CRSFDude crsf;
void setup() {
crsf.begin(20, 420000);
}
void loop() {
if (crsf.update()) {
uint16_t throttle = crsf.getChannel(2);
crsf.sendFlightMode("ACRO");
}
}
gpio_reset_pin() is your friend on C3.gpio_reset_pin() after TX — on ESP32-C3, the only reliable way to release the UART TX output from the pin.