Skip to content

mixer: dynamic idle from bidirectional DShot RPM - #12134

Draft
MrScothh wants to merge 35 commits into
iNavFlight:maintenance-10.xfrom
MrScothh:feature/dynamic-idle-bidir
Draft

MrScothh wants to merge 35 commits into
iNavFlight:maintenance-10.xfrom
MrScothh:feature/dynamic-idle-bidir

Conversation

@MrScothh

@MrScothh MrScothh commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Depends on bidirectional DShot, not merged yet. It is based on #12027's head (4a7d4e2): error414's current version of #11605, marked as testing and meant to be merged into #11605. Until one of the two is merged this PR's diff includes #12027's commits; I'll rebase onto whichever is merged. Please don't merge this before it.

Ported from Betaflight's dynamic idle. With bidirectional DShot, the motors' low end follows the slowest motor's RPM instead of staying at throttle_idle: a PID on the slowest motor raises it as much as needed to keep dynamic_idle_min_rpm, up to dynamic_idle_max_increase, and lets it fall back to the lowest DShot value when the motors turn fast enough on their own. Low idle where the props keep spinning, more idle where they would slow down too much, for example in a fast descent or a sharp flip, which is where desyncs start.

Settings, in the motor group, on targets with USE_DSHOT_BIDIR (dynamic_idle_min_rpm 0, the default, keeps today's behaviour):

setting default
dynamic_idle_min_rpm 0 in RPM; Betaflight's dyn_idle_min_rpm is in hundreds, so 35 there is 3500 here
dynamic_idle_p_gain, _i_gain, _d_gain 50 Betaflight's defaults and scaling
dynamic_idle_max_increase 150 15% of the motor range, as in Betaflight

How it follows Betaflight, and where it differs:

  • The slowest motor's frequency is smoothed by a 150 Hz PT1 before the PID. Betaflight smooths each motor at rpm_filter_lpf_hz (150) and then takes the minimum; Bidir dshot - testing dont merge #12027 hands over the last eRPM as received and filters it only for the RPM filter's notches.
  • P, I (rising fast, falling slowly), the D on a copy of the speed about 20 ms behind, and the 1% throttle floor while dynamic idle runs are Betaflight's. Until the throttle has passed airmode_throttle_threshold once after arming it goes no higher than throttle_idle: Betaflight waits for its airmode threshold too, and caps the start at dyn_idle_start_increase.
  • INAV's throttle command starts at throttle_idle (the throttle curve and navigation use that as the low end), so the mixer maps it onto the range above the dynamic idle; a motor value under throttle_idle is then sent as a running DShot value, not as a stop, also in failsafe. Open-loop throttles (nav_mc_hover_thr, failsafe_throttle without an altitude source) map a little lower while the dynamic low end is under throttle_idle; closed loops absorb it.
  • At low stick with airmode off, INAV skips the mixer and sets the motors to the idle; dynamic idle sets that idle too (motor stop off, stopped by the pilot, not by navigation). Betaflight runs its mixer at zero throttle, so there it always acts.
  • A motor whose replies stop decoding keeps its last RPM in Bidir dshot - testing dont merge #12027. Dynamic idle uses Bidir dshot - testing dont merge #12027's per-motor count of decoded replies in the last 1 s window: it starts once at least 80% of every motor's replies decode (and at least 100 came), and falls back to the static idle below 50%. When it comes back, its smoothing and D start from the speed at that moment. Replies with no edges at all are not counted by Bidir dshot - testing dont merge #12027, so a motor that mostly does not reply but decodes the rest passes; a per-motor age of the last eRPM would close that.
  • A motor the current mixer profile keeps stopped (a VTOL pusher in the multirotor profile) is left out of the minimum and keeps its idle at low stick.
  • With motor stop on, the motors stop at low stick as today; when they start again the P term on 0 RPM takes the low end straight to dynamic_idle_max_increase, as in Betaflight.

It runs only on a multirotor mixer with DShot and dshot_bidir_enabled. Reversible motors, the logic-condition throttle override and fixed-wing mixers are left as they are. The new DYN_IDLE debug mode logs P, I, D (x10000) and the slowest motor's frequency (0.1 Hz).

The new fields go at the end of motorConfig_t, which #12027 already moves from version 11 to 12, so there is no second bump: the motor group returns to its defaults once, with #12027. They are on every target, like #12120's, so turning USE_DSHOT_BIDIR on for more targets later never moves the fields stored after them; only the settings and the controller are under USE_DSHOT_BIDIR.

Overlaps with other open PRs (trial merges against every open PR touching the same files): #12120 and #12121 (mine, throttle boost and thrust linearization) also add fields to motorConfig_t and code to mixTable(), and #12022 adds settings next to these in the motor group. The overlaps are textual: whichever is merged second, I'll rebase it.

Testing

On the bench, motors without props, PIDs at zero so that only the idle moves the motors, armed over MSP: 6 s at low stick, 1.5 s at 1300, 8 s at low stick. Read every 0.1 s: motor outputs, RPM, and the DYN_IDLE debug values.

board code dynamic_idle_min_rpm before the blip after the blip
Kakute H7 Mini, its ESC this PR on #12027 7000 output 1090, ~5,200 RPM (capped at throttle_idle) slowest motor 6,871 to 7,114 RPM, output 1115 to 1136
Flywoo F722 Pro V2, stack ESC the same controller before review, on #11605 with a faster capture switch (see #12027) 4000 output 1090, ~2,900 RPM slowest motor 3,986 to 4,014 RPM, output 1121 to 1124
Diatone Mamba F722, stack ESC the same as the Flywoo 14000, P and D 15 output 1090, ~11,000 RPM slowest motor 13,686 to 14,186 RPM, output 1117 to 1125

On the F722s with airmode on and off; on the Kakute with airmode on and off. Disarming stops the motors each time.

  • The Mamba's motors gain about 17 Hz per 1% of throttle without props, the Flywoo's about 6: with the default P and D 50 the Mamba oscillated (output 1034 to 1159, slowest motor 12,757 to 15,086 RPM, I at the cap), with 15 it held. Props load the motors and lower that gain in flight, which is why Betaflight keeps 50; fast motors may still need lower P and D.
  • A motor whose replies stop decoding: on the F722 before the faster capture switch (one motor at 0% decoded, its RPM frozen on a wrong value) an earlier version of this PR raised the idle to the 15% cap. With the decoded-replies check, on the Kakute with a bench build that throws away motor 3's replies mid-run: its RPM froze, the static idle applied 1.1 s later (output 1080), and when the replies came back dynamic idle resumed after 1.0 s, settling within 0.7 s with no D kick. The delay is the 1 s counting window; a per-motor age of the last decoded RPM in Bidir dshot - testing dont merge #12027 would cut it to milliseconds.
  • A motor the active mixer profile keeps stopped: on the Kakute, a fifth motor used only in mixer profile 2 (throttle 0 in the active profile, nothing on that output, so it never replies). Dynamic idle held the four lift motors at 6,957 to 7,286 RPM and output 5 stayed stopped; with the same motor made active in profile 1, dynamic idle never started, as the decoded-replies check wants for a motor that doesn't reply. getThrottlePercent() reads 0% at low stick under dynamic idle.

Not flown. I'd be glad if someone who flies #12027 on a quad could try it, with debug_mode = DYN_IDLE in the blackbox.

Builds on F4, F7, H7 and AT32 (F7 without USE_DSHOT_BIDIR in #12027, so without dynamic idle) and SITL. Against #12027's head: flash +1,240 to +1,368 B and RAM +32 to +48 B on F4 (MATEKF405, MATEKF405SE), H7 (MATEKH743, KAKUTEH7MINI) and AT32 (BLUEBERRYF435WING), ITCM +56 to +112 B on H7; on F7, where #12027 has no bidirectional DShot, flash +16 to +24 B (the debug mode's name), RAM +8 B (the fields), ITCM unchanged (KAKUTEF7 and KAKUTEF7HDV 16,096 of 16,384). Unit tests: 620 of 620.

halfmanbear and others added 30 commits May 26, 2026 23:05
Ports bidirectional DShot (GCR) telemetry from Betaflight, enabling
RPM-based gyro filtering without a dedicated ESC telemetry UART.

After each DShot output frame the motor pin is switched to timer input
capture mode via per-channel DMA to receive the ESC's GCR-encoded eRPM
response. The decoded eRPM feeds the existing RPM filter infrastructure.

New settings:
- dshot_bidir_enabled: enable bidirectional DShot telemetry
- dshot_edt_enabled: enable Extended DShot Telemetry (temperature,
  voltage, current in addition to eRPM)

Changes:
- drivers/dshot.c/.h: GCR decode, EDT frame parsing, eRPM-to-RPM
  conversion, motor frequency LPF (ported from Betaflight, GPLv3)
- drivers/pwm_output.c: per-channel DMA direction switching between
  DShot output and GCR input capture on STM32 (StdPeriph/HAL) and AT32
- sensors/esc_sensor.c/.h: DShot bidir data path into ESC sensor
  framework (escSensorSetDshotData, escSensorIsActive)
- flight/rpm_filter.c: DShot telemetry as frequency source alongside
  serial ESC sensor path
- target/common_post.h: USE_RPM_FILTER enabled for any USE_DSHOT target
- fc/fc_init.c: call initDshotTelemetry() at startup; fix RPM filter
  init condition to accept DShot bidir as a valid source
- blackbox/blackbox.c: fix NULL dereference when escSensorGetData()
  returns NULL; fix uninitialized blackboxSlowState_t comparison
- telemetry/srxl.c: replace Betaflight-specific USE_DSHOT_TELEMETRY
  guards with INAV's USE_DSHOT/USE_ESC_SENSOR; use escSensorGetData()

Tested on SPEEDYBEEF405V4 (STM32F405, StdPeriph) with Bluejay ESC
firmware. eRPM telemetry confirmed in blackbox; RPM gyro filter
updating notch frequencies with motor speed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dshotDmaStream() is only called from the USE_HAL_DRIVER code paths, but
was defined unconditionally with a dead fallback stub, so non-HAL F4
targets (e.g. FF_F35_LIGHTNING) built it with -Werror=unused-function.
Scope the definition to USE_HAL_DRIVER to match its call sites.
pwm_output.c called LL_TIM_EnableDMAReq_CCx/LL_TIM_DisableDMAReq_CCx,
which don't exist in ST's LL timer driver (only per-channel CC1..CC4
variants are provided). Add local static inline wrappers that set/clear
the DIER CCxDE bits directly by mask, matching the equivalent helpers
already used internally by timer_impl_hal.c.
…metry settings

dshot_bidir_enabled and dshot_edt_enabled were added to settings.yaml but
docs/Settings.md was never regenerated, failing CI's update_cli_docs.py
check. Ran the generator; purely additive diff, no other drift.
loadDmaBufferDshot() was only defined when USE_DSHOT_DMAR was unset, but
pwmCompleteMotorUpdate() also calls it as the per-channel DMA fallback on
DMAR targets whenever bidir telemetry is enabled (burst DMA can't drive
per-channel direction switching). Define it unconditionally; keep
loadDmaBufferDshotStride() gated behind USE_DSHOT_DMAR since it's only
used by the DMAR-specific burst path.
…cm-overflow

fix: keep DShot telemetry decode out of ITCM
pwmSetMotorDMACircular() predates bidirectional DSHOT and corrupts its
per-channel direction-switching state machine when a config save runs
(e.g. servo autotrim saving on disarm):

- On USE_DSHOT_DMAR targets the burst path is never armed with bidir, so
  dmaBurstBuffer stays NULL and loadDmaBufferDshotStride() writes the
  zero-throttle frame through the NULL pointer into 0x00000000 - which is
  ITCM RAM holding .tcm_code (FAST_CODE), corrupting the firmware.
- On per-channel targets impl_timerPWMSetDMACircular() re-enables the
  stream without regard to the current transfer direction, and on exit
  disables the TIM DMA request before the stream: a FIFO-enabled
  mem-to-periph stream then cannot drain, EN sticks at 1 and every later
  reconfiguration is silently ignored - the output is dead until reboot.
- Circular output also repeats frames back-to-back, colliding with the
  GCR reply the ESC drives onto the same line after every frame.

Match Betaflight instead: with bidir telemetry active, leave motor DMA
alone during the flash write and accept the frame gap. Saves only happen
disarmed, the ESC holds zero throttle, and the existing decode path
rebuilds the streams (full DeInit/Init) on the first update afterwards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB55zdRNZq3Q6hZYsDYwTc
…ot-fix

BidirDshot - fix for H7, invertion of timer, reset DMA for reading te…
# Conflicts:
#	src/main/drivers/pwm_output.c
#	src/main/fc/settings.yaml
#	src/main/sensors/esc_sensor.c
Structure:
- dshot.c/h -> bidir_dshot.c/h, compiled only with USE_DSHOT_BIDIR. The
  define comes from common_post.h: USE_DSHOT && STM32H7 && !USE_DSHOT_DMAR
  (direction switching is verified on H7; DMAR has no per-channel stream).
  dshot_bidir_enabled / dshot_edt_enabled are hidden elsewhere.
- Every bidir path in pwm_output.c sits under USE_DSHOT_BIDIR and, at run
  time, under useDshotTelemetry: with the setting off, plain DSHOT keeps the
  timer driver's DMA handler, FIFO setup and channel config from
  maintenance-10.x. The DMAR fallback to per-channel DMA is gone (excluded
  by the define instead).
- USE_RPM_FILTER follows USE_ESC_SENSOR || USE_DSHOT_BIDIR instead of
  USE_DSHOT, so targets without ESC sensor do not grow.
- No circular keep-alive for bidir: the line idles high for the flash
  write, as in Betaflight.

Fixes:
- EDT voltage/current were 10x off (0.25 V and 1 A steps into 0.01 V /
  0.01 A fields): x25 / x100 instead of x250 / x1000.
- ESC sensor RPM taken from the already computed mechanical RPM instead
  of computeRpm((int16_t)eRPM/100); an EDT-only first frame no longer
  publishes rpm = 0.
- GCR decoder: guard against len < 1 (shift by -1) on glitch edges and on
  an exactly filled frame, and against len > 21.
- Bidir ESC sensor data is aged at the serial poll rate, so a silent ESC
  drops out of the combined values like a serial one.
- useDshotTelemetry is set once, by pwmMotorPreconfigure(); the ESC
  sensor asks isDshotTelemetryActive() instead of re-deriving it.
- pwmDshotDmaIrqHandler keeps tch->dmaState in step with the timer
  driver's handler (timerPWMDMAInProgress()).
- srxl.c: RPM comes through escSensorGetData(), no dshot include needed;
  duplicate includes removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
error414 and others added 5 commits October 2, 2026 17:19
With bidirectional DShot, a PID on the slowest motor's RPM raises the motors'
low end as much as needed to keep dynamic_idle_min_rpm, up to
dynamic_idle_max_increase. Off by default (dynamic_idle_min_rpm = 0).
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

RAM / Flash usage vs. base commit e87050f — commit 44d28e4

Using the nearest available size baseline — the PR's exact base commit has no stored baseline yet.

Target Flash Δ RAM Δ
MATEKF405 ⚠️ +4328 B (+0.60%) CCM: +40 B (+0.12%)
RAM: +408 B (+0.39%)
MATEKF722 +376 B (+0.08%) ITCM_RAM: -184 B (-1.47%)
RAM: +120 B (+0.14%)
TCM: ±0 B (±0.00%)
MATEKF765 +688 B (+0.09%) DTCM_RAM: ±0 B (±0.00%)
SRAM1: +120 B (+0.10%)
MATEKH743 ⚠️ +4872 B (+0.61%) D2_RAM: +320 B (+9.80%)
DTCM_RAM: ±0 B (±0.00%)
ITCM_RAM: +184 B (+1.13%)
RAM: +416 B (+0.29%)

See RAM/flash optimization guide for techniques to reduce usage.

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Test firmware build ready — commit 44d28e4

Download firmware for PR #12134

251 targets built. Find your board's .hex file by name on that page (e.g. MATEKF405SE.hex). Files are individually downloadable — no GitHub login required.

Development build for testing only. Use Full Chip Erase when flashing.

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.

5 participants