Skip to content

feat(radio): support CRSF Trainer over USB-VCP - #7630

Open
BelixRogner wants to merge 3 commits into
EdgeTX:mainfrom
BelixRogner:feat/crsf-trainer-over-usb-vcp
Open

BelixRogner wants to merge 3 commits into
EdgeTX:mainfrom
BelixRogner:feat/crsf-trainer-over-usb-vcp

Conversation

@BelixRogner

@BelixRogner BelixRogner commented Aug 8, 2026 •

Copy link
Copy Markdown

Summary of changes:

SYS → Hardware → Serial Port → USB-VCP can now be set to CRSF Trainer. With the model's
trainer mode set to Master/CRSF, a PC can drive the trainer channels by writing CRSF frames to
the radio's CDC device. The handset then acts as a bridge: commands in over USB-C, out over its
own RF link, with the physical sticks still live so a momentary switch gives an instant override.

This replaces #7615, which did the same thing with SBUS. @3djc and @pfeerick both preferred a
modern checksummed protocol there, and they were right — CRSF turned out to be both a better fit
and less code.

Most of this already existed

  • TRAINER_MODE_CRSF is already in the trainer mode enum.
  • telemetry/crossfire.cpp already decodes CHANNELS_ID frames into trainerInput[] and resets
    the trainer validity timer. It was simply only reachable from a module's telemetry stream.

That decode is extracted here as crossfireProcessChannelsFrame() so the module path and the new
USB path share one decoder, and therefore produce identical channel values and identical freshness
behaviour.

Correction to something I said on #7615

I claimed there that _processFrames() could be reused as-is and that this "needs no new framing
at all". That was wrong. _processFrames() casts its context to etx_module_state_t* and needs a
module index (modulePortGetModule, lastAlive[module], processCrossfireTelemetryFrame), and a
USB-VCP port has no module. The algorithm was already proven; the plumbing was not, so the
assembler in crsf_trainer.cpp is new.

It is still far less awkward than SBUS over CDC: CRSF is self-framing, with a length byte and a
CRC, so no idle-line detection is needed to find frame boundaries — CDC just needs the partial
frame state kept across chunked reads.

Notes for review

  • Offered on USB-VCP only. The mode is driven from a receive callback, and
    stm32_serial_driver.cpp has setReceiveCb = nullptr, so the AUX UARTs cannot drive it today.
    If/when the USART driver grows a receive callback this restriction can simply be dropped.
  • UART_MODE_CRSF_TRAINER is appended to the end of UartModes, so existing persisted 4-bit
    g_eeGeneral.serialPort values keep their meaning. The count is now 12, still inside
    SERIAL_CONF_MODE_MASK.
  • isTrainerModeAvailable() also had to change. It gated TRAINER_MODE_CRSF on an ELRS module

    = 4.0 being enabled, which is correct when the frames come from a module but wrong when they
    arrive on a serial port — there is no module involved, and on a bench the internal RF is usually
    off. It now mirrors what TRAINER_MODE_MASTER_SERIAL does one line above:
    if (serialGetModePort(UART_MODE_CRSF_TRAINER) >= 0) return true;
    Consequence for users: USB-VCP has to be set to CRSF Trainer before Master/CRSF appears in
    the model's trainer mode list, the same ordering the SBUS path already has.

  • The receive callback is released on de-init. Without it, switching USB-VCP away from
    CRSF Trainer to a mode that installs no RX callback of its own would leave the assembler
    attached to the stream. (CLI happens to overwrite the pointer, so it was masked.)
  • A CRC failure drops the whole frame rather than re-syncing byte-by-byte, matching
    _processFrames(). This matters: with a valid address and a sane length, a CRC failure is far
    more likely to be corruption inside a real frame than a false lock, and byte-wise re-sync can
    latch onto a payload byte that happens to equal an address byte. See the test note below.
  • Companion is untouched. Its AuxSerialMode enum already diverges from the radio's (it has no
    SBUS_TRAINER_INV), and its YAML is name-based, so this does not make anything worse — but
    Companion cannot represent the new mode. Happy to follow up separately.

Testing

Unit tests — 16 new cases in radio/src/tests/crsf_trainer.cpp (117 total pass): frames split
at every chunk boundary, back-to-back frames in one packet, garbage prefix, bad CRC, corrupted
payload, corrupted frame dropped with the stream staying aligned, re-sync after truncation, insane
length byte, other frame types consumed but ignored, both address bytes accepted, gating when the
trainer mode is not CRSF, staleness after frames stop, partial-frame drop on port release, and the
two menu-availability checks.

One of those caught a real bug during development. The test channel values put 0xC8 (UART_SYNC)
inside the payload, so an earlier byte-wise re-sync on CRC failure latched onto it, read a bogus
length, and swallowed the following good frame while waiting for it to complete. Hence the
drop-whole-frame behaviour above.

On a RadioMaster TX15 Max (STM32H7), driven by tools/crsf_vcp_test.py:

Check Result
CRSF Trainer offered on USB-VCP pass
Master/CRSF selectable with internal RF off pass
CRSF frames drive the trainer channels pass — 100 Hz sweep, full travel
Mid-stream corruption pass — 59 injections of 1–11 random bytes over 30 s, no visible glitch
Frames stop, port held open pass — channels return to the sticks in ~1 s
USB-VCP back to CLI afterwards pass — prompt, ls / and beep all work

Builds: tx15, tx16s, t12max.

Not verified: ExpressLRS passthrough flashing was not directly exercised (only the CLI it
shares a path with). The momentary-switch override was verified on the SBUS build and not re-run
here — it lives in mixer.cpp and is untouched by this change.

Happy to close #7615 in favour of this, or leave it as @pfeerick suggested. Also happy to rebase
around #4102 whichever way suits.


Maintainer update

Rebased onto main, with two commits added:

  • fix(radio): harden CRSF trainer over USB-VCP
    • CHANNELS_ID frames too short to hold 16 channels are now ignored on the USB path. A CRC-valid truncated frame used to be decoded from stale buffer bytes.
    • The receive callback is now released before the port's driver context is de-initialised, next to sbusTrainerReleaseCtx() from fix(trainer): SBUS trainer context ownership #7699.
  • feat(cpn): support CRSF Trainer serial mode
    • Companion now knows CRSF_TRAINER, and also the previously missing SBUS_TRAINER_INV. Before this, both decoded to OFF and were silently dropped when radio settings were saved.
    • CRSF Trainer is offered on USB-VCP only.

⚠️ Behaviour change: ELRS passthrough flashing

On radios with an internal CRSF/ELRS module, USB-VCP defaults to CLI so ExpressLRS passthrough flashing over USB works. Setting USB-VCP to CRSF Trainer takes the CLI off the port, so passthrough flashing of the internal module will not work until USB-VCP is set back to CLI. Anyone updating ELRS through the handset's USB port needs to switch it back first.

@RCSIM-Git

Copy link
Copy Markdown

Huge thanks for this PR @BelixRogner!

This is an absolute game-changer for PC Ground Control Stations and robotics/sim-to-RC bridges (like RCSIM-GCS). Driving the internal RF link directly with CRSF packets over a single USB-C cable without needing external bridge microcontrollers is exactly what was missing.

I'm preparing to test this PR on both a RadioMaster MT12 (surface) and a RadioMaster Pocket. Will report back with test results. Really hoping to see this merged into main!

@RCSIM-Git

Copy link
Copy Markdown

Hi @BelixRogner,

Huge thanks for this PR! I've just compiled your branch (feat/crsf-trainer-over-usb-vcp) for both the RadioMaster MT12 and RadioMaster Pocket, and I'm currently putting it through its paces. It works remarkably well and solves a huge pain point for PC-to-radio setups.

I'm developing an open-core Ground Control Station / sim-racing bridge (RCSIM) that lets users drive RC models using PC steering wheels and pedals. Being able to plug radio directly into a PC via USB-C and have it act as a direct CRSF bridge without extra adapter hardware (like FTDI/RP2350 boards or external modules) is a massive step forward.

A quick technical question: Would it be within the scope of this PR (or perhaps a follow-up) to make the USB-VCP connection bidirectional and mirror incoming CRSF telemetry back to the PC (e.g., tying into telemetrySetMirrorCb)?
Having Link Statistics, battery voltage, and sensor data piped back through the same CDC port would turn the handset into a complete, zero-hardware bidirectional USB transceiver for GCS apps and Force Feedback.

If you'd like any testing or verification on the MT12 / Pocket hardware, I'd be more than happy to help!

@RCSIM-Git

Copy link
Copy Markdown

Hi @BelixRogner,

I've just tested your PR on real hardware (RadioMaster Pocket and MT12) paired with our ground station (RCSIM-GCS) and a PC sim-racing wheel. The 100 Hz channel streaming into trainerInput[] works flawlessly!

To close the loop for GCS applications and Force Feedback, I implemented bidirectional full-duplex communication so the handset also mirrors the internal RF telemetry back to the PC over the same USB-VCP port.

It requires only two tiny adjustments in radio/src/serial.cpp:

Set port direction to bidirectional:

cpp
case UART_MODE_CRSF_TRAINER:
  params.baudrate = CROSSFIRE_BAUDRATES[1];
  params.direction = ETX_Dir_TX_RX; // Changed from ETX_Dir_RX
  break;

Hook the native telemetry mirror callback to the USB CDC transmit buffer:

cpp
case UART_MODE_CRSF_TRAINER:
  if (drv && drv->setReceiveCb) {
    crsfTrainerStart(ctx, drv);
    telemetrySetMirrorCb(ctx, sendByte); // Pipe telemetry back to USB-VCP
  } else {
    crsfTrainerStop();
    telemetrySetMirrorCb(nullptr, nullptr);
  }
  break;

With this in place, the PC sends 100 Hz RC controls and simultaneously receives real-time CRSF telemetry (Link Stats, Battery, GPS, IMU) over a single USB-C cable. Tested live and 100% stable.

If you'd like to include this in your PR, feel free to cherry-pick these changes! I can also open a PR against your fork (BelixRogner/edgetx:feat/crsf-trainer-over-usb-vcp) if you prefer.

Thanks again for the incredible work!

@pfeerick pfeerick added enhancement ✨ New feature or request needs: companion rn: feature Feature to be highlighted in release notes labels Sep 19, 2026
@Vavooon

Vavooon commented Sep 25, 2026

Copy link
Copy Markdown

Great job!
We are also testing similar functionality: https://github.com/slik/edgetx/tree/feature/ext-module-over-usb-vcp
Instead of sticking to CRSF, it allows to select USB-VCP as external module, therefore you can select SBUS, CRSF or even Mavlink (which we've also implemented and testing).
We were told that this feature won't be merged due to potential military use, but I hope we are going to get some implementation accepted.

Feel free to use our branch if you are interested.

BelixRogner and others added 3 commits September 30, 2026 09:27
Allow USB-VCP to be set to CRSF Trainer, so a PC can drive the trainer
channels over the radio's USB-C port. The handset then acts as a bridge:
commands in over USB, out over its own RF link, with the physical sticks
still live so a momentary switch gives an instant override.

Most of this already existed. TRAINER_MODE_CRSF is in the trainer mode
enum, and telemetry/crossfire.cpp already decodes CHANNELS_ID frames
into trainerInput[]; it was simply only reachable from a module's
telemetry stream. That decode is extracted here as
crossfireProcessChannelsFrame() so the module path and the new USB path
share one decoder, and therefore identical scaling and freshness
behaviour.

CRSF suits USB CDC well: it is self-framing, with a length byte and a
CRC per frame, so it needs no idle-line detection to find frame
boundaries. crsf_trainer.cpp assembles frames from the arbitrarily
chunked buffers CDC delivers, and releases the receive callback on
de-init so the next user of the port gets a clean stream.

The mode is offered on USB-VCP only: it is driven from a receive
callback, and the STM32 USART driver has setReceiveCb = nullptr, so the
AUX UARTs cannot drive it today.

UART_MODE_CRSF_TRAINER is appended to the end of UartModes so existing
persisted g_eeGeneral.serialPort values keep their meaning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011yNWsmBtBtjmHtNSJjuUeA
- Ignore CHANNELS_ID frames too short to hold 16 channels. A CRC-valid
  truncated frame was decoded from stale assembler buffer bytes, applying
  garbage to the trainer channels. The module telemetry path is left as is.
- Release the receive callback before the port's driver context is
  de-initialised, next to sbusTrainerReleaseCtx() (see EdgeTX#7699), and disarm
  it before clearing the assembler state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Companion did not know the CRSF_TRAINER (nor the existing
SBUS_TRAINER_INV) serial port modes. Unknown mode names decode to OFF,
so opening and saving radio settings silently dropped them.

- add both modes, with their YAML names
- offer CRSF Trainer on USB-VCP only, as the firmware does
- hide SBUS Trainer Inv. on F4 boards, and count it as a Master/Serial
  trainer port elsewhere, matching serialGetSbusTrainerPort()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pfeerick
pfeerick force-pushed the feat/crsf-trainer-over-usb-vcp branch from e5784ee to cab4337 Compare September 30, 2026 09:43
@Vavooon

Vavooon commented Oct 23, 2026 •

Copy link
Copy Markdown

Hi. We created a PR similar to this one, but it is protocol-agnostic, therefore allows to use not only CRSF, but also MAVLink (pending PR) and probably even SBUS.
A PC can act as an external module over USB VCP, both reading channel values and sending back telemetry (CRSF or MAVLink)
You can check it here: #7938

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement ✨ New feature or request rn: feature Feature to be highlighted in release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants