RX serial wiring auto-detect — design¶
Scope: for the currently selected serial RX protocol, detect the right
serialrx_inverted / serialrx_halfduplex / serialrx_pinswap combination
by trying each live and watching for signal. Protocol selection itself stays
manual (see prior discussion — 8 combos per protocol is tractable, ~160 across
all protocols is not, and most of the value is here anyway: users know "it's
an FBUS receiver," they don't know if their board's inverter or their wiring
guess was right).
Why this needs a firmware change, not just a configurator loop¶
rxInit() (rx.c:281) only ever runs once,
at boot (init.c:770). Every RX_CONFIG
field is SET + EEPROM_WRITE + reboot today
(Receiver.svelte:110-124).
Cycling 8 combos that way means 8 real EEPROM writes and 8 full reboot/MSP-
reconnect cycles — slow, disruptive, and needless flash wear for something
that's inherently transient (only the final answer should ever be persisted).
The good news: closing and reopening the RX serial port generically, without provider cooperation, is already possible with existing plumbing:
findSerialPortConfig(FUNCTION_RX_SERIAL)gives the identifier of whatever UART is assigned to the main RX.findSerialPortUsageByIdentifier(identifier)is already public (serial.h:144) and returns theserialPortUsage_t*holding the liveserialPort_t*for that identifier.closeSerialPort()(serial.c:445) releases it and marks the slotFUNCTION_NONEagain.- Each provider's own init (e.g.
sbusInit, sbus.c:225-234) just reopens the port itself, buildingportOptions_estraight fromrxConfig->serialrx_inverted/halfDuplex/pinSwap. Nothing provider-specific needs touching — mutate those three fields in the live (RAM-only)rxConfigMutable()and re-run the existingserialRxInit()dispatch (rx.c:172) for the currentserialrx_provider.
So the mechanism is: close current port → flip the trial bits in RAM →
re-run the same init switch that already exists → poll
rxIsReceivingSignal() (rx.c:391),
which is already exposed to MSP as mainLinkUp in
MSP2_WING_RX_INPUT_BACKUP_STATUS and already polled every 200ms by the
configurator today.
Firmware: new state machine (rx.c)¶
A small non-blocking state machine, ticked from the scheduler like any other periodic task — not run inside the MSP handler, since a combo can take up to ~1s to settle and MSP processing must stay non-blocking.
typedef enum {
RX_TRIAL_IDLE,
RX_TRIAL_RUNNING,
RX_TRIAL_SUCCESS,
RX_TRIAL_FAILED,
} rxTrialState_e;
typedef struct {
rxTrialState_e state;
uint8_t comboIndex; // 0..7, current attempt
uint8_t comboOrder[8]; // walk order, see below
timeMs_t comboStartedAt;
timeMs_t lastPollAt; // watchdog: configurator must keep polling
bool savedInverted, savedHalfDuplex, savedPinSwap; // restore point
} rxSerialTrial_t;
rxSerialTrialStart()— guards:FEATURE_RX_SERIALactive, a serial port is actually assigned, no trial already running. Snapshots the three current bits as the restore point, buildscomboOrder(below), setscomboIndex = 0, applies combo 0, closes+reopens the port, callsserialRxInit(), recordscomboStartedAt.rxSerialTrialTick()(called every scheduler pass, cheap early-out when idle):- If
state != RUNNING, nothing to do. - Watchdog: if
now - lastPollAt > 3000ms, treat as an abandoned trial (configurator crashed / USB unplugged mid-scan) → restore +IDLE. - If
rxIsReceivingSignal()has been continuously true for the debounce window (see below) →SUCCESS, stop touching the port further (leave the winning combo live so the user can literally see channels move before deciding to save). - Else if
now - comboStartedAt > settleMs[provider]→ advancecomboIndex; if that was the last of 8, restore original bits, reinit, →FAILED; else apply next combo, close+reopen, reinit. rxSerialTrialStop()— always restores the snapshotted bits and reinits, regardless of current state. Idempotent. This is the only way aSUCCESScombo becomes "real" from the firmware's point of view — it doesn't persist anything itself, ever.
Debounce¶
rxSignalReceived already flips true on the very next valid frame and
false after an internal invalid-frame timeout
(rx.c:545-554), so a single stray
checksum-lucky garbage byte is already unlikely to read as sustained signal.
Still, require it held for a short continuous window (e.g. 150–250ms) before
calling a combo a SUCCESS, rather than trusting one instantaneous poll —
cheap insurance, and easy to tune from bench testing.
Per-protocol settle timeout¶
Streaming protocols (S.BUS, CRSF, IBUS, FPort/FBUS, GHST) lock on within a
frame or two — a few hundred ms of budget is generous. Handshake-based ones
(SRXL2, Jeti EXBUS, Spektrum bind-flavored modes) need materially longer
before anything valid shows up. Rather than guess exact numbers here, add a
settleMs[] lookup keyed by serialrx_provider with a conservative default
(~600ms) and larger values (~1200-1500ms) for the handshake protocols,
then tune from real receivers on the bench. Worst case (8 combos × slowest
timeout) is ~10-12s if every combo fails — acceptable for a manual
"click and wait a few seconds" action, and the scan order below makes the
common case much faster.
Scan order — walk outward from current settings, not 0..7¶
Most real-world misconfigurations are "one thing is wrong" (usually
inverted, from a board with/without a hardware inverter), not "everything
is wrong." Order the 8 combos by Hamming distance from whatever's currently
configured: distance 0 (current — confirms the existing setting still isn't
the problem, cheap to rule out first), then the three distance-1 flips, then
the three distance-2 flips, then the distance-3 (all flipped) as last
resort. Converges in 1 try for the common single-bit-wrong case instead of
averaging 4.
New MSP2 commands¶
Next free IDs after 0x5F11 (msp_protocol.h:305-322):
| Command | ID | Request | Response |
|---|---|---|---|
MSP2_WING_RX_SERIAL_TRIAL_START |
0x5F12 |
(none — always trials the currently-configured serialrx_provider) |
U8 accepted (0 = rejected: no serial RX feature/port, or trial already running) |
MSP2_WING_RX_SERIAL_TRIAL_STATUS |
0x5F13 |
(none) | U8 state (idle/running/success/failed), U8 comboIndex, U8 inverted, U8 halfDuplex, U8 pinSwap, U16 elapsedMs — also is the watchdog keep-alive (updates lastPollAt) |
MSP2_WING_RX_SERIAL_TRIAL_STOP |
0x5F14 |
(none) | U8 ack — always restores + reinits, idempotent |
Mirrors the existing MSP2_WING_RX_INPUT_BACKUP_STATUS handler pattern
(msp.c:1321) closely enough to reuse
its shape.
Configurator side (wingflight-configurator)¶
ReceiverType.svelte: a "Detect wiring" button next to the protocol select, enabled only whenRX_PROTOCOLS[rxProtoIndex]?.feature === "RX_SERIAL"(same guard already used for the signaling SubSection at ReceiverType.svelte:65).- On click: send
TRIAL_START; if accepted, pollTRIAL_STATUSon the same ~150-200ms cadence the tab already uses forMSP2_WING_RX_INPUT_BACKUP_STATUS(Receiver.svelte:89-91), showing "Trying combo N of 8…". - On
SUCCESS: sendTRIAL_STOP(commits nothing on the FC, just cleans up the trial and restores original live bits), then apply the returnedinverted/halfDuplex/pinSwapinto localFC.RX_CONFIG— this is the only place the result becomes visible, and it flows through the tab's existing dirty-diff/Save/Revert machinery exactly like any manually-edited field. Nothing is pushed to the FC until the user hits the normal Save/Reboot button. Toast a friendly success message. - On
FAILED: sendTRIAL_STOP, toast "no signal found on this protocol — check wiring/provider," touch nothing. - Always send
TRIAL_STOPon component unmount / tab navigation, not just on terminal states — covers the user clicking away mid-scan. (Firmware's 3s watchdog is the last-resort backstop if even that doesn't arrive.)
This keeps the trial's blast radius genuinely contained: the FC can wander across up to 8 electrical configs for a few seconds while the button spins, but from the user's (and the rest of the tab's) point of view nothing is "real" until the ordinary Save button is pressed — same contract as every other field on this page today.
Open items to settle before/while implementing¶
- Exact
settleMs[]values per provider — needs bench time with real receivers, not a guess from reading code. - Where the scheduler task hook goes (need to check
fc/tasks.ctask table conventions) and confirm ticking a lightweight trial state machine there doesn't need its ownTASK_*slot vs. piggybacking on the existing RX task. - Whether
SUCCESSshould leave the winning combo live (so the user sees channel bars move as confirmation before saving) vs. immediately restoring and just reporting the numbers — leaning toward "leave it live until STOP," since seeing channels twitch is a much stronger confidence signal than a text result, but it does mean the FC sits on unsaved wiring for slightly longer.