feat(radio): support CRSF Trainer over USB-VCP - #7630
Open
BelixRogner wants to merge 1 commit into
Open
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary of changes:
SYS → Hardware → Serial Port → USB-VCPcan now be set toCRSF Trainer. With the model'strainer mode set to
Master/CRSF, a PC can drive the trainer channels by writing CRSF frames tothe 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_CRSFis already in the trainer mode enum.telemetry/crossfire.cppalready decodesCHANNELS_IDframes intotrainerInput[]and resetsthe 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 newUSB 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 framingat all". That was wrong.
_processFrames()casts its context toetx_module_state_t*and needs amodule index (
modulePortGetModule,lastAlive[module],processCrossfireTelemetryFrame), and aUSB-VCP port has no module. The algorithm was already proven; the plumbing was not, so the
assembler in
crsf_trainer.cppis 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
stm32_serial_driver.cpphassetReceiveCb = 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_TRAINERis appended to the end ofUartModes, so existing persisted 4-bitg_eeGeneral.serialPortvalues keep their meaning. The count is now 12, still insideSERIAL_CONF_MODE_MASK.isTrainerModeAvailable()also had to change. It gatedTRAINER_MODE_CRSFon an ELRS moduleCRSF Trainerto a mode that installs no RX callback of its own would leave the assemblerattached to the stream. (CLI happens to overwrite the pointer, so it was masked.)
_processFrames(). This matters: with a valid address and a sane length, a CRC failure is farmore 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.
AuxSerialModeenum already diverges from the radio's (it has noSBUS_TRAINER_INV), and its YAML is name-based, so this does not make anything worse — butCompanion 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 splitat 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:CRSF Traineroffered on USB-VCPMaster/CRSFselectable with internal RF offls /andbeepall workBuilds:
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.cppand 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.