Skip to content

Spektrum Smart ESC (SRXL2) motor output - #11947

Open
MrScothh wants to merge 34 commits into
iNavFlight:maintenance-10.xfrom
MrScothh:feature/srxl2-esc-10x
Open

MrScothh wants to merge 34 commits into
iNavFlight:maintenance-10.xfrom
MrScothh:feature/srxl2-esc-10x

Conversation

@MrScothh

@MrScothh MrScothh commented Sep 14, 2026

Copy link
Copy Markdown

Still a draft, but no longer an untested one: the driver has now been run against a
real Avian, and links, drives the motor and reads telemetry correctly. What remains
open is listed at the end, and one of those items is a measurement that disagrees
with an assumption rather than a gap.

What this adds

Spektrum's "Smart Throttle" is SRXL2 carried on the ESC's throttle wire: the ESC is
an SRXL2 device and the receiver, or here the flight controller, is the bus master.
The same single wire carries throttle one way and telemetry the other.

INAV can now take that master role, which makes two things available that a PWM
connection cannot:

  • Thrust reverse. On an Avian this is only reachable over Smart Throttle. Wired
    as a conventional PWM ESC there is no way to command reverse at all.
  • Telemetry with no extra wire. Voltage, current, rpm and temperatures arrive on
    the throttle wire, feeding the OSD, Blackbox, current estimation and the gyro RPM
    filter. There is no telemetry lead to run and no ESC telemetry pad to connect.

The ESC's signal wire goes to a UART TX pin, not to a motor pad, and the port is
opened half duplex. motor_pwm_protocol = SRXL2 selects it. One ESC per port, so a
model with several motors needs a port each; the board refuses to arm if there are
fewer ports than motors, because a motor with no port has no timer output to fall
back on.

Reverse is a mode, THRUST REVERSE. Spektrum describe the ESC's reverse channel as
a switch — "flipping the designated switch reverses motor rotation, throttle will
still control motor speed"
— so it maps onto a mode rather than onto the
reversible-motor mixer state, which models a centre-zero stick and would hand the
ESC roughly half throttle where the pilot expects the motor stopped.
FEATURE_REVERSIBLE_MOTORS is therefore cleared for this protocol, next to the line
that already does the same for brushed.

There is also a throttle-range calibration, driven from the Outputs tab or
esc_calibrate in the CLI, because a Spektrum ESC learns its endpoints from the
signal present as it powers up and that normally needs a Spektrum transmitter.

Off by default except where flash is plentiful: about 3.5 KB, enabled on H7 and AT32
and on SITL, and any other target can opt in with one line in its target.h.

How it is tested

The master is written from the published specification. Spektrum's library
implements the device side only — the bus master is not part of the open release —
so it is validated against that library, compiled as the counterpart:

inav/src/main/io/motor_srxl2.c is built for the host behind thin stubs and wired to
Spektrum's spm_srxl.c configured as a Smart ESC. Two ESCs are simulated on two
ports as two independent copies of the library, compiled under renamed symbols,
because raising SRXL_NUM_OF_BUSES turns it into a hub that forwards between buses
and two Avians on two wires do no such thing.

57 checks pass, 2175 frames reach an ESC, none rejected by their parser. Covered:
handshake and baud negotiation, throttle scaling, thrust reverse, telemetry decode,
the whole calibration sequence including every refusal, per-instance isolation on a
twin, a silent ESC on one port of two, and a deliberately starved wire that holds
the transmit buffer busy across ticks — that last one exists because the deferred
baud switch depends on isSerialTransmitBufferEmpty() answering honestly, and a
harness that always claims "drained" never tests it. It found two real defects.

Also verified over MSP against a SITL build of this branch: assigning the port,
setting the protocol, rebooting, and reading back one open port and the mode
registered.

Builds: TBS_LUCID_H7_WING, AIKONF7 (feature compiled out) and SITL with
-Werror.

Measured against an Avian

Confirmed working on the Avian 70 A Smart Lite, part SPMXAE70C.

The 70 A parts are the same controller and differ only in their battery connector -
SPMXAE70B is this one with an IC3 where the C has an IC5 - so what was measured
here holds across them.

Wider than that is expectation rather than measurement, though a well founded one:
SRXL2 is a published protocol, nothing in this driver is written for a particular
model, and any Smart ESC that speaks it should work the same way. The one thing this
bench found that genuinely varies between models is the baud negotiation, and the
driver already takes whatever an ESC advertises instead of assuming - which is
exactly why it survived meeting one that advertises nothing.

The driver was not run on a flight controller for this: the same protocol code was
driven from a host over a single-wire adapter, which is why numbers rather than
impressions appear below.

The link works. The ESC is device 0x40, announces itself unprompted at
power-up exactly as specification 7.2.1 describes for a unit id whose low nibble is
zero, and answers a directed handshake. It has to be met while it is still
announcing: one that has sat powered in silence for a while stops asking and then
ignores handshakes until it is power-cycled.

Telemetry decodes as STRU_TELE_ESC assumes. 77 requests, 77 replies. Sensor
id 0x20, pack voltage 0x09A8 -> 24.72 V on a 6S, FET temperature 0x0168 ->
36.0 C, rpm and current zero at rest. Field order, big-endian packing and every
scale are as written. The 0xFFFF/0xFF no-data path is exercised too, by the BEC
fields this ESC does not populate.

It relays a second sensor. Smart Battery telemetry, sensor id 0x42, arrives
interleaved with the ESC's own. The handler already checks that byte before
decoding, so nothing changes - but anything else reading this wire needs to.

The motor runs, and the scale is right. Reported throttle tracks commanded
throttle to within a point from 10 % upwards, with a small dead band below, and rpm
rises monotonically across 25 consecutive readings. srxl2UsToValue() is now
checked against what the ESC does with the numbers rather than only against
Spektrum's own parser.

Calibration works, and turns out to be required. Uncalibrated, that ESC ignored
everything below 1238 us - the bottom quarter of the range, silently. Calibrated by
this driver's sequence, it answered from 1050 us. docs/Spektrum Smart ESC.md now
says so where someone will read it before flying. The three-second settle taken from
the published tone timings later turned out to need adjustment - see the update
below.

The receive timeout is real. 200 ms of silence survived, 400 ms did not, and the
link recovered by itself afterwards - consistent with the 0.25 s the manual states,
and twelve times the driver's 20 ms interval. Turnaround from request to reply
measured 0.5 to 1.8 ms.

400000 baud does not exist on this ESC, and forcing it is destructive. It
advertises baudSupported = 0x00. Sending the broadcast at 400000 anyway leaves it
unreachable until the battery is pulled. The driver already cannot do this - the
agreed rate is SRXL2_BAUD_BIT_400K & baudSupported, which is zero here - and that
line turns out to be the one thing standing between this protocol and a dead link in
flight.

Two collisions this rebase turned up

Both were invisible against the older base and both would have been silent.

Serial function bit 28 is already assigned to the MassZero thermal camera in the
Configurator, whose firmware side is not on this branch. SRXL2 takes 29 instead. Say
so if the camera is going elsewhere and this can move back.

BOXTHRUSTREVERSE collided with AUTO SPEED, TERRAIN AGL HOLD and IN FLIGHT MENU; it
is now 63 with permanent id 72.

Still open

  • The reported current may be the motor's, not the pack's. The ESC reported
    2.85 A while the bench supply feeding it measured 0.999 A at the same instant, at
    about 40 % throttle - the relationship expected of a converter, where pack current
    is roughly motor current times duty. One operating point cannot separate that from
    a plain scale error in the ESC's sensor, so this is stated and not concluded. It
    matters only for current_meter_type = ESC; the documentation says to prefer a
    board's own shunt meanwhile. Readings across several throttle settings against an
    instrumented supply are the next measurement.
  • Whether reverse is a switch or a proportional scale. Spektrum word it both
    ways: "flipping the designated switch reverses motor rotation, throttle will
    still control motor speed"
    in one place, and "when selected transmitter channel
    is positioned between 0 - 100% travel, the motor will run in reverse"
    in the
    programming instructions. The driver assumes the first.
    docs/Spektrum Smart ESC.md describes the bench check, propeller off.
  • The parameter reply framing is moot. Section 7.4 specifies the request and
    never the reply, and this ESC simply ignores 0x50 - 16 queries, no answer, with
    telemetry still flowing throughout to prove the link was up. Whatever configures
    an Avian is not this packet, so nothing in this PR depends on it.
  • Companion Configurator PR: Spektrum Smart ESC (SRXL2) support in Ports and Outputs inav-configurator#2770

🤖 Generated with Claude Code


Update, 16 September: a longer bench session, with the numbers

Everything below was read off the ESC's own telemetry on an Avian 70 A, motor
connected except where stated. Several of these exist because the first answer was
wrong, and where an earlier claim in this PR is contradicted the measurement that
replaces it is given in full.

Asking for telemetry on every frame stops the throttle

Fixed 1250 us command, two channels in the frame, one power-up, only the request
rate changing:

requests throttle reported rpm telemetry frames in 4 s
every 4th frame, 12.5 Hz 14.0 % 6606 32
every 3rd frame, 16.7 Hz 14.0 % 6605 43
every 2nd frame, 25 Hz 14.0 % 6610 64
every frame, 50 Hz 0.0 % 0 128

The link does not drop: at 50 Hz the ESC answers more often than ever and simply
stops obeying. Both the failure and the recovery happened twice in that single
power-up, so it is the rate and not an accident of ordering. The driver now clamps
to every second frame, and esc_srxl2_telemetry_rate no longer offers 50 Hz.

The rate setting was named for the wrong thing

The ESC answers about two requests in three and rotates its reply between three
sensors, so ESC data arrives at roughly a ninth of the request rate. Measured over
20 s per row:

requests/s ESC frames/s text page battery ESC
25.0 2.70 8.0/s 5.3/s 2.7/s
10.0 1.10 3.2/s 2.1/s 1.1/s
5.0 0.50 1.6/s 1.1/s 0.5/s
2.0 0.20 0.7/s 0.5/s 0.2/s

The setting's values are now named for what arrives: 3HZ, 2HZ, 1HZ, 0_5HZ,
0_2HZ, default 1HZ - which is the same behaviour the old 10HZ default had.

The block of eight channels was a confounded measurement

The earlier reading in this PR - one channel does not arm, eight does - varied the
telemetry rate at the same time. Repeated with the rate held fixed at every third
frame, same 1250 us command, one power-up:

channels in the frame throttle rpm
throttle only 14.0 % 6606
throttle + ch2 14.0 % 6618
throttle + ch7 14.0 % 6617
first four 14.0 % 6608
all eight 14.0 % 6610

The same five masks with telemetry requested on every frame gave 0.0 % and 0 rpm,
the single-channel mask included - the very mask that had appeared to be the cure.
So the frame now carries the throttle and the reverse channel and nothing else,
which is twelve bytes a frame less on a wire the telemetry reply has to share.

Verified through the driver itself, reading the wire between a SITL build and the
real ESC:

esc_srxl2_reverse_channel channels on the wire
7 ch1, ch7
9 ch1, ch9
0 ch1
1 (the throttle - refused) ch1

The calibration timings were too short

Three seconds high and five low sound the tones and store nothing. Four and seven
store it. Measured by sweeping the raw channel value and reading back the throttle
the ESC reports, motor disconnected:

3 s / 5 s (no effect) 4 s / 7 s
starts responding at 12220 (1178 us) 2687 (1029 us)
saturates at 50820 (1781 us) 64307 (1993 us)
share of the channel used 59 % 94 %

Uncalibrated the response is linear but displaced - 0.16 % per microsecond, zero
around 1162 us and full scale around 1787 us - so 41 % of INAV's range does nothing
and full power arrives at about three quarters stick, with nothing to say so.
Calibrated, what the ESC reports tracks what INAV commands across the whole range:

commanded reported
1050 us 5.0 %
1113 us 11.0 %
1300 us 30.0 %
1500 us 50.0 %
1691 us 69.0 %
1847 us 84.5 %
2000 us 100.0 %

SRXL2_CAL_SETTLE_MS is now 4000 and SRXL2_CAL_LOW_MS 7000.

Driving it as the mixer does

Values as the driver receives them, after calibration:

command throttle rpm
1000 (mincommand, disarmed) 0.0 % 0
1080 (armed, throttle_idle 8 %) 8.0 % 3520
1100 10.0 % 4480
1200 20.0 % -

Worth knowing: uncalibrated, this ESC does not turn at INAV's armed idle at all;
calibrated, it does. Arming goes from a still propeller to a turning one, which is
correct and is the point of throttle_idle, but it changes on the day the
calibration is done.

Thrust reverse, including with the motor running

Reverse engages from rest and at speed. Engaged at 4490 rpm against an unchanged
1100 us command, the motor changed direction: one telemetry sample caught it at
0 rpm during the transition and the next had it back at the same speed the other
way, with no current step large enough to read at that load. Direction was confirmed
by eye, counter-clockwise and clockwise alternating with the channel, four passes.

Through the flight controller rather than the bench harness: with SITL armed and
THRUST REVERSE active the configured channel goes to 2000 on the wire and back to
1000 when released, alongside an unchanged throttle channel.

Finding an ESC: the polling finds nothing

An Avian announces itself six times in the 300 ms after it gains power and is mute
from then on. Against a running one, with the bus otherwise quiet:

attempt frames sent replies
handshake to its own address 128 0
handshake broadcast 128 0
handshake across 0x40..0x4F 128 0
control data requesting telemetry 319 0

So the link is made at power-up or not at all. A board that reboots under a battery
that stayed connected never links, and arming into that state commands a motor that
is not listening - the model arms, the telemetry looks sane, and the propeller does
not turn.

Arming is therefore refused while the link is missing. Where ESC and board come
up together this clears in about a second and is never seen. Verified both ways
against the real ESC: with the wire idle arming is refused and the OSD reports the
hardware; with the ESC linked the same configuration arms and drives it.

What is still not tested

  • Only one ESC model. The usable band, the reverse channel range and its default
    vary across Avians, so nothing here assumes them.
  • Throttle through SITL, which cannot emit it: the motor-writing block in mixer.c
    is compiled out under SITL_BUILD, so the throttle path was exercised against the
    ESC directly and only the frame shape, telemetry and reverse paths were checked
    through the firmware.
  • The bench supply limits current to one ampere, so nothing above about 20 % throttle
    has been run under load.

MrScothh and others added 22 commits September 14, 2026 10:43
Spektrum's "Smart Throttle" is SRXL2 on the throttle signal wire: the ESC is an
SRXL2 slave (device type 4, IDs 0x40-0x4F) and the receiver, or here the flight
controller, is the bus master. Reverse on an Avian is only reachable this way -
over plain PWM the ESC has no way to be told - so this is the only route to
thrust reverse on INAV.

This commit adds the protocol layer alone: framing, CRC, handshake with baud
negotiation, Control Data with a channel mask, failsafe, and decoding of
STRU_TELE_ESC telemetry. The packet structures are the ones INAV already carries
in rx/srxl2_types.h for the receiver side, so both ends of the protocol share a
single definition.

Two deliberate properties worth not undoing:

- The master starts silent. Specification 7.2.1 requires a master sharing its
  UART with a possible throttle PWM line to wait for a slave handshake rather
  than speak first, because sending SRXL2 at a non-SRXL2 ESC can cause
  "unintended movement or erratic behavior". If nothing answers within 200 ms it
  concludes no SRXL2 device is present and stays quiet.
- There is no fallback to PWM. The pin is a UART pin, not a timer output, so
  "degrade gracefully to PWM" is not available and must not be faked.

Channel values use the exact inverse of the scaling rx/srxl2.c already applies
when decoding, so 1500 us maps onto 0x8000, the specification's "Servo Center".

Not yet wired to motorWritePtr, settings, or esc_sensor; those follow. Builds
clean for SITL with -Wall -Wextra -Werror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewing how existing bus-master implementations behave against real Smart ESCs
surfaced five things the specification leaves implicit, all of which the first
version got wrong:

1. Device ID. We announced ourselves as 0x30, unit ID 0. Specification 7.1.1
   gives unit 0 the meaning "send an unprompted handshake at startup", which is
   slave behaviour: a master doing it collides with the ESC's own announcements.
   Now 0x31.

2. Never finding a quiet ESC. The master listened and, hearing nothing, stayed
   silent for good. Only an ESC with unit ID 0 announces itself, so an ESC
   configured otherwise would never have been found. It now listens first, to
   keep out of the way of the common case, then polls the ESC device ID.

3. Baud switch losing a frame. serialSetBaudRate() was called immediately after
   queueing the broadcast that tells the bus to change rate, so the tail of that
   very frame would have been clocked out at the new rate. The change is now
   deferred until isSerialTransmitBufferEmpty().

4. Control Data at the wrong rate. It was emitted per motor update, i.e. at loop
   rate. The specification has the master send at the rate RF frames arrive, tens
   of hertz, and 115200 baud could not carry loop rate anyway - one frame is
   about 1.6 ms on the wire. Now rate-limited to 50 Hz, independent of the mixer.

5. Telemetry requested on every frame. Telemetry is a reply, so asking every
   frame doubles bus occupancy and forces a half-duplex turnaround each time for
   values that barely change. Now every fifth frame, giving 10 Hz.

Also handles the ESC powering up after the flight controller, which is the normal
bench case: the board runs on USB and the ESC only boots with the battery, long
after the listen window closed, so its handshake arrives mid-RUNNING and has to
be honoured rather than ignored.

Builds clean for SITL with -Wall -Wextra -Werror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three more things from comparing against working bus-master implementations:

- RSSI was sent as 0 because we are not an RF device. The field is defined as
  "best RSSI when sending channel data", and an ESC may reasonably read nothing
  as a dead link and apply its own failsafe, so 0 is the wrong answer even when
  technically true. Now 100.

- Unused channels are still not padded with centred values, and there is now a
  comment saying why, because the opposite choice looks tidier. Padding with
  centre is right for a surface ESC, where centre means stopped. On an aircraft
  ESC 1500 us is half throttle, so if the ESC read throttle on an index we had
  not anticipated, padding would spin the motor at 50 percent while sending
  nothing there leaves it idle. A wrong guess should fail safe.

- Documented that the channel scaling deliberately does not reach the ends of
  the 0..65532 range, since a receiver's full travel decodes to 988..2012 us
  rather than 1000..2000, and named it as the constant to revisit if an ESC
  cannot reach full throttle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… full range

The previous comment had this backwards, suggesting the constant should be
changed if an ESC cannot reach full throttle.

Spektrum ESCs learn their throttle endpoints during the ESC/Radio calibration
their manual describes, and INAV cannot run that procedure: it requires full
throttle to be present as the battery is connected, and INAV outputs mincommand
while disarmed. The calibration is therefore performed with a Spektrum
transmitter, or left at the factory default, and the range the ESC remembers is a
receiver's. Emulating a receiver is the reason this scaling is correct, not a
compromise.

The roughly 1.2 percent of travel lost at the top is the better side of the
trade: stretching 1000..2000 us over the full 0..65532 range would move the
centre, and the centre is where an ESC in Reverse brake mode takes zero thrust. A
slightly low maximum costs throttle curve; a misplaced centre costs creeping
thrust at neutral.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Spektrum ESC learns its endpoints from the signal present as it powers up:
full throttle, then low within five seconds of the tones that acknowledge it.
INAV could not produce that, because it outputs mincommand while disarmed, so the
documented procedure needs a Spektrum transmitter - which leaves an INAV user who
does not own one unable to calibrate the ESC at all.

esc_calibrate start runs the sequence unattended. The five second window opens at
tones only a person standing there can hear, so rather than ask the operator to
type against a stopwatch, the sequence is timed from the moment the ESC gains
power, which the flight controller can see: either the pack appearing on the
voltage sensor, or the ESC announcing itself on the bus.

    esc_calibrate start     battery disconnected; plug it in when told
    esc_calibrate off       abort
    esc_calibrate high/low  by hand, for boards with no voltage sensing

One of these phases commands full throttle with the aircraft disarmed, so:

- it refuses to start with the battery already connected. The ESC only reads its
  endpoints as it powers up, so starting with it live would achieve nothing, and
  it would mean presenting full throttle to an ESC able to act on it. Every other
  safeguard here is a latch; this one removes the hazard.
- it refuses while armed, in the command and again in the driver rather than
  trusting the caller, and arming cancels a sequence in progress.
- every phase leaves on a deadline, so nothing can strand the output high.
- the override is applied where the packet is built, not where values are staged,
  so no mixer path can write over it in between.
- the help leads with the propeller.

The automatic mode declines up front when there is no battery voltage sensing,
instead of starting a sequence that could never advance, and points at the manual
phases.

The three second settle after power-up is taken from the published tone timings
rather than measured, and is the first thing to adjust if an ESC refuses the
calibration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spektrum's reference application advances its SRXL2 state machine on a 5 ms tick,
and a master has to run several times faster than its own Control Data interval
for replies to be collected promptly on a half-duplex wire. TASK_PWMDRIVER, which
already exists to drive the SBUS servo output, runs at 200 Hz and matches that
exactly - worth writing down before the task is wired up, because the number is
not obvious from either end.

Also confirms the device ID chosen here: Spektrum's own example configuration
uses 0x31, flight controller type with unit ID 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e RSSI value

Both confirmed against Spektrum's own published code rather than reasoned about.

The reference implementation sets the reply ID to zero when it emits failsafe
channel data - a device being told the link is gone has nothing useful to answer
with - so the telemetry request is now suppressed while failsafe is announced.

The RSSI comment now carries its evidence. Spektrum's receiver code reads a
received zero as loss of link:

    if (channelData->rssi == 0) { globalResult = RX_FRAME_FAILSAFE; }

so sending 0, which this driver originally did on the grounds that we are not an
RF device, would have announced a dead link on every frame.

Also notes that the bus arbitrates mastership by device ID, lowest winning, and
why this driver does not implement standing down: the port is a dedicated link to
an ESC, and 0x40 cannot outrank 0x31. It would be wrong on a bus shared with a
Spektrum receiver, which would be master at 0x21.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The protocol has a failsafe channel-data command that a receiver sends when its
RF link is gone, so each device applies its own failsafe. Transcribing it here was
faithful to the protocol and wrong for this architecture.

On this bus the master is the flight controller and there is no RF link - the link
is a wire. INAV handles failsafe by substituting channel values and continuing to
fly: LAND and RTH command the motors all the way down, and DROP_IT holds throttle
at neutral. Telling the ESC the link had failed would hand throttle authority to
the ESC's own behaviour in the middle of INAV's landing, so the two would fight
over the motor during the one manoeuvre that most needs a single authority.

What protects against this wire actually dying is the ESC's own receive timeout,
which needs no cooperation from us: if the flight controller stops sending, the
ESC falls back on its own. That is the case where the ESC's failsafe is the right
authority, and it works without being told.

Removes srxl2MotorSetFailsafe() rather than leaving it unused, and records the
reasoning where the command constant used to be, since the protocol offering a
failsafe command invites putting it back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught by running the driver against Spektrum's own library acting as a Smart ESC.

The calibration sequence left its wait-for-battery phase when either the pack
appeared or escDeviceId was non-zero. The second test was meant to read as "the
ESC just announced itself, so it has just powered up", but escDeviceId is
persistent state held from the last time the ESC was seen, not an event. On any
board whose ESC had already completed a handshake - which is every board in normal
use - the wait was skipped immediately and the sequence ran through without the
operator having connected anything.

Both conditions are now events: the pack appearing, or the handshake count moving
from where it stood when the phase began.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lter

motor_pwm_protocol gains SRXL2, appended rather than inserted so existing stored
values keep their meaning. Selecting it points motorWritePtr at the SRXL2 driver,
the same hook DSHOT uses. If the port was never assigned the pointer is left null
and the motor stays unwritten: the pin is a UART pin, so there is no PWM to fall
back to and pretending otherwise would be worse than doing nothing.

TASK_PWMDRIVER, which already exists for the SBUS servo output, gets enabled for
this protocol too and runs the master. Its 200 Hz happens to be exactly the 5 ms
cadence Spektrum's reference application advances its state machine on.

Reverse follows the mixer rather than a mode of its own: when INAV has decided the
motor should run backwards, the ESC's reverse channel is armed. A separate switch
would let the two disagree about direction, which is the one thing that must not
happen with thrust reverse. Which auxiliary channel to use is esc_srxl2_reverse_channel,
because nothing on the wire advertises how the ESC was programmed.

Telemetry is taken in esc_sensor, which means every existing consumer is fed
without knowing where the numbers came from - including the RPM filter, which
reads getEscTelemetry() and therefore now works on an ESC that is not DSHOT. The
wire carries electrical rpm, so it is divided by motorPoleCount/2 exactly as
computeRpm() does for the serial backends; getting that wrong would place the
notch at the wrong frequency. There is no separate telemetry port to assign,
since Smart Throttle carries telemetry on the throttle wire.

Enabled by default only on H7 and AT32, where flash is plentiful. It costs about
3.5 KB, and AIKONF7 sits at 93.4% of its flash: measured, that target returns to
459091 bytes, byte-for-byte its size without this feature. A tighter target that
wants the protocol can define USE_MOTOR_SRXL2 for itself.

docs/Settings.md regenerated for the new setting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit took telemetry from the SRXL2 link whenever that was the motor
protocol, with no way to decline. Turning ESC telemetry off needs no setting for a
conventional ESC - you leave the port unassigned - but an SRXL2 ESC returns
telemetry on the throttle wire, so there is no port to leave unassigned and no
other way to say no, which also makes the two halves impossible to separate when
something looks wrong on a bench.

esc_srxl2_telemetry, default ON, is that switch. It sits in motorConfig beside
esc_srxl2_reverse_channel so the two SRXL2 settings live together, it is
conditional on USE_MOTOR_SRXL2, and it gates only the SRXL2 path. escSensorConfig
and the serial backend are left byte-identical to upstream: a conventional setup
gains no setting, sees no new behaviour, and has nothing new to misconfigure.

An earlier attempt at this offered AUTO/SERIAL/SRXL2/NONE. The explicit middle
values described combinations no real hardware wants - a Smart ESC has no separate
telemetry lead, and forcing SRXL2 under a different motor protocol does nothing -
so they were settings that could only be set wrong.

Also guards motorConfig's new fields behind USE_MOTOR_SRXL2. The settings generator
only emits the SETTING_..._DEFAULT macros where the condition holds, so an
unguarded initialiser broke every target without the feature. Caught by building
AIKONF7, which has it disabled: neither SITL nor the H7 target would have shown it,
since SITL excludes esc_sensor.c and the H7 build has the feature on.

Measured after: AIKONF7 at 459075 bytes, the same as without this work at all, and
TBS_LUCID_H7_WING at 36.15% of flash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The calibration already existed as a CLI command, but a wizard cannot use it: the
Configurator confines CLI to its own tab and no other tab sends CLI lines.

MSP2_INAV_ESC_SRXL2_CALIBRATE starts or aborts the sequence.
MSP2_INAV_ESC_SRXL2_STATUS reports the current phase and whether an ESC is
answering, so a wizard can follow what is happening rather than describing what it
hopes is happening.

The refusals are not re-implemented here. One of these phases commands full
throttle with the aircraft disarmed, and a second copy of the conditions is a
second thing to forget to update, so the command calls into the driver and honours
what it returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A page covering the wiring, which is the part most likely to be got wrong: the
signal wire goes to a UART's TX pin rather than a motor pad, and nothing connects
to the ESC telemetry pad, because Smart Throttle carries telemetry on the throttle
wire and a Smart ESC has no separate telemetry lead.

Also covers the calibration sequence, the reverse channel having to match how the
ESC was programmed since nothing on the wire advertises it, and why the pole count
matters more than usual: the wire carries electrical rpm, so a wrong pole count puts
the RPM filter's notch at the wrong frequency, which is worse than no notch.

On the single-ESC limit, the page says what is actually true rather than calling the
protocol single-motor. A bus can address several ESCs but each needs a unit ID that
the specification says cannot be set over SRXL2, so one per bus; this driver drives
one, on one port; a motor per port would be reasonable for a twin fixed-wing; and
what rules out multirotors is the update rate, tens of hertz by design against
DSHOT's kilohertz, not the wiring.

SITL now defines USE_MOTOR_SRXL2. It is not covered by the flash-size rule in
common.h, and opting in explicitly is both the documented mechanism for any target
and useful here: SITL exposes each UART on a TCP port, so a simulated ESC can be
attached to the real driver and the path from the Configurator through MSP to the
wire exercised without hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One ESC per bus, several buses. The driver held a single set of statics and
srxl2MotorUpdate() took a motor index it then ignored, so a twin with two Avians
folded both motors onto the first port and dropped the second silently. The per-bus
state is now an array, one instance per assigned port, each with its own handshake,
baud negotiation, receive framing and telemetry.

Motors map to ports in port order, which is the only mapping available: nothing on
an SRXL2 bus says which motor an ESC drives. Fewer ports than motors is refused at
init with PWM_INIT_ERROR_NOT_ENOUGH_MOTOR_OUTPUTS rather than absorbed, because a
motor with no port has no timer output to fall back on.

Reverse is armed on every ESC together: the mixer decides a direction for the
aircraft, and reversing one side of a twin and not the other is worth engineering
against. Calibration likewise runs on all of them, since they share a battery and
so power up together. esc_sensor fills escSensorData[i] per port.

Three defects fixed along the way:

  - motorProtocolProperties[] had no PWM_TYPE_SRXL2 entry, so asking for its
    properties read one past the end of the array. Added unconditionally, because
    the value is stored in configuration and a diff restored from a board that has
    the protocol would otherwise index out of bounds on one that does not.

  - the handshake was re-entered on every handshake frame received. Our finalise
    broadcasts, the slave answers the broadcast, and that answer restarted the
    sequence - so the two ping-ponged handshakes indefinitely. Control data is sent
    on a timer reset on entering RUNNING, so the bus never reached its first control
    frame: the link read as established and the motor never turned. On a slow port
    it was worse, each restart queueing another broadcast so the transmit buffer
    never drained. The negotiation is now entered only from the states that are
    still looking for an ESC.

  - handshakes were counted per frame, which the calibration uses as its "an ESC
    just gained power" event. Counted per negotiation now.

Verified against Spektrum's own SRXL2 library as the counterpart, compiled twice
under renamed symbols so the two simulated ESCs are genuinely independent devices
rather than one library in hub mode: 54 checks, 2175 frames accepted, 0 rejected by
their parser. The harness also models the transmit buffer draining at the wire's
rate instead of claiming it is always empty, which is what exposed the handshake
loop - the deferred baud switch depends on that answer being honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverse on a Smart ESC is a switch, not a throttle value. Spektrum say so in as
many words - "flipping the designated switch reverses motor rotation, throttle
will still control motor speed" - so the throttle goes on meaning throttle and the
reverse channel is a binary arm. That maps onto a mode, which is how every other
pilot-commanded action in INAV works, so there is now a THRUST REVERSE box. It is
offered only when the protocol is SRXL2 and a reverse channel is set, so it never
appears as a mode that silently does nothing.

Deriving reverse from the reversible-motor mixer state, as this did before, was the
wrong model for the aircraft the feature is for. It requires FEATURE_REVERSIBLE_MOTORS,
which recentres the throttle stick so mid-stick is zero thrust: forward thrust then
lives only in the top half of the stick, chopping the throttle on short final
commands reverse in the air, arming needs the stick centred rather than down, and an
ARM switch becomes mandatory. Reverse is also unreachable from every automatic
throttle path, since they all clamp to at least idle. Fine for a 3D model, wrong for
an aeroplane that wants reverse on the landing roll. The mixer state is still
honoured for the models it does suit; either route arms reverse and neither masks
the other.

Two defects fixed:

  - a reverse channel of 1 landed on the throttle's own slot, and
    srxl2MotorSetReverse() writes its channel at task rate, so it overwrote the
    mixer's staged throttle several hundred times a second: the motor held at idle
    whenever reverse was released and at full throttle whenever it was armed. The
    setting's range cannot express "zero, or five to nine", so the driver refuses
    the collision itself.

  - reverse was never released on disarm. mixTable() returns early while disarmed
    and so stops updating the direction, while the task kept publishing it, leaving
    an aircraft that landed under reverse sitting on the ground with the ESC's
    reverse channel armed until the throttle next went forward.

Default reverse channel moved from 5 to 7, which is what Spektrum ship: the Avian
160/200HV manual states "It's default setting is channel 7" and the 130 Pro table
marks CH7 likewise. Sending on 5 while the ESC watched 7 meant reverse never
engaged, silently, out of the box.

Documentation rewritten to match: the setting selects a slot on the SRXL2 wire and
not a transmitter channel, the switch is assigned in Modes, the channel must match
the ESC's own Thrust Rev. parameter, and reverse cannot assist an automatic landing.

Oracle: 57 checks, 0 failures, including a new one pinning the throttle-collision
refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
validateAndFixConfig() forced any protocol above BRUSHED to MULTISHOT on builds
without USE_DSHOT. SRXL2 was appended after DSHOT600, so it fell inside that range
and was silently rewritten on every boot: the setting could be saved, but the board
came back up on MULTISHOT and nothing SRXL2 was reachable - no port function acted
on, no THRUST REVERSE mode offered, no ESC block in the Outputs tab.

The check is about DSHOT, so it now names DSHOT rather than testing "above
BRUSHED". SRXL2 is a UART protocol and a build without DSHOT can drive it perfectly
well.

Found on SITL, which is built without DSHOT, once the whole path was exercised from
the Configurator against a SITL binary carrying this branch. Verified there over
MSP: the protocol survives a reboot and the firmware then reports THRUST REVERSE in
its mode list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The throttle-range calibration has preconditions - disarmed, an open SRXL2 port,
a voltage sensor, and the battery not yet connected - and MSP2_INAV_ESC_SRXL2_CALIBRATE
is an IN command, so a refusal reached the caller as a bare error with nowhere to
say which one failed. A wizard cannot act on that: it either has to guess or carry
on regardless, and carrying on means telling the operator to connect a battery to a
sequence that never started, then reporting it finished.

The driver now remembers its verdict and MSP2_INAV_ESC_SRXL2_STATUS reports it,
alongside the number of open ports. Both are appended, so a reader that expects the
old two bytes is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things a caller could not previously get right.

MSP2_INAV_ESC_SRXL2_STATUS now reports the mixer's motor count alongside the number
of open ports. These are exactly the two numbers pwmInitMotors() compares to decide
whether the board may arm, so a caller no longer has to infer either. The obvious
place to look, MSP2_INAV_MIXER, is a trap: its last two bytes are
MAX_SUPPORTED_MOTORS and MAX_SUPPORTED_SERVOS - the compile-time ceilings, not the
model - so reading a motor count from there reports 12 on any board.

SITL now opens its SRXL2 ports. It has no motor output layer at all - the simulator
reads the mixer's motor[] array directly, so pwmMotorPreconfigure() never runs and
nothing opened them. Since SITL maps every UART onto a TCP port, opening them is
what makes the target.h comment true: a simulated ESC can be attached to the real
driver, and the handshake, telemetry and calibration paths exercised, with no
hardware. Verified against a SITL carrying this branch: assign the function, set the
protocol, reboot, and the status reports one open port and accepts a calibration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
How often the ESC is asked for telemetry was a compile-time constant. The reply
shares the throttle wire with the control data, so the right answer depends on the
model: the RPM filter wants it fast, everything else is a display and reads fine at
a few hertz, and a busy bus wants it slow. esc_srxl2_telemetry_rate now offers
50, 25, 10, 5 and 2 Hz, derived from the 50 Hz control rate.

The table lists the default first so that it is index 0, which is not cosmetic.
The field is appended to motorConfig_t, but it landed inside the struct's existing
padding, so sizeof did not grow and pgLoad()'s memcpy copies the old zero over the
reset default - a configuration saved before this existed reads index 0 whatever
the default says. Verified on SITL: with a pre-existing eeprom the field read 0
while a fresh one read the default, which with the natural fast-to-slow ordering
would have silently moved every upgraded board to a telemetry request on every
single control frame. With the default at index 0 both now agree.

Worth remembering for the next field: appending to the end of a parameter group is
necessary but not sufficient. It is only additive if it actually grows the struct
past its padding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
escSensorGetData() returned NULL unless escSensorPort was open. That was the same
question as "is there ESC telemetry" for as long as the only source was a dedicated
telemetry lead, but a Spektrum Smart ESC reports over the throttle wire itself,
handled by the motor driver, so the port stays NULL while the data is perfectly
good.

Everything that reads through that function therefore saw nothing on such a board:
the OSD's ESC elements, the Blackbox ESC fields, current and voltage estimation,
and the Jeti and SBUS2 telemetry backends. The OSD was inconsistent with itself as
a result - it checks STATE(ESC_SENSOR_ENABLED), which was set, and then asks a
function that answered NULL.

It now asks the state flag, which is the question it meant. Nothing changes for a
conventional ESC: there the flag is only set once the port has opened.

The gyro RPM filter was unaffected throughout - it reads getEscTelemetry() directly
and never went through this path - which is why the split was easy to miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spektrum state the behaviour in words: "flipping the designated switch reverses
motor rotation, throttle will still control motor speed". So on a Smart ESC reverse
is a switch and the throttle goes on meaning throttle, and the THRUST REVERSE mode
is the whole of it.

Reversible motors models the other arrangement, where the stick centre is zero
thrust. It was kept as a second route in case an Avian turned out to work that way,
but supporting both means the wrong one is always one checkbox away - and enabling
it on a switch-type ESC hands it roughly half throttle at the point the pilot
expects the motor stopped, which is the worse of the two errors.

FEATURE_REVERSIBLE_MOTORS is therefore cleared when the protocol is SRXL2, next to
the line that already does the same for brushed, and the mixer-derived trigger is
gone from the task. That also removes getReversibleMotorsThrottleState(), which
this branch had added and nothing else needed.

The documentation now describes the bench check that tells the two kinds of ESC
apart, and says plainly that INAV drives only the switch kind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebasing onto maintenance-10.x turned up two collisions that were invisible
against the older base, both of which would have been silent.

Serial function 28 is already spoken for: the Configurator assigns it to the
MassZero thermal camera, whose firmware side is not on this branch yet. Two
functions sharing a mask bit means assigning either one enables both. Deferring to
the number already published is the safe direction; say so if the camera is going
somewhere else and this can move back.

The mode box had the same problem - BOXTHRUSTREVERSE at 60 with permanent id 69
collided with AUTO SPEED, TERRAIN AGL HOLD and IN FLIGHT MENU, which arrived on
this branch after the work started. It is now 63 with permanent id 72, matching the
Configurator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MrScothh and others added 6 commits September 14, 2026 11:42
motorConfig_t was nine bytes in use plus one of tail padding, and the new
fields went straight after it, so srxl2ReverseChannel landed on that tenth
byte. pgLoad() applies the reset defaults and then copies MIN(stored,
current) bytes over them, which for an existing ten-byte record reaches
offset nine - and the record holds zero there, because pgResetInstance()
copies the reset template whole and a template's padding is zero.

The result was the default of channel 7 being replaced by 0 - reverse
disabled - on every board that had been configured before this firmware,
while a freshly reset board worked. That is the wrong way round for a
defect to present: it looks like the feature does not work, and erasing
the configuration appears to fix it.

A one-byte member now absorbs that overlap so the three real fields start
past the end of the old record. Bumping the group version would also have
worked, at the price of discarding everyone's motor settings - protocol,
rates, pole count - to add an optional field.

Verified by layout: the old record is 10 bytes, the group is now 14, and
srxl2ReverseChannel moves from offset 9 to 10, past what pgLoad() copies.
SITL builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Reverse section treated Brake Type as a parenthetical that applied on
some models. Spektrum's programming instructions put it the other way
round: Brake Type = Reverse is what enables reversing, and Thrust Rev.
only chooses the channel that arms it - "Reverse must set in the Brake
Type menu". An ESC with the channel set and Brake Type left at Disabled
does nothing, and nothing says why, which is the failure this page exists
to prevent.

Also carries across their warning that the channel must be one nothing
else uses, because sharing it "can cause unexpected behavior in flight" -
the reason the setting already refuses the throttle slot - and their
recommendation of Brake Force 7 with reverse.

Documentation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both additions come from a first session against a real Avian 70 A Smart Lite
rather than from the specification.

The calibration was described as something INAV offers. It turns out to be
something an Avian needs: uncalibrated, that ESC ignored everything below
1238 us, so the bottom quarter of the throttle range did nothing and said
nothing. Calibrated, it answered from 1050 us and tracked the commanded
throttle to within a point from 10 % upwards. That is worth stating where
someone will read it before flying rather than after.

The current field is the open one. The ESC reported 2.85 A while the supply
feeding it measured 0.999 A at the same instant at about 40 % throttle, which
is what motor current rather than pack current looks like through a converter.
A scale error in the ESC's sensor would look the same at one operating point,
so the page says which reading to prefer meanwhile instead of picking a
conclusion the measurement does not yet support.

Documentation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Control Data carried a single channel, the throttle. An Avian parses such a
frame - it reports the throttle back over telemetry, correctly - and then
refuses to drive the motor for as long as they keep arriving. Once armed by a
wider frame it accepts single-channel ones happily, so the requirement is on
arming rather than on running: a link that comes up, reports plausible
telemetry, and never turns the propeller.

Measured on an Avian 70 A Smart Lite. Eight seconds of minimum throttle on one
channel leaves the ESC reporting 0.0 % and the bench supply delivering 58 mA,
its own electronics and nothing else. The same minimum on eight channels arms
it, and 1300 us then gives 30.0 % and 26400 eRPM at 840 mA. Two channels were
enough in every combination tried, so the eight here is margin rather than a
threshold, chosen to look like what a receiver sends, and widened further when
the reverse channel sits above it - that value has to travel in the frame
anyway.

The unused channels are filled at their minimum. The comment this replaces
argued against padding, and its reasoning was right about centre - 1500 us is
half throttle on an aircraft ESC, so a wrong guess about which index carries
throttle would spin the motor - but wrong in concluding that sending nothing
was therefore safer. Sending nothing means never arming. Minimum padding keeps
the safety property and drops the one that was costing everything.

The mask is now built rather than accumulated, so the frame does not change
shape depending on what has been called since power-up.

Two things came out of widening it.

srxl2SendFrame() now checks there is room and reports whether the frame went
out. A port that has backed up drops what does not fit, which for Control Data
is of no consequence - another follows in 20 ms and the ESC tolerates 250 ms of
silence - but for the broadcast that moves the bus to a new rate it is the
difference between a working link and a dead one. srxl2Finalise() only arms the
switch if that broadcast was actually queued. This failure mode is not
hypothetical: forcing 400000 on an ESC that had not agreed to it left it
unreachable on the bench until the battery came out.

The oracle harness needed its starved-wire scenario relaxed from three bytes
per tick to eight. At thirty-byte frames three bytes a tick is not a slow link
but an impossible one - a real 115200 link has eight times the headroom the
frame rate needs - and the scenario exists to hold the transmit buffer busy
across ticks, which it still does. Adjusting a test to accommodate a change
deserves suspicion, so: the two checks that scenario was written for, that the
buffer really was busy and that the rate was never raised with bytes unsent,
both still pass.

57 checks, 0 failures, 2371 frames accepted by Spektrum's parser. SITL builds
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A bench session against an Avian 70 A, with the ESC's own telemetry as the
instrument, corrected four things this driver believed.

Telemetry rate. The setting was named for how often INAV asks. The ESC answers
about two requests in three and rotates its reply between a text page, a battery
page and the ESC page, so what arrives is roughly a ninth of what is asked for:
asking 25 times a second yields 2.7 ESC frames a second, 10 yields 1.1, 5 yields
0.5, 2 yields 0.2. The values are now named for what arrives, and 50 Hz is gone -
asked on every frame, the ESC keeps the link, keeps answering, and stops obeying
the throttle, reporting 0.0 % from every stick position. Every second frame
restores it immediately, without a power cycle. A floor in the driver means a
configuration saved by an older build cannot select it either.

Channel count. The frame carried a block of eight because one channel appeared
not to arm. That reading was confounded: the two runs also asked for telemetry at
different rates. With the rate held fixed, masks of one, two adjacent, two spread,
four and eight channels all gave the same 14.0 % and the same 6610 rpm from the
same command; asking on every frame, all five gave nothing. So the frame now
carries the throttle and the reverse channel, and nothing else - twelve bytes a
frame less on a wire the telemetry reply has to share.

Calibration timings. Three seconds high and five low, taken from the published
tone timings, sound the tones and store nothing. Four and seven store it. The
difference is not subtle: uncalibrated the ESC ignores everything below channel
value 12220 and is saturated from 50820, so 41 % of the range does nothing and
full power arrives at three quarters stick; calibrated it responds from 2687,
saturates at 64307, and reports back what was commanded to within a point across
the whole range.

Finding an ESC. The polling this driver does finds nothing, and the comment now
says so. An Avian announces itself six times in the 300 ms after it powers up and
is mute from then on: 128 handshakes to its address, 128 broadcasts, 128 spread
across the whole ID range and 319 control frames asking for telemetry all drew
exactly nothing from a running one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An Avian announces itself for a third of a second after it gains power and is
silent afterwards. Miss that window - a board rebooting under a battery that
stayed connected, or a bench supply switched on before the flight controller -
and the link never forms. Arming then commands a motor that is not listening:
the model arms, the telemetry looks sane, and the propeller does not turn.

So arming waits for the link rather than for a timer. Where ESC and board come up
on the same battery this costs about a second at power-up and is never noticed;
where it does not clear, the throttle would have done nothing anyway, and finding
that out while disarmed is the point.

Verified both ways in SITL against the real ESC: with the wire idle, arming is
refused and the OSD reports the hardware; with the ESC linked, the same
configuration arms and drives it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MrScothh
MrScothh marked this pull request as ready for review September 15, 2026 23:15
@qodo-code-review

Copy link
Copy Markdown
Contributor

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add SRXL2 motor output for Spektrum Smart ESCs

✨ Enhancement 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Adds half-duplex SRXL2 motor control and telemetry for Spektrum Smart ESCs.
• Supports thrust reverse, endpoint calibration, multi-ESC ports, and link-aware arming safeguards.
• Exposes configuration, MSP/CLI controls, SITL support, and comprehensive operating documentation.
Diagram

graph TD
  CFG["Settings and Ports"] --> MIX["Motor Output"] --> DRIVER["SRXL2 Master"] --> UART["Half-Duplex UART"] --> ESC["Smart ESC"]
  ESC -->|Telemetry| DRIVER --> SENSOR["ESC Telemetry"] --> CONSUMERS["OSD and Filters"]
  UI["CLI and MSP"] -->|Calibration| DRIVER
Loading
High-Level Assessment

The current approach is appropriate. A dedicated INAV bus master is necessary because Spektrum's published library implements only the device side, and independent UARTs avoid relying on unavailable ESC unit-ID configuration. Reusing the existing motor-output, ESC telemetry, task, mode, CLI, and MSP abstractions minimizes downstream changes while preserving explicit safety checks.

Files changed (25) +2110 / -5

Enhancement (13) +1653 / -2
pwm_mapping.cIntegrate SRXL2 with motor resource validation +20/-0

Integrate SRXL2 with motor resource validation

• Registers SRXL2 as a non-timer motor protocol and rejects configurations with fewer opened SRXL2 ports than mixer motors. This prevents partially driven multi-motor aircraft from arming.

src/main/drivers/pwm_mapping.c

pwm_mapping.hDefine the SRXL2 motor protocol identifier +4/-0

Define the SRXL2 motor protocol identifier

• Appends the persistent SRXL2 protocol value without renumbering existing stored motor protocol identifiers.

src/main/drivers/pwm_mapping.h

pwm_output.cRoute motor writes through the SRXL2 driver +15/-0

Route motor writes through the SRXL2 driver

• Initializes assigned SRXL2 ports, applies reverse and telemetry settings, and installs the SRXL2 staging function as the motor writer. It deliberately provides no PWM fallback.

src/main/drivers/pwm_output.c

cli.cAdd SRXL2 throttle calibration CLI +86/-0

Add SRXL2 throttle calibration CLI

• Adds automatic and manual 'esc_calibrate' commands with phase reporting, safety refusals, timeout guidance, and abort support.

src/main/fc/cli.c

fc_init.cApply SRXL2 startup constraints and SITL initialization +29/-0

Apply SRXL2 startup constraints and SITL initialization

• Disables incompatible center-zero reversible-motor behavior for SRXL2. It also opens SRXL2 ports directly under SITL, where the hardware motor-output layer is absent.

src/main/fc/fc_init.c

fc_msp.cExpose SRXL2 status and calibration over MSP +45/-0

Expose SRXL2 status and calibration over MSP

• Adds MSP handlers for calibration commands and reports phase, connectivity, refusal reason, opened ports, and required motor count.

src/main/fc/fc_msp.c

fc_msp_box.cRegister the THRUST REVERSE flight mode +25/-0

Register the THRUST REVERSE flight mode

• Adds a permanent THRUST REVERSE mode and exposes it only when SRXL2 is selected with a configured reverse channel.

src/main/fc/fc_msp_box.c

fc_tasks.cProcess SRXL2 buses from the PWM driver task +28/-1

Process SRXL2 buses from the PWM driver task

• Runs the SRXL2 state machines at 200 Hz and maps the armed THRUST REVERSE mode onto the ESC reverse channel. The existing PWM driver task is enabled whenever SRXL2 is active.

src/main/fc/fc_tasks.c

rc_modes.hAllocate the THRUST REVERSE mode ID +1/-0

Allocate the THRUST REVERSE mode ID

• Adds the non-conflicting internal mode identifier for SRXL2 thrust reverse.

src/main/fc/rc_modes.h

motor_srxl2.cImplement the SRXL2 Smart ESC bus master +1106/-0

Implement the SRXL2 Smart ESC bus master

• Implements independent per-UART handshakes, safe baud negotiation, framed throttle and reverse output, telemetry scheduling and decoding, stale-link recovery, and connectivity reporting. It also provides guarded automatic and manual endpoint calibration with measured timings and timeout protection.

src/main/io/motor_srxl2.c

motor_srxl2.hDefine the SRXL2 motor driver API +221/-0

Define the SRXL2 motor driver API

• Declares driver lifecycle, throttle staging, reverse control, telemetry access, connectivity, calibration phases and results, supported rates, and the four-motor limit.

src/main/io/motor_srxl2.h

msp_protocol_v2_inav.hAllocate SRXL2 MSP command identifiers +3/-0

Allocate SRXL2 MSP command identifiers

• Defines MSPv2 command IDs for SRXL2 status retrieval and calibration control.

src/main/msp/msp_protocol_v2_inav.h

esc_sensor.cFeed SRXL2 telemetry into existing ESC consumers +70/-1

Feed SRXL2 telemetry into existing ESC consumers

• Adapts SRXL2 voltage, current, temperature, and electrical RPM into the common per-motor ESC sensor data path. This makes the data available to OSD, Blackbox, current estimation, telemetry backends, and RPM filtering without a separate telemetry port.

src/main/sensors/esc_sensor.c

Bug fix (1) +6 / -1
config.cPreserve SRXL2 on builds without DSHOT +6/-1

Preserve SRXL2 on builds without DSHOT

• Narrows DSHOT validation to explicit DSHOT protocol values so non-DSHOT builds do not rewrite SRXL2 configurations to MULTISHOT.

src/main/fc/config.c

Documentation (3) +339 / -0
ESC and servo outputs.mdClarify UART-based Smart ESC output mapping +6/-0

Clarify UART-based Smart ESC output mapping

• Explains that SRXL2 Smart ESCs connect through UARTs rather than timer-backed motor outputs and links to the dedicated guide.

docs/ESC and servo outputs.md

Settings.mdDocument SRXL2 ESC settings +30/-0

Document SRXL2 ESC settings

• Documents the reverse channel, telemetry enablement, and measured telemetry-rate options, including the shared-wire bandwidth constraint.

docs/Settings.md

Spektrum Smart ESC.mdAdd comprehensive Spektrum Smart ESC guide +303/-0

Add comprehensive Spektrum Smart ESC guide

• Adds wiring, setup, multi-motor mapping, calibration, telemetry, RPM filtering, thrust reverse, reboot recovery, and safety guidance. It also records measured Avian behavior and known telemetry limitations.

docs/Spektrum Smart ESC.md

Other (8) +112 / -2
CMakeLists.txtBuild the SRXL2 motor driver +2/-0

Build the SRXL2 motor driver

• Adds the SRXL2 motor source and header to the common firmware source list.

src/main/CMakeLists.txt

fc_core.cBlock arming when SRXL2 links are missing +21/-1

Block arming when SRXL2 links are missing

• Treats any unconnected configured SRXL2 ESC as a hardware failure, preventing arming when motor commands would not reach every ESC.

src/main/fc/fc_core.c

settings.yamlConfigure SRXL2 protocol and ESC options +27/-1

Configure SRXL2 protocol and ESC options

• Adds SRXL2 to motor protocol settings and defines telemetry, delivered telemetry rate, and reverse-channel options. The rate table preserves a safe default for older stored configurations.

src/main/fc/settings.yaml

mixer.cInitialize SRXL2 motor configuration defaults +5/-0

Initialize SRXL2 motor configuration defaults

• Sets defaults for the reverse channel, telemetry enablement, and telemetry delivery rate when SRXL2 support is compiled.

src/main/flight/mixer.c

mixer.hPersist SRXL2 motor settings safely +26/-0

Persist SRXL2 motor settings safely

• Extends motor configuration with SRXL2 reverse and telemetry fields. A compatibility padding byte prevents older parameter-group data from overwriting new defaults without discarding existing motor settings.

src/main/flight/mixer.h

serial.hReserve the SRXL2 ESC serial function +3/-0

Reserve the SRXL2 ESC serial function

• Assigns serial function bit 29 to Spektrum Smart ESCs while leaving bit 28 available for the Configurator's MassZero camera assignment.

src/main/io/serial.h

target.hEnable SRXL2 support in SITL +9/-0

Enable SRXL2 support in SITL

• Opts SITL into the SRXL2 driver so simulated ESCs can connect through TCP-backed UARTs for integration testing.

src/main/target/SITL/target.h

common.hEnable SRXL2 by default on larger targets +19/-0

Enable SRXL2 by default on larger targets

• Enables SRXL2 automatically on STM32H7 and AT32F43x targets while allowing flash-constrained targets to opt in explicitly.

src/main/target/common.h

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 15, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Slow telemetry drops the ESC link ✓ Resolved 🐞 Bug ≡ Correctness
Description
srxl2MotorSetTelemetryRate() maps the 0.2 Hz setting to one request every 25 control frames,
exactly 500 ms, while SRXL2_RUNNING declares the link dead after 500 ms without receiving a frame.
Before the first reply to that request can be drained, the same processing pass meets the >= 500
timeout, returns the port to low baud, and leaves the powered ESC behind at the negotiated baud.
Code

src/main/io/motor_srxl2.c[R816-823]

+    static const uint8_t divisor[] = { 5, 2, 3, 10, 25 };
+
+    uint8_t every = (rate < ARRAYLEN(divisor)) ? divisor[rate] : SRXL2_TELEM_REQUEST_DEFAULT;
+
+    /* Clamped here as well as in the table, so that no future entry - or a
+     * configuration written by an older build, where index 1 meant every
+     * frame - can ask at a rate the ESC answers but will not fly at. */
+    telemRequestEvery = (every < SRXL2_TELEM_REQUEST_MIN) ? SRXL2_TELEM_REQUEST_MIN : every;
Evidence
Control frames run every 20 ms, making divisor 25 exactly 500 ms. The running state sends the
request and then immediately evaluates a 500 ms receive timeout, so no response to the newly sent
frame can have arrived yet.

src/main/io/motor_srxl2.c[154-185]
src/main/io/motor_srxl2.c[811-824]
src/main/io/motor_srxl2.c[1011-1029]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The 0.2 Hz telemetry mode schedules its next possible reply no earlier than the link timeout, so the driver disconnects before receiving it.
## Fix Focus Areas
- src/main/io/motor_srxl2.c[184-188]
- src/main/io/motor_srxl2.c[811-824]
- src/main/io/motor_srxl2.c[1011-1029]
## Recommended Fix
Base the link timeout on the selected request interval with allowance for multiple unanswered requests, or use a separate sufficiently frequent link-health exchange independent of the telemetry delivery rate.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Powered ESCs can receive full throttle ✓ Resolved 🐞 Bug ☼ Reliability
Description
srxl2MotorCalibrationManual() checks only armed state and port presence before accepting the
manual-high phase, omitting the battery-presence guard used by unattended calibration. Running
esc_calibrate high or sending the corresponding MSP command while a sensed battery is connected
makes the next control frame command 2000 microseconds to an ESC that can already drive the motor.
Code

src/main/io/motor_srxl2.c[R867-875]

+srxl2CalResult_e srxl2MotorCalibrationManual(srxl2CalPhase_e phase)
+{
+    const srxl2CalResult_e common = srxl2CalCommonChecks();
+    if (common != SRXL2_CAL_ACCEPTED) {
+        return (calLastResult = common);
+    }
+
+    calPhase = phase;
+    calPhaseMs = millis();
Evidence
The common manual checks omit battery state, and the accepted phase is directly interpreted as the
2000 µs calibration endpoint. The automatic path already demonstrates the required safety check by
rejecting the same powered condition.

src/main/io/motor_srxl2.c[638-648]
src/main/io/motor_srxl2.c[826-859]
src/main/io/motor_srxl2.c[867-876]
src/main/fc/fc_msp.c[3831-3834]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Manual-high calibration can command full throttle while a battery-sensed ESC is already powered.
## Fix Focus Areas
- src/main/io/motor_srxl2.c[826-876]
- src/main/fc/cli.c[4807-4812]
- src/main/fc/fc_msp.c[3831-3834]
## Recommended Fix
For `SRXL2_CAL_HIGH_MANUAL`, reject the request with `SRXL2_CAL_REJECT_BATTERY_PRESENT` whenever battery sensing is configured and the battery state is not `BATTERY_NOT_PRESENT`; retain the manual fallback only where voltage cannot be sensed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Unsupported boards can leave every motor unwritten ✓ Resolved 🐞 Bug ≡ Correctness
Description
validateAndFixConfig() preserves PWM_TYPE_SRXL2 when USE_MOTOR_SRXL2 is absent, although the
branch assigning motorWritePtr and the SRXL2 link-health arming check are compiled out while the
protocol remains selectable and classified as non-timer-based. Selecting or restoring SRXL2 on such
firmware retains the null motor writer, bypasses timer initialization and the insufficient-output
error, and can still permit arming without functional motor output.
Code

src/main/fc/config.c[R269-275]

+    // Named explicitly rather than tested as "above BRUSHED". This is a DSHOT
+    // check, and the enum has since grown a UART protocol above DSHOT600 that a
+    // build without DSHOT can still drive perfectly well - a range test would
+    // quietly rewrite it to MULTISHOT on every boot.
+    if (motorConfig()->motorPwmProtocol >= PWM_TYPE_DSHOT150 &&
+        motorConfig()->motorPwmProtocol <= PWM_TYPE_DSHOT600) {
motorConfigMutable()->motorPwmProtocol = PWM_TYPE_MULTISHOT;
Evidence
The configuration validation retains the appended SRXL2 value without checking whether the SRXL2
feature is compiled, and the settings table exposes the protocol unconditionally. Meanwhile, both
the only SRXL2 motor-writer assignment and its link-health arming check are guarded by
USE_MOTOR_SRXL2; because the protocol properties always mark SRXL2 as not using hardware timers,
unsupported builds skip timer allocation and return successfully with the null writer rather than
reporting a PWM initialization or port-count failure.

src/main/fc/config.c[267-277]
src/main/drivers/pwm_output.c[599-634]
src/main/drivers/pwm_mapping.c[66-94]
src/main/drivers/pwm_mapping.c[435-455]
src/main/fc/settings.yaml[29-37]
src/main/drivers/pwm_output.c[599-633]
src/main/drivers/pwm_mapping.c[413-455]
src/main/fc/fc_core.c[307-324]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Firmware built without `USE_MOTOR_SRXL2` can retain and select `PWM_TYPE_SRXL2` even though no compiled driver case assigns a motor writer and the SRXL2 link-health arming check is absent. Since SRXL2 is classified as a non-timer output, timer allocation is also bypassed, leaving motor writes as no-ops without a visible PWM initialization or insufficient-output error.
## Fix Focus Areas
- src/main/fc/config.c[267-277]
- src/main/drivers/pwm_output.c[599-634]
- src/main/drivers/pwm_mapping.c[66-94]
- src/main/drivers/pwm_mapping.c[438-455]
## Recommended Fix
Add explicit validation for builds without `USE_MOTOR_SRXL2` so `PWM_TYPE_SRXL2` is rejected or replaced with a supported protocol such as `PWM_TYPE_MULTISHOT`, with a visible configuration or output error where rejection is used. Keep the existing DSHOT-specific validation separate, prevent unavailable firmware from advertising SRXL2 as selectable, and ensure an unsupported protocol cannot reach the non-timer initialization path.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (1)
4. Some Smart ESCs can never establish a link ✓ Resolved 🐞 Bug ≡ Correctness
Description
srxl2ProcessEsc() always sends discovery handshakes only to SRXL2_ESC_ID_FIRST (0x40) despite
accepting device IDs through 0x4F. An ESC configured with a nonzero unit ID does not announce
itself and is never addressed by this poll, so its port remains in polling and arming stays blocked.
Code

src/main/io/motor_srxl2.c[997]

+            srxl2SendHandshake(e, SRXL2_ESC_ID_FIRST, SRXL2_BAUD_BIT_400K);
Evidence
The driver defines and accepts the full 0x40–0x4F ESC range, and its own polling comments state that
nonzero unit IDs require polling. The actual transmitted poll is nevertheless hard-coded to the
first ID only.

src/main/io/motor_srxl2.c[74-77]
src/main/io/motor_srxl2.c[498-506]
src/main/io/motor_srxl2.c[980-998]
src/main/fc/fc_core.c[307-325]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
Issue description
SRXL2 discovery polls only device ID `0x40`, although the driver recognizes ESC IDs `0x40` through `0x4F` and documents that nonzero-unit ESCs require polling because they do not announce. Such an ESC therefore cannot complete the handshake.
Fix Focus Areas
- src/main/io/motor_srxl2.c[74-77]
- src/main/io/motor_srxl2.c[980-998]
Recommended Fix
Track a discovery device ID per ESC instance and rotate through `0x40` to `0x4F` while polling, resetting the scan when a valid handshake is received. Preserve the one-ESC-per-port behavior and only finalize negotiation with the ID that replied.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Slow telemetry vanishes between samples ✓ Resolved 🐞 Bug ≡ Correctness
Description
srxl2MotorGetTelemetry() rejects every reading after a fixed one-second age, and
escSensorUpdate() then increments its age on every busy-loop callback rather than on expected
telemetry opportunities. At the offered 0.5 Hz rate, readings disappear for roughly half of each
interval, while consumers using voltage, current, temperature, and combined ESC data repeatedly see
no valid sensor.
Code

src/main/io/motor_srxl2.c[R187-188]

+/* Telemetry older than this is reported as stale rather than current. */
+#define SRXL2_TELEM_STALE_MS        1000
Evidence
The driver offers 0.5 Hz and 0.2 Hz delivery but makes every sample invalid after one second. The
ESC sensor adapter is called from a documented busy-loop callback, causing its failure branch to
advance to invalid almost immediately once that timestamp expires.

src/main/io/motor_srxl2.c[811-824]
src/main/io/motor_srxl2.c[1094-1103]
src/main/sensors/esc_sensor.c[282-303]
src/main/fc/fc_core.c[1092-1108]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The fixed one-second freshness window is shorter than supported telemetry intervals and invalidation is advanced at busy-loop frequency.
## Fix Focus Areas
- src/main/io/motor_srxl2.c[187-188]
- src/main/io/motor_srxl2.c[1094-1103]
- src/main/sensors/esc_sensor.c[273-303]
## Recommended Fix
Derive freshness from the configured delivery interval with jitter and missed-response allowance, and update `dataAge` only when an expected sample opportunity is missed rather than on every realtime callback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Missing telemetry becomes a zero reading ✓ Resolved 🐞 Bug ≡ Correctness
Description
srxl2DecodeEscTelemetry() clears every field and skips sentinel-valued measurements, but then sets
one overall valid flag without recording which individual values were absent. When an ESC omits
current, voltage, temperature, or RPM while reporting other fields, downstream consumers receive a
fresh zero rather than an unavailable measurement.
Code

src/main/io/motor_srxl2.c[R490-491]

+    t->lastUpdateMs = millis();
+    t->valid = true;
Evidence
The decoder recognizes per-field sentinel values but merely leaves those fields at the zero
established by memset, after which it marks the whole structure valid. The adapter copies those
zeroes into standard ESC telemetry without any per-field validity check.

src/main/io/motor_srxl2.c[462-492]
src/main/io/motor_srxl2.h[52-69]
src/main/sensors/esc_sensor.c[282-299]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Per-field no-data sentinels are converted into valid zero readings because telemetry has only one aggregate validity flag.
## Fix Focus Areas
- src/main/io/motor_srxl2.c[462-492]
- src/main/io/motor_srxl2.h[52-69]
- src/main/sensors/esc_sensor.c[282-299]
## Recommended Fix
Add validity flags for each exported measurement and propagate them into consumers, or reject/retain fields explicitly so sentinel values are never presented as fresh numeric zeroes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Reverse mode always reports inactive ✓ Resolved 🐞 Bug ≡ Correctness
Description
initActiveBoxIds() advertises BOXTHRUSTREVERSE, but packBoxModeFlags() never copies
IS_RC_MODE_ACTIVE(BOXTHRUSTREVERSE) into the outgoing active-box bitmask with CHECK_ACTIVE_BOX.
When the reverse switch is active, the motor driver can consume its runtime state internally while
Configurator and other MSP clients still receive a clear bit and display the mode as inactive.
Code

src/main/fc/fc_msp_box.c[R417-419]

+    if (motorConfig()->motorPwmProtocol == PWM_TYPE_SRXL2 && motorConfig()->srxl2ReverseChannel != 0) {
+        ADD_ACTIVE_BOX(BOXTHRUSTREVERSE);
+    }
Evidence
The active-box list includes thrust reverse and determines its position in the MSP bitmask, while
the motor driver reads the mode's runtime state. However, the complete serialization population list
has no corresponding entry, and serialization emits only bits whose activeBoxes entries were
populated, so the advertised thrust-reverse bit remains zero regardless of the switch's actual
state.

src/main/fc/fc_msp_box.c[399-419]
src/main/fc/fc_msp_box.c[426-525]
src/main/fc/fc_tasks.c[335-340]
src/main/fc/fc_msp_box.c[400-419]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new Thrust Reverse mode is advertised in the active-box list and consumed internally, but omitted from MSP active-mode status serialization, preventing MSP clients from displaying its active state.
## Fix Focus Areas
- src/main/fc/fc_msp_box.c[399-419]
- src/main/fc/fc_msp_box.c[426-525]
- src/main/fc/fc_tasks.c[335-340]
## Recommended Fix
Add `CHECK_ACTIVE_BOX(IS_RC_MODE_ACTIVE(BOXTHRUSTREVERSE), BOXTHRUSTREVERSE)` to `packBoxModeFlags()` alongside the other mode-state serialization entries. Apply the same feature conditions used when advertising the box so the serialized flag corresponds to its active-box-list position.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (3)
8. Invalid reverse settings fail silently ✓ Resolved 🐞 Bug ≡ Correctness
Description
The setting range accepts channels 1 through 4 and initActiveBoxIds() advertises reverse for every
nonzero value, although the documented ESC range is 5 through 9 and channel 1 aliases throttle. A
user can therefore configure and activate a visible reverse mode that either gets disabled
internally or sends a channel the ESC does not support, with no validation error.
Code

src/main/fc/settings.yaml[R884-890]

+      - name: esc_srxl2_reverse_channel
+        description: "For an SRXL2 Smart ESC, the 1-based auxiliary channel its \"Thrust Rev.\" setting selects to arm reverse. Spektrum allow channels 5 to 9 and ship channel 7 by default. Must match how the ESC was programmed, because nothing on the wire advertises it. 0 disables reverse."
+        default_value: 7
+        field: srxl2ReverseChannel
+        condition: USE_MOTOR_SRXL2
+        min: 0
+        max: 9
Evidence
The YAML constraint is only 0–9 even though its own description states 5–9. Mode registration tests
only nonzero, while the driver silently converts channel 1 to disabled and does not enforce the
ESC's 5–9 restriction for channels 2–4.

src/main/fc/settings.yaml[884-890]
src/main/fc/fc_msp_box.c[414-419]
src/main/io/motor_srxl2.c[761-808]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Configuration accepts unsupported reverse channels and still advertises an operational reverse mode.
## Fix Focus Areas
- src/main/fc/settings.yaml[884-890]
- src/main/fc/fc_msp_box.c[414-419]
- src/main/io/motor_srxl2.c[761-808]
## Recommended Fix
Validate the value as exactly zero or 5 through 9 during configuration loading, reject invalid CLI/MSP writes, and advertise the mode only when the normalized channel is usable.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Some builds lose the calibration CLI ✓ Resolved 🐞 Bug ≡ Correctness
Description
Both cliEscCalibrate() and its command-table entry are nested under USE_USB_MSC in addition to
USE_MOTOR_SRXL2, despite calibration having no mass-storage dependency. SRXL2 builds without the
optional MSC feature—including the explicitly enabled SITL configuration and applicable H7 and AT32
targets—omit the documented esc_calibrate command while retaining the driver and MSP calibration
paths.
Code

src/main/fc/cli.c[R4745-4747]

#ifdef USE_USB_MSC
+#ifdef USE_MOTOR_SRXL2
+static void cliEscCalibratePrintResult(srxl2CalResult_e r)
Evidence
The implementation and command registration are both visibly enclosed by the pre-existing
USE_USB_MSC guard, while build logic enables USB mass storage only for targets carrying the
optional MSC feature. SITL explicitly enables SRXL2 without defining USB mass-storage support, and
the calibration implementation resides entirely in the SRXL2 driver with its own safety checks; MSP
likewise exposes calibration under only USE_MOTOR_SRXL2, demonstrating that the USB dependency is
unrelated.

src/main/fc/cli.c[4745-4828]
src/main/fc/cli.c[5107-5114]
src/main/target/SITL/target.h[57-65]
src/main/fc/fc_msp.c[3815-3839]
src/main/fc/cli.c[4745-4827]
src/main/fc/cli.c[5107-5115]
cmake/stm32.cmake[327-338]
src/main/io/motor_srxl2.c[826-876]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The SRXL2 calibration CLI implementation and command registration are accidentally compiled only when unrelated USB mass-storage support is enabled. SRXL2 targets without the optional MSC build feature therefore cannot use the documented CLI calibration flow even though the driver API requires only SRXL2 support.
## Fix Focus Areas
- src/main/fc/cli.c[4745-4828]
- src/main/fc/cli.c[5107-5115]
- src/main/target/SITL/target.h[57-65]
- cmake/stm32.cmake[327-338]
- src/main/io/motor_srxl2.c[826-876]
## Recommended Fix
Move the SRXL2 calibration helper and command registration outside the `USE_USB_MSC` blocks while retaining their `USE_MOTOR_SRXL2` guards. Leave only the existing `msc` command under `USE_USB_MSC`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Malformed traffic can corrupt link negotiation ✓ Resolved 🐞 Bug ☼ Reliability
Description
srxl2DrainRx() accepts every CRC-valid frame from five bytes onward, and srxl2HandleFrame()
dispatches a Handshake without requiring sizeof(Srxl2HandshakeFrame). A short frame marked as a
handshake then makes srxl2HandleHandshake() consume payload fields that were not received,
allowing stale receive-buffer bytes to determine the discovered device and baud negotiation.
Code

src/main/io/motor_srxl2.c[R604-614]

+        if (e->rxLen == 3) {
+            e->rxExpected = e->rxBuf[2];
+            if (e->rxExpected < SRXL2_MIN_FRAME || e->rxExpected > SRXL2_MAX_FRAME) {
+                e->rxLen = 0;           /* bogus length, drop and resynchronise */
+                continue;
+            }
+        }
+
+        if (e->rxExpected && e->rxLen >= e->rxExpected) {
+            srxl2HandleFrame(e, e->rxBuf, e->rxLen);
+            e->rxLen = 0;
Evidence
The parser's minimum accepted frame length is five bytes and dispatch validates only the CRC and
packet type. The packed handshake structure is 13 bytes, but its handler reads handshake payload
values after no length validation.

src/main/io/motor_srxl2.c[590-618]
src/main/io/motor_srxl2.c[498-553]
src/main/io/motor_srxl2.c[567-588]
src/main/rx/srxl2_types.h[81-86]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
Issue description
The SRXL2 receive parser accepts a CRC-valid five-byte frame and dispatches it as a handshake without checking the handshake structure length. The handshake handler then reads fields outside the logical received frame, so stale buffer contents can affect link setup.
Fix Focus Areas
- src/main/io/motor_srxl2.c[498-553]
- src/main/io/motor_srxl2.c[567-588]
- src/main/io/motor_srxl2.c[590-618]
- src/main/rx/srxl2_types.h[81-86]
Recommended Fix
Before calling `srxl2HandleHandshake()`, require `len >= sizeof(Srxl2HandshakeFrame)` (or require the protocol-defined exact handshake length). Reject malformed handshake frames without updating state, device ID, handshake counters, or baud capabilities.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can group findings by type and pick your Finding display, from Minimal to Full

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/main/io/motor_srxl2.c Outdated
Comment thread src/main/io/motor_srxl2.c
Comment thread src/main/io/motor_srxl2.c Outdated
Comment thread src/main/io/motor_srxl2.c
Comment thread src/main/fc/fc_msp_box.c
Comment thread src/main/fc/settings.yaml
Comment thread src/main/fc/cli.c Outdated
Comment thread src/main/fc/config.c
Comment thread src/main/io/motor_srxl2.c Outdated
Comment thread src/main/io/motor_srxl2.c
An automated review pass raised ten issues on this PR. Each was checked against
the code before being acted on, and each turned out to be real.

**The link timeout was coupled to the telemetry rate, and one setting guaranteed
a disconnection.** Nothing but a telemetry reply refreshes the receive timer,
because a running Avian never speaks unprompted - so the request rate *is* the
link-liveness rate. The slowest setting asked once every 500 ms against a 500 ms
timeout. Worse, the ESC answers only about two requests in three, and a dropped
link is unrecoverable on this hardware: the driver returns to polling, where a
running ESC answers nothing at all. The two slow settings are gone rather than
papered over with an elastic timeout, which would have hidden the very failure
the timeout exists to catch. What remains is one request every five frames at
worst - a reply typically every 150 ms, three consecutive misses still inside the
window - and the timeout constant now carries that reasoning.

**Telemetry aged out faster than it arrived.** The stale window was one second
while the ESC's own readings arrive about once a second at the default rate, so a
healthy sensor flickered. Now three seconds; what notices a stopped ESC is the
link timeout, which invalidates the reading anyway.

**Manual calibration could command full throttle into a live ESC.** The
unattended sequence refuses to start with a battery present; `esc_calibrate high`
did not, which left the guard off the path a person drives by hand. Boards with
no voltage sensing report the pack absent and are unaffected - which is the case
that path exists for.

**A short handshake was parsed.** Any CRC-valid frame five bytes or longer
labelled Handshake had its device ID and baud read from whatever the buffer held
last. Length is checked before the payload is touched.

**Discovery polled one address.** The comment claimed polling was what finds an
ESC with a non-zero unit ID; it only ever addressed 0x40, the one ID that
announces itself. It now walks 0x40 to 0x4F.

**Thrust reverse never reported active over MSP.** The mode was advertised in
initActiveBoxIds() but never packed into the outgoing bitmask, so the
Configurator showed it off while the driver was acting on it.

**`esc_calibrate` was compiled out of most builds.** Both the command and its
table entry sat inside `#ifdef USE_USB_MSC`, so the documented command did not
exist on any build without mass storage - SITL included. Verified present there
now.

**A configuration could select a protocol the firmware cannot drive.** Restoring
SRXL2 onto a build without the driver left the motor writer null and escaped the
insufficient-outputs check, giving a model that arms and does nothing; it now
falls back at boot. Reverse on channel 1 aliases the throttle and the driver
refuses it, silently - that setting is now corrected to 0 where the user can see
it.

**Absent telemetry fields arrived as confident zeros.** The wire has a "no data"
code per field and the struct carried one valid flag for the frame, so an ESC
that does not report current delivered 0.00 A - indistinguishable from a motor at
rest, and believed by the battery estimate. Fields are now tracked individually
and a missing one leaves the previous reading in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MrScothh

Copy link
Copy Markdown
Author

Thanks - all ten were real, and all ten are fixed in ac906d2. Two of them were serious, so worth spelling out:

Slow telemetry dropping the link (1) was worse than the finding says. Nothing but a telemetry reply refreshes the receive timer, because a running Avian never speaks unprompted - so the request rate is the link-liveness rate. On top of the 25-frame case landing exactly on the timeout, this ESC answers only about two requests in three, so the 10-frame setting could also miss for 600 ms. And a dropped link is unrecoverable here: the driver returns to polling, and a running Avian answers no discovery of any kind (measured: 128 handshakes to its address, 128 broadcasts, 128 spread across 0x40..0x4F, 319 control frames requesting telemetry - zero replies). So the timeout could kill a healthy motor in flight with no way back.

I removed the two slow settings rather than making the timeout elastic, which would have hidden the failure the timeout exists to catch. The table now stops at one request every five frames - a reply typically every 150 ms, three consecutive misses still inside the 500 ms window - and the constants carry that reasoning. Related, (5): the stale window was 1 s while the ESC's own readings arrive about once a second, so a healthy sensor flickered; it is 3 s now, with the link timeout doing the job of noticing a stopped ESC.

Manual calibration into a live ESC (2) - correct, and it was the worse half: the unattended wizard refused a connected battery and the hand-typed command did not. Guarded now, with boards that cannot sense the pack unaffected, which is the case that path exists for.

The rest: handshake length checked before the payload is read (10); discovery now walks 0x40..0x4F instead of only the ID that announces itself, which is what the comment claimed it did (4); THRUST REVERSE is packed into the outgoing box bitmask, so the Configurator stops showing it off while the driver acts on it (7); esc_calibrate no longer sits inside #ifdef USE_USB_MSC - it was absent from every build without mass storage, SITL included, and is verified present there now (9); a configuration selecting SRXL2 on a build without the driver falls back at boot instead of arming and driving nothing, and reverse on channel 1 is corrected to 0 rather than refused silently (3, 8); telemetry fields are tracked individually, so an ESC that does not report current no longer delivers 0.00 A for the battery estimate to believe (6).

The PR description also now carries the full bench data behind the recent changes - the rate table, the mask sweep, the calibration band before and after, and the thrust reverse measurements.

Upstream took MSP2_INAV_MAG_UNALIGNED at 0x2232 while this branch was out of
tree, where it was the SRXL2 status command. A released command keeps its
number and an unmerged one has no claim on it, so the SRXL2 pair moves up to
0x2233 and 0x2234 instead.

Also closes the last open review thread. Reverse was normalised only against
channel 1, the throttle alias the driver refuses; channels 2 to 4 were still
accepted and still offered a mode no Smart ESC can act on, since Spektrum
document the reverse channel as 5 to 9. The setting's range cannot express
"zero, or five to nine", so anything between is corrected to zero at boot -
verified by saving 3, 7 and 9 and rebooting: 3 comes back as 0, the other two
survive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MrScothh

Copy link
Copy Markdown
Author

Rebased onto current maintenance-10.x (73 commits) - the conflict was an MSP number collision: MSP2_INAV_MAG_UNALIGNED landed on 0x2232, which this branch was using for the SRXL2 status command. A released command keeps its number and an unmerged one has no claim on it, so the SRXL2 pair moved up to 0x2233/0x2234.

That also closes the one review thread still open, (8). My earlier pass only normalised channel 1 - the throttle alias the driver refuses outright - and left 2 to 4 accepted, still advertising a reverse mode no Smart ESC can act on. Since the setting's range cannot express "zero, or five to nine", anything between is now corrected to zero at boot, where it is visible rather than silently ineffective. Verified by saving 3, 7 and 9 and rebooting: 3 comes back as 0, 7 and 9 survive.

MrScothh and others added 3 commits September 16, 2026 10:20
The guide weighed the slow telemetry and then concluded that on an aircraft
holding cruise throttle it is adequate. That conclusion was reasoning, presented
in the same voice as the measurements around it.

The measurement is that rpm arrives 2.7 times a second at the fastest rate the
link tolerates, and 1.1 at the default - two to three orders of magnitude below
bidirectional DSHOT, with no margin to recover, since asking faster stops the
ESC obeying the throttle and it advertises no support for 400000 baud. From
that, on a multirotor the filter should be off: a notch updated twice a second
is mostly in the wrong place, attenuating signal instead of noise. On a fixed
wing at steady throttle it may well be fine, and the text now says that this is
an argument rather than a flight result, and suggests logging it both ways.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A telemetry gap used to tear the link down: back to 115200, device ID forgotten,
state to POLLING - which stops sending control data altogether. On this hardware
that is unrecoverable, because a running Avian answers no discovery of any kind,
so a gap of half a second cost the motor for the rest of the flight.

Measured on the bench with the supply as the witness rather than the ESC's own
rpm field, which turned out to freeze at its last value when commutation stops:

  - no telemetry requested for 3 s, then for 10 s, with control frames still
    going out: current held 0.29 to 0.31 A throughout, against 0.30 before. The
    motor never faltered. Telemetry has nothing to do with the throttle.
  - no frames at all: current falls to the ESC's own 58 mA within about half a
    second, and returns to 0.30 A as soon as frames resume - no re-arm, no power
    cycle, the throttle picked up where it was left.

So the timeout was causing the outage it existed to detect. It now lets the
telemetry go stale and keeps driving. The teardown remains for the one case that
needs it: a slave that reset comes back at 115200 and announces for 300 ms, which
cannot be heard from 400000 - so it fires only where the baud was actually
raised, which no Avian tested allows.

Arming keeps the old strictness. With the link no longer torn down, a board whose
ESC was unplugged would sit in RUNNING and arm happily, so srxl2MotorIsConnected()
now asks for a recent reply rather than for the state alone. Strict on the ground,
forgiving in the air: two questions that used to share one answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An Avian announces itself within 300 ms of gaining power, and is not ready to
turn the motor for about five seconds after that: it spends them playing its
startup tones, the last of which is what a pilot hears as "connected". Timed on
the bench with the announcement as the zero and the tones as the reference.

Arming now waits six seconds from the link coming up rather than for the
handshake alone - a second past the last tone. Where a person powers the
aircraft and then arms it this costs nothing, since nobody arms within six
seconds of connecting the battery; where something arms sooner, the alternative
was commanding a motor that was not yet listening.

It rides on srxl2MotorIsConnected(), which the arming check already consults, so
this is one more reason for that predicate to say no rather than a new mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MrScothh
MrScothh force-pushed the feature/srxl2-esc-10x branch from d6d11da to e5e121a Compare September 16, 2026 13:12
Six seconds was timed against the tones themselves, which leaves very little
margin once the announcement jitters. Commanding throttle exactly on that
boundary does start the motor cleanly - measured on the bench, it turns 0.35 s
after the command and holds - but half a second of headroom costs nothing on a
delay nobody waits for in practice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant