Skip to content

blackbox: optionally log the second gyro on dual-IMU boards - #11933

Merged
sensei-hacker merged 14 commits into
iNavFlight:maintenance-10.xfrom
MrScothh:feature/secondary-gyro-logging
Sep 21, 2026
Merged

sensei-hacker merged 14 commits into
iNavFlight:maintenance-10.xfrom
MrScothh:feature/secondary-gyro-logging

Conversation

@MrScothh

@MrScothh MrScothh commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

The latent bug this feature turns into a real one was proposed separately as
#11932, which has since been closed as a duplicate of this PR. So the first
commit here is now the only place that fix lives
- please keep it rather than
dropping it.

It is one line in gyroUpdateAndCalibrate(): the function took a
zeroCalibrationVector_t * and then wrote to gyroCalibration[0] regardless.
That changes no behaviour today, because the single call site passes
&gyroCalibration[0], and it becomes a real bug the moment a second calibration
slot exists - which is what this PR adds. @Raffi1202 reviewed it independently
while checking #11905 against it and reached the same conclusion: "Worth merging
#11932 regardless - passing a parameter and then ignoring it is the kind of thing
that bites exactly once, later."

Later in this PR gyroUpdateAndCalibrate() goes one step further and takes the
sensor index instead of pointers, so the device and the calibration it works on
come from the same place and cannot disagree at all.

What this adds

On boards that carry two IMUs, INAV currently selects one of them:
gyro_to_use picks a bus tag, MAX_GYRO_COUNT is 1, and only gyroDev[0] is
ever read. The second sensor is present and powered but never sampled.

This PR adds the option to sample it as well, purely as an instrumentation
channel, and to log it to Blackbox as gyroRaw2.

One switch: set gyro_secondary_enabled = ON. The second IMU is then initialised
at boot, measures its own zero, and is read while a Blackbox log is open, one
extra SPI transaction per gyro cycle; the rest of the time it is not read. It adds
gyroRaw2 to the log; include GYRO_RAW as well to compare the two sensors,
since gyroADC is filtered and gyroRaw2 is not. Default OFF.

An earlier revision also had a blackbox GYRO_2 include flag. I removed it in
a524043: with the setting on and the flag off, the board detected, calibrated
and read the second sensor every gyro cycle and threw the numbers away. The
setting exists only to produce that column, so it decides both.

Why

With a single gyro you cannot separate sensor noise from real airframe motion:
whatever you see might be the aircraft. Two sensors rigidly attached to the same
airframe let you separate them by coherence: what correlates between the two is
motion, what does not is noise. That is the number you actually want when
choosing a gyro LPF cutoff, and today it cannot be measured from an INAV log.

The same data makes it possible to characterise a board's two IMUs against each
other, which is useful when deciding what gyro_to_use should be.

Safety

The secondary never reaches attitude estimation or the PID loops. gyro.gyroRaw2
is read only by blackbox.c, and the secondary itself is only initialised and
read inside the USE_DUAL_GYRO blocks in gyro.c.

Three specific points:

1. Read ordering. The secondary is read before the primary's early return,
so a stalled or uncalibrated secondary can never suppress the sample that flies
the aircraft.

2. Calibration ownership. This was the one place where the two sensors
genuinely touched. gyroConfig()->gyro_zero_cal[] is a single shared triple and
performGyroCalibration() rewrites it on completion. With a second gyro
calibrating, whichever finishes last wins, so the secondary could write its own
bias into the primary's persisted calibration, the one used in flight when
init_gyro_cal = OFF. performGyroCalibration() and gyroUpdateAndCalibrate()
now take the sensor index, GYRO_PRIMARY or GYRO_SECONDARY, and only the
primary writes the config. Likewise only the primary honours init_gyro_cal: the
secondary always measures its own zero, because the stored calibration belongs to
the other sensor and applying it here would bias the logged samples. Each sensor
also has its own cached "calibration complete" flag. With a shared one, the
secondary finished first, the primary skipped its own final sample and stayed
"calibrating" for good, and the board would not arm; reproduced in SITL before the
fix, and gone after it.

3. Config compatibility: why the new field sits at the end of the struct.
pgLoad() compares only the parameter group version, never the size, and then
memcpy()s MIN(stored, current) bytes over a freshly pgReset() instance:

pgReset(reg, profileIndex);
if (version == pgVersion(reg)) {
    const int take = MIN(size, pgSize(reg));
    memcpy(pgOffset(reg, profileIndex), from, take);
}

So a field added mid-struct without a version bump would silently shift every
following field of an existing saved configuration; on gyroConfig_t that is
the whole set of gyro filter cutoffs. Appending at the end instead makes the
change purely additive: existing settings keep their offsets, the new field keeps
the default pgReset() installed, and PG_GYRO_CONFIG does not need a version
bump, which means upgrading does not discard anyone's gyro configuration. There
is a comment in gyro.h saying so, so the next person does not undo it.

If you would rather have the version bumped anyway as a matter of policy, say so
and I will add it; I avoided it specifically to not reset users' settings.

Single-IMU targets are untouched

Everything is under #ifdef USE_DUAL_GYRO, including the Blackbox field
definitions and the condition enum entry, string tables that would otherwise land
in the binary regardless. On a single-IMU target gyroConfig_t does not change
shape at all.

Verified on the ELFs built from the current head rather than by reading the
source:

$ strings bin/AIKONF7.elf | grep -Ex "gyro_secondary_enabled|gyroRaw2|GYRO_2"
(no output)
$ strings bin/TBS_LUCID_H7_WING.elf | grep -Ex "gyro_secondary_enabled|gyroRaw2|GYRO_2"
gyroRaw2
gyroRaw2
gyro_secondary_enabled

And against the same two targets built from this PR's merge base:

Target text data bss
AIKONF7 (single IMU) +0 +0 +0
TBS_LUCID_H7_WING (dual IMU) +824 +0 +180

AIKONF7 was the deliberate control build because it sits at about 93% of FLASH1,
with no room for an accidental regression; it comes out the same size in every
section.

One unrelated line, and why it is here

The Blackbox condition cache is a uint64_t, and on a dual-gyro target the
condition enum now ends at exactly 64 entries: FLIGHT_LOG_FIELD_CONDITION_NEVER
is 63, the last usable bit. This PR consumes the last free slot. (The enum entry
is under #ifdef, so single-IMU targets keep upstream's numbering and still have
one spare.)

The assertion that is supposed to catch that is off by one:

STATIC_ASSERT((sizeof(blackboxConditionCache) * 8) >= FLIGHT_LOG_FIELD_CONDITION_LAST, ...)

FLIGHT_LOG_FIELD_CONDITION_LAST is inclusive: blackboxBuildConditionCache()
loops cond <= LAST and shifts 1ULL << cond, so 64 bits hold conditions 0..63
and the correct comparison is >, not >=. With >= the 65th condition passes
the check and produces 1ULL << 64, which is undefined behaviour, silently.
Tightened to >, with a comment next to it saying why. I would normally send this
separately, but it is the guard on the exact resource this PR exhausts, so it
seemed wrong to leave it for the next person to discover the hard way. Happy to
split it out if you prefer.

What has NOT been verified yet

Being explicit so nobody has to guess:

  • No timing measurement on real hardware yet. The extra SPI transaction lands
    in the gyro task while a log is open, and I have not measured the task execution
    time with and without the option on a physical board. On the target I am using
    (TBS_LUCID_H7_WING) the two IMUs sit on separate SPI buses, so there is no bus
    contention; but that is an argument, not a measurement. I will post numbers.
    Please do not merge on my word alone.
  • Not yet flown on real hardware. It has flown in SITL against X-Plane 11, a
    flying wing taking off, climbing and turning both ways with the log open for the
    whole flight. SITL's fake IMU answers for both sensors, so the two columns should
    agree, and they did: 1746 rows, all three axes, with one row 1 deg/s apart where
    the simulator updated between the two reads. That exercises the logging path, not
    a second physical sensor.
  • Config upgrade path not exercised on hardware. The reasoning in point 3
    above is read off pgLoad(); I have not yet flashed over an existing
    configuration on a dual-gyro board and confirmed the gyro settings survive.

Review of the design and of the safety argument is very welcome in the meantime.

Testing done

  • Builds clean for TBS_LUCID_H7_WING (dual IMU) and AIKONF7 (single IMU), and
    the SITL builds clean with and without a second IMU.
  • Symbol and size check on both ELFs, as above.
  • SITL, with a local-only define that gives it a second IMU and a fake gyro that
    only moves while armed, so a stale gyroRaw2 would show as a flat line:
    • arms with the option off, on, and on with init_gyro_cal = OFF;
    • reads of the secondary counted every second: about 95/s during its power-up
      calibration and while a log is open, none otherwise; before 23c4a8e they ran
      at about 95/s all the time;
    • gyroRaw2 equal to gyroRaw on every row of every log, with the gyro moving;
    • two logs in a row: the reads resume for the second and stop in between.
  • The X-Plane flight above.
  • docs/Settings.md regenerated with src/utils/update_cli_docs.py, so the
    "Make sure docs are updated" workflow passes.
  • docs/Blackbox.md updated by hand to describe gyroRaw2 and when it is read.

sensei-hacker and others added 2 commits September 8, 2026 15:54
…rate()


The function takes the calibration state to operate on as a parameter, but
one line wrote to gyroCalibration[0] directly instead of the argument.

There is currently a single call site and it passes &gyroCalibration[0], so
this has no effect today. It becomes wrong the moment a second call site
exists with a different calibration: the wrong one is marked
ZERO_CALIBRATION_DONE.
@github-actions

Copy link
Copy Markdown

Branch Targeting Suggestion

You've targeted the master branch with this PR. Please consider if a version branch might be more appropriate:

  • maintenance-9.x - If your change is backward-compatible and won't create compatibility issues between INAV firmware and Configurator 9.x versions. This will allow your PR to be included in the next 9.x release.

  • maintenance-10.x - If your change introduces compatibility requirements between firmware and configurator that would break 9.x compatibility. This is for PRs which will be included in INAV 10.x

If master is the correct target for this change, no action is needed.


This is an automated suggestion to help route contributions to the appropriate branch.

On boards with two IMUs, gyro_to_use selects one of them and only gyroDev[0]
is ever sampled. The second sensor is powered but never read.

Add gyro_secondary_enabled, which samples the other IMU purely as an
instrumentation channel, and the GYRO_2 blackbox include flag, which logs it
as gyroRaw2. Both default to off. The two are separate switches because the
costs differ: one is an extra SPI transaction per gyro cycle, the other is
log bandwidth.

The secondary never reaches attitude estimation or the PID loops. It is read
before the primary's early return, so a stalled or uncalibrated secondary
cannot suppress the sample that flies the aircraft.

Calibration ownership had to be made explicit. gyroConfig()->gyro_zero_cal[]
is a single shared triple that performGyroCalibration() rewrites on
completion, so a second calibrating gyro could persist its own bias over the
primary's. performGyroCalibration() now takes persist and only the primary
writes the config; gyroUpdateAndCalibrate() takes isPrimary and the secondary
always measures its own zero, ignoring init_gyro_cal, whose stored value
belongs to the other sensor.

Everything is under USE_DUAL_GYRO, including the field definitions, the
condition enum entry and the CLI flag name. On single-IMU targets isPrimary
is a compile-time constant and LTO removes the parameters: AIKONF7, at 93%
flash, does not grow.

The condition enum now ends at exactly 64 entries, so this consumes the last
bit of the uint64_t condition cache. The STATIC_ASSERT guarding that compared
with >= and would have let the next addition through as 1ULL << 64;
tightened to >.
@MrScothh
MrScothh force-pushed the feature/secondary-gyro-logging branch from f23c02d to 0386632 Compare September 12, 2026 13:26
@MrScothh
MrScothh marked this pull request as ready for review September 12, 2026 13:27
@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

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

Copy link
Copy Markdown

PR Summary by Qodo

Optionally log secondary gyro data on dual-IMU boards

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

Grey Divider

AI Description

• Optionally initializes and logs the unselected IMU on dual-gyro targets.
• Samples gyroRaw2 only during calibration and active Blackbox logging.
• Isolates per-sensor calibration while protecting primary flight-control and persisted offsets.
Diagram

sequenceDiagram
    participant C as CLI Config
    participant G as Gyro Core
    participant S as Secondary IMU
    participant B as Blackbox
    participant L as Log File
    C->>G: Enable secondary
    G->>S: Detect and calibrate
    B->>G: Start secondary logging
    loop Gyro cycles
        G->>S: Read raw rates
        S-->>G: Return sensor sample
        G-->>B: Expose gyroRaw2
        B->>L: Write gyroRaw2
    end
    B->>G: Stop secondary logging
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Dedicated secondary sampling task
  • ➕ Separates added SPI and calibration work from the flight-critical gyro task.
  • ➕ Allows an independently configured secondary sampling rate.
  • ➖ Introduces timestamp skew that weakens sensor-coherence analysis.
  • ➖ Requires scheduler integration, synchronization, and additional buffering.
2. Continuous sampling with a Blackbox flag
  • ➕ Keeps secondary samples available for future diagnostics or consumers.
  • ➕ Separates sensor activation from log-field selection.
  • ➖ Consumes SPI and CPU time even when no log uses the data.
  • ➖ Permits configurations that sample the sensor only to discard every value.

Recommendation: Keep the PR’s log-lifecycle-controlled sampling approach because it minimizes runtime cost and preserves closely timed primary/secondary samples without exposing the secondary to flight-control consumers. A dedicated task is only preferable if hardware timing measurements show the extra transaction materially affects the gyro task.

Files changed (7) +211 / -30

Enhancement (2) +18 / -0
blackbox_fielddefs.hDefine the secondary gyro logging condition +3/-0

Define the secondary gyro logging condition

• Adds a dual-gyro-only field condition used to include 'gyroRaw2' without changing condition numbering on single-IMU targets.

src/main/blackbox/blackbox_fielddefs.h

gyro.hExpose secondary gyro state and configuration +15/-0

Expose secondary gyro state and configuration

• Adds dual-gyro-only raw sample and initialization state, appends the opt-in setting without shifting existing configuration fields, and declares the Blackbox sampling-control API.

src/main/sensors/gyro.h

Documentation (2) +18 / -0
Blackbox.mdDocument the gyroRaw2 Blackbox channel +8/-0

Document the gyroRaw2 Blackbox channel

• Explains how dual-IMU targets expose aligned secondary rates as 'gyroRaw2', when the sensor is sampled, and why 'GYRO_RAW' should also be logged for comparison.

docs/Blackbox.md

Settings.mdDocument the secondary gyro setting +10/-0

Document the secondary gyro setting

• Adds generated CLI documentation for 'gyro_secondary_enabled', including its default, availability, runtime cost, and isolation from flight-control processing.

docs/Settings.md

Other (3) +175 / -30
blackbox.cEncode secondary gyro samples during active logs +38/-1

Encode secondary gyro samples during active logs

• Adds conditional 'gyroRaw2' field definitions, state storage, sampling lifecycle control, and I/P-frame encoding for dual-gyro targets. It also corrects the inclusive condition-cache capacity assertion before the new condition consumes the final available bit.

src/main/blackbox/blackbox.c

settings.yamlAdd the gyro_secondary_enabled CLI setting +6/-0

Add the gyro_secondary_enabled CLI setting

• Defines a dual-gyro-only Boolean setting that defaults off and controls both secondary sensor activation and Blackbox output.

src/main/fc/settings.yaml

gyro.cInitialize, calibrate, and conditionally sample the secondary gyro +131/-29

Initialize, calibrate, and conditionally sample the secondary gyro

• Expands dual-gyro storage, probes and starts the unselected IMU, and reads it only during calibration or active logging. Calibration state is indexed per sensor, only the primary uses or persists configured offsets, and secondary failures cannot suppress primary processing.

src/main/sensors/gyro.c

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

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

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Sampling forces second-gyro logging ✓ Resolved 🐞 Bug ≡ Correctness
Description
testBlackboxConditionUncached() enables gyroRaw2 solely from gyro.secondaryInitialized instead
of requiring a dedicated Blackbox include flag. Whenever gyro_secondary_enabled successfully
initializes the sensor, every log includes the channel and users cannot independently avoid its
bandwidth cost or enable it through blackbox GYRO_2.
Code

src/main/blackbox/blackbox.c[R865-866]

+    case FLIGHT_LOG_FIELD_CONDITION_GYRO_SECONDARY:
+        return gyro.secondaryInitialized;
Evidence
The new field condition checks only initialization, while the Blackbox feature-mask enum and CLI
name table contain no second-gyro entry. The documentation confirms the resulting coupling by
explicitly stating that gyroRaw2 has no separate flag.

src/main/blackbox/blackbox.c[861-867]
src/main/blackbox/blackbox.h[24-39]
src/main/fc/cli.c[185-204]
docs/Blackbox.md[171-175]

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

## Issue description
Secondary gyro sampling automatically enables `gyroRaw2` logging, so the independently configurable `blackbox GYRO_2` switch described by the feature is unavailable.
## Fix Focus Areas
- src/main/blackbox/blackbox.c[864-867]
- src/main/blackbox/blackbox.h[24-39]
- src/main/fc/cli.c[185-204]
- docs/Blackbox.md[171-175]
## Recommended Fix
Add a dual-gyro Blackbox feature-mask bit and matching `GYRO_2` CLI name, then require both that include flag and `gyro.secondaryInitialized` in the secondary field condition. Keep the new flag disabled by default and update the documentation to describe sampling and logging as separate switches.

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


2. Some boards never log the second gyro ✓ Resolved 🐞 Bug ≡ Correctness
Description
gyroInit() derives the secondary bus tag as 1 only for primary tag 0 and as 0 for every
other configured tag, instead of selecting another registered physical sensor. AETH743Basic
registers its two sensor positions as tags 0 and 2, so the default tag-0 primary probes
nonexistent tag 1, leaves secondaryInitialized false, and causes Blackbox to omit gyroRaw2;
several other dual-sensor targets assign both positions tag 0 and are unreachable as well.
Code

src/main/sensors/gyro.c[R351-352]

+        gyroDev[1].imuSensorToUse = (gyroConfig()->gyro_to_use == 0) ? 1 : 0;
+        if (gyroDetect(&gyroDev[1], GYRO_AUTODETECT) != GYRO_NONE) {
Evidence
The added assignment can probe only tag 0 or 1, while bus lookup requires an exact hardware-and-tag
match. AETH743Basic explicitly enables dual gyros but registers its two physical positions with tags
0 and 2, and the Blackbox condition omits the field whenever this failed lookup leaves the secondary
uninitialized.

src/main/sensors/gyro.c[343-357]
src/main/drivers/bus.c[115-136]
src/main/target/AETH743Basic/target.c[29-36]
src/main/target/BRAHMA_H7/target.c[29-30]
src/main/blackbox/blackbox.c[790-793]

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

## Issue description
Secondary gyro selection assumes every dual-sensor target uses tags 0 and 1, but repository targets also use 0 and 2 or duplicate tag 0. This silently prevents detection and Blackbox logging on those boards.
## Fix Focus Areas
- src/main/sensors/gyro.c[343-358]
- src/main/target/AETH743Basic/target.c[30-36]
- src/main/target/BRAHMA_H7/target.c[29-30]
## Recommended Fix
Replace arithmetic tag inversion with an explicit target-declared physical sensor-slot mapping. Normalize duplicate physical descriptors to unique logical slots where necessary, support the existing 0-and-2 layout, and add coverage for both conventional and nonconventional tag pairs.

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


3. Simulator builds fail on a warning ✓ Resolved 🐞 Bug ≡ Correctness
Description
performGyroCalibration() adds the persist parameter, but its sole reference is inside `#ifndef
USE_IMU_FAKE`, so preprocessing leaves that parameter unused in fake-sensor builds. SITL defines
USE_IMU_FAKE, and the CI target combines -Wall -Wextra with warnings-as-errors, so compilation
stops on -Wunused-parameter.
Code

src/main/sensors/gyro.c[418]

+STATIC_UNIT_TESTED void performGyroCalibration(gyroDev_t *dev, zeroCalibrationVector_t *gyroCalibration, bool persist)
Evidence
The parameter is introduced in the changed signature, while its only use is excluded from
fake-sensor preprocessing. SITL enables the fake sensor, the common compile options enable the
relevant warnings, and CI explicitly promotes them to errors.

src/main/sensors/gyro.c[418-418]
src/main/sensors/gyro.c[436-442]
src/main/target/SITL/target.h[62-67]
cmake/main.cmake[14-20]
cmake/sitl.cmake[129-134]
.github/workflows/ci.yml[300-301]

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 `persist` parameter is unused when `USE_IMU_FAKE` removes its only reference, causing warnings-as-errors SITL builds to fail.
## Fix Focus Areas
- src/main/sensors/gyro.c[418-442]
## Recommended Fix
Add `UNUSED(persist)` in the `USE_IMU_FAKE` branch, or restructure the conditional so `persist` is evaluated in every build configuration. Verify the SITL target with warnings-as-errors enabled.

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



Remediation recommended

4. Paused logs still read the second gyro 🐞 Bug ➹ Performance ⭐ New
Description
blackboxSetState() enables gyroSecondaryLogging for every state numerically above
BLACKBOX_STATE_STOPPED, rather than only BLACKBOX_STATE_RUNNING. Header generation, paused
logging, and shutdown therefore keep gyroUpdateSecondary() issuing SPI reads even though paused
and shutdown paths write no main frames that could contain gyroRaw2.
Code

src/main/blackbox/blackbox.c[R942-944]

+#ifdef USE_DUAL_GYRO
+    // The second gyro is read only for the log, so only while there is one
+    gyroSetSecondaryLogging(newState > BLACKBOX_STATE_STOPPED);
Evidence
The changed state predicate covers every state after STOPPED, while the Blackbox state handler
confirms that paused and shutdown states do not invoke main-frame logging. The secondary update path
performs a read whenever this flag is set.

src/main/blackbox/blackbox.c[938-945]
src/main/blackbox/blackbox.c[2453-2479]
src/main/sensors/gyro.c[655-671]
src/main/blackbox/blackbox.h[42-53]

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

## Issue description
`gyroSetSecondaryLogging(newState > BLACKBOX_STATE_STOPPED)` enables the secondary IMU while headers are emitted, while Blackbox is paused, and while it is shutting down. Those states do not write main log frames, so the extra gyro transaction cannot produce `gyroRaw2` data and contradicts the intended logging-only sampling behavior.

## Fix Focus Areas
- src/main/blackbox/blackbox.c[942-944]

## Recommended Fix
Enable secondary sampling only when entering `BLACKBOX_STATE_RUNNING`, and disable it for every other state. Preserve the existing calibration behavior: `gyroUpdateSecondary()` already continues reading an incomplete secondary calibration even when logging is disabled.

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


5. Vibration can zero the second-gyro log ✓ Resolved 🐞 Bug ☼ Reliability
Description
gyroStartCalibration() starts the secondary's two-second calibration with allowFailure set to
false, while gyroIsCalibrationComplete() continues to gate arming on the primary alone. If the
aircraft arms before that window completes and vibration exceeds the threshold, calibration restarts
indefinitely and gyroUpdate() writes zeroes to gyroRaw2 each cycle, making the second-gyro
channel useless for that flight.
Code

src/main/sensors/gyro.c[R389-390]

+    if (gyro.secondaryInitialized) {
+        zeroCalibrationStartV(&gyroCalibration[1], CALIBRATING_GYRO_TIME_MS, CALIBRATING_GYRO_MORON_THRESHOLD, false);
Evidence
The new secondary calibration uses a two-second window and disallows failure, which makes the
calibration implementation restart whenever noise remains above threshold. Arming checks only the
primary state, and every incomplete secondary update is explicitly replaced with zeros before
Blackbox copies it.

src/main/sensors/gyro.c[383-415]
src/main/sensors/sensors.h[42-46]
src/main/common/calibration.c[153-177]
src/main/fc/fc_core.c[201-218]
src/main/sensors/gyro.c[600-610]
src/main/blackbox/blackbox.c[1669-1674]

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

## Issue description
Secondary calibration may continue retrying after arming, causing every secondary Blackbox sample to remain zero under sustained vibration.
## Fix Focus Areas
- src/main/sensors/gyro.c[383-415]
- src/main/sensors/gyro.c[600-610]
## Recommended Fix
Make secondary calibration terminate after one window by allowing failure, then continue producing aligned and scaled samples with a zero offset when calibration fails instead of restarting forever. Add coverage for noisy secondary input during early arming and verify that primary updates remain unaffected.

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


Grey Divider

Context sources
Review mode: 🧠 Deep: This PR introduces substantial, hardware-facing gyro initialization, calibration, sampling, configuration, and Blackbox changes across multiple independent paths, creating a high density of easy-to-miss defects.

Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 15aaf4c 🧠 Deep

Results up to commit N/A


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


Action required
1. Sampling forces second-gyro logging ✓ Resolved 🐞 Bug ≡ Correctness
Description
testBlackboxConditionUncached() enables gyroRaw2 solely from gyro.secondaryInitialized instead
of requiring a dedicated Blackbox include flag. Whenever gyro_secondary_enabled successfully
initializes the sensor, every log includes the channel and users cannot independently avoid its
bandwidth cost or enable it through blackbox GYRO_2.
Code

src/main/blackbox/blackbox.c[R865-866]

+    case FLIGHT_LOG_FIELD_CONDITION_GYRO_SECONDARY:
+        return gyro.secondaryInitialized;
Evidence
The new field condition checks only initialization, while the Blackbox feature-mask enum and CLI
name table contain no second-gyro entry. The documentation confirms the resulting coupling by
explicitly stating that gyroRaw2 has no separate flag.

src/main/blackbox/blackbox.c[861-867]
src/main/blackbox/blackbox.h[24-39]
src/main/fc/cli.c[185-204]
docs/Blackbox.md[171-175]

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

## Issue description
Secondary gyro sampling automatically enables `gyroRaw2` logging, so the independently configurable `blackbox GYRO_2` switch described by the feature is unavailable.
## Fix Focus Areas
- src/main/blackbox/blackbox.c[864-867]
- src/main/blackbox/blackbox.h[24-39]
- src/main/fc/cli.c[185-204]
- docs/Blackbox.md[171-175]
## Recommended Fix
Add a dual-gyro Blackbox feature-mask bit and matching `GYRO_2` CLI name, then require both that include flag and `gyro.secondaryInitialized` in the secondary field condition. Keep the new flag disabled by default and update the documentation to describe sampling and logging as separate switches.

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


2. Some boards never log the second gyro ✓ Resolved 🐞 Bug ≡ Correctness
Description
gyroInit() derives the secondary bus tag as 1 only for primary tag 0 and as 0 for every
other configured tag, instead of selecting another registered physical sensor. AETH743Basic
registers its two sensor positions as tags 0 and 2, so the default tag-0 primary probes
nonexistent tag 1, leaves secondaryInitialized false, and causes Blackbox to omit gyroRaw2;
several other dual-sensor targets assign both positions tag 0 and are unreachable as well.
Code

src/main/sensors/gyro.c[R351-352]

+        gyroDev[1].imuSensorToUse = (gyroConfig()->gyro_to_use == 0) ? 1 : 0;
+        if (gyroDetect(&gyroDev[1], GYRO_AUTODETECT) != GYRO_NONE) {
Evidence
The added assignment can probe only tag 0 or 1, while bus lookup requires an exact hardware-and-tag
match. AETH743Basic explicitly enables dual gyros but registers its two physical positions with tags
0 and 2, and the Blackbox condition omits the field whenever this failed lookup leaves the secondary
uninitialized.

src/main/sensors/gyro.c[343-357]
src/main/drivers/bus.c[115-136]
src/main/target/AETH743Basic/target.c[29-36]
src/main/target/BRAHMA_H7/target.c[29-30]
src/main/blackbox/blackbox.c[790-793]

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

## Issue description
Secondary gyro selection assumes every dual-sensor target uses tags 0 and 1, but repository targets also use 0 and 2 or duplicate tag 0. This silently prevents detection and Blackbox logging on those boards.
## Fix Focus Areas
- src/main/sensors/gyro.c[343-358]
- src/main/target/AETH743Basic/target.c[30-36]
- src/main/target/BRAHMA_H7/target.c[29-30]
## Recommended Fix
Replace arithmetic tag inversion with an explicit target-declared physical sensor-slot mapping. Normalize duplicate physical descriptors to unique logical slots where necessary, support the existing 0-and-2 layout, and add coverage for both conventional and nonconventional tag pairs.

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


3. Simulator builds fail on a warning ✓ Resolved 🐞 Bug ≡ Correctness
Description
performGyroCalibration() adds the persist parameter, but its sole reference is inside `#ifndef
USE_IMU_FAKE`, so preprocessing leaves that parameter unused in fake-sensor builds. SITL defines
USE_IMU_FAKE, and the CI target combines -Wall -Wextra with warnings-as-errors, so compilation
stops on -Wunused-parameter.
Code

src/main/sensors/gyro.c[418]

+STATIC_UNIT_TESTED void performGyroCalibration(gyroDev_t *dev, zeroCalibrationVector_t *gyroCalibration, bool persist)
Evidence
The parameter is introduced in the changed signature, while its only use is excluded from
fake-sensor preprocessing. SITL enables the fake sensor, the common compile options enable the
relevant warnings, and CI explicitly promotes them to errors.

src/main/sensors/gyro.c[418-418]
src/main/sensors/gyro.c[436-442]
src/main/target/SITL/target.h[62-67]
cmake/main.cmake[14-20]
cmake/sitl.cmake[129-134]
.github/workflows/ci.yml[300-301]

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 `persist` parameter is unused when `USE_IMU_FAKE` removes its only reference, causing warnings-as-errors SITL builds to fail.
## Fix Focus Areas
- src/main/sensors/gyro.c[418-442]
## Recommended Fix
Add `UNUSED(persist)` in the `USE_IMU_FAKE` branch, or restructure the conditional so `persist` is evaluated in every build configuration. Verify the SITL target with warnings-as-errors enabled.

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



Remediation recommended
4. Vibration can zero the second-gyro log ✓ Resolved 🐞 Bug ☼ Reliability
Description
gyroStartCalibration() starts the secondary's two-second calibration with allowFailure set to
false, while gyroIsCalibrationComplete() continues to gate arming on the primary alone. If the
aircraft arms before that window completes and vibration exceeds the threshold, calibration restarts
indefinitely and gyroUpdate() writes zeroes to gyroRaw2 each cycle, making the second-gyro
channel useless for that flight.
Code

src/main/sensors/gyro.c[R389-390]

+    if (gyro.secondaryInitialized) {
+        zeroCalibrationStartV(&gyroCalibration[1], CALIBRATING_GYRO_TIME_MS, CALIBRATING_GYRO_MORON_THRESHOLD, false);
Evidence
The new secondary calibration uses a two-second window and disallows failure, which makes the
calibration implementation restart whenever noise remains above threshold. Arming checks only the
primary state, and every incomplete secondary update is explicitly replaced with zeros before
Blackbox copies it.

src/main/sensors/gyro.c[383-415]
src/main/sensors/sensors.h[42-46]
src/main/common/calibration.c[153-177]
src/main/fc/fc_core.c[201-218]
src/main/sensors/gyro.c[600-610]
src/main/blackbox/blackbox.c[1669-1674]

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

## Issue description
Secondary calibration may continue retrying after arming, causing every secondary Blackbox sample to remain zero under sustained vibration.
## Fix Focus Areas
- src/main/sensors/gyro.c[383-415]
- src/main/sensors/gyro.c[600-610]
## Recommended Fix
Make secondary calibration terminate after one window by allowing failure, then continue producing aligned and scaled samples with a zero offset when calibration fails instead of restarting forever. Add coverage for noisy secondary input during early arming and verify that primary updates remain unaffected.

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


Grey Divider

Qodo Logo

Comment thread src/main/sensors/gyro.c Outdated
Comment thread src/main/sensors/gyro.c Outdated
Comment thread src/main/sensors/gyro.c Outdated
Three issues raised in review on the original commit:

1. Secondary sensor selection assumed the two IMU positions are always tagged
   0 and 1. They are not: AETH743Basic registers them as 0 and 2, and some
   targets register both with tag 0, where not even gyro_to_use can reach the
   second one. Probe the candidate tags and skip the primary's instead of
   inverting arithmetically, so the feature works on the 0-and-2 layout and
   stays cleanly disabled where there is nothing to find.

2. performGyroCalibration()'s new persist parameter was referenced only inside
   #ifndef USE_IMU_FAKE. SITL defines USE_IMU_FAKE and builds with
   -Wall -Wextra -Werror, so -Wunused-parameter broke that target. Verified by
   building SITL with WARNINGS_AS_ERRORS=ON before and after.

3. The secondary's zero calibration was started with allowFailure = false.
   Nothing gates arming on that sensor, so under sustained vibration the
   calibration restarts forever and every logged sample stays zero for the
   whole flight. Allow it to fail once and then log the sensor with a zero
   offset: a constant bias can be removed in post-processing, a column of
   zeroes cannot.
@MrScothh

MrScothh commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

All three review findings were real. Addressed in the commit above.

1. Secondary sensor selection assumed tags 0 and 1. Confirmed — AETH743Basic/target.c registers its two positions as tags 0 and 2, and BRAHMA_H7/target.c registers both with tag 0, where not even gyro_to_use can reach the second one. The arithmetic inversion probed a tag that does not exist on those boards, so the feature was silently unavailable.

Now the candidate tags are probed, skipping the one the primary claimed, bounded by the highest tag any in-tree target uses. It picks up the 0-and-2 layout and stays cleanly disabled where there is genuinely nothing to find. I deliberately did not go as far as a target-declared slot mapping — that touches every dual-IMU target and felt out of scope for this PR, but say the word if you would rather have it done properly in one go.

As a side note, this also corrected my own reading of gyro_to_use: I had assumed max: 2 was an inherited leftover, since the description documents only 0 and 1. It is not — it is there for exactly the 0-and-2 boards.

2. SITL build break. Confirmed and reproduced, not just reasoned about:

$ cmake -DSITL=ON -DWARNINGS_AS_ERRORS=ON .. && make SITL
src/main/sensors/gyro.c:418:111: error: unused parameter 'persist' [-Werror=unused-parameter]

persist was referenced only inside #ifndef USE_IMU_FAKE. Added UNUSED(persist) in the other branch. Same command now builds SITL.elf clean.

3. Secondary calibration could restart forever. Confirmed by construction. zeroCalibrationAddValueV() restarts the window when the deviation exceeds the threshold and allowFailure is false — the code carries a TODO about exactly that — and nothing gates arming on the secondary, so under sustained vibration gyroRaw2 would stay zero for the whole flight.

Changed to allowFailure = true. On failure the state goes to ZERO_CALIBRATION_FAIL, zeroCalibrationIsCompleteV() returns true, and the normal path logs the sensor with a zero offset. That is the better failure mode for what this field is for: a constant bias is removable in post-processing, a column of zeroes is not.


Build status after the fixes

Target Result
SITL with -DWARNINGS_AS_ERRORS=ON builds clean
TBS_LUCID_H7_WING (dual IMU) 35.95% FLASH1, all three symbols present
AIKONF7 (single IMU) hex byte-for-byte identical to before this commit, zero symbols

The single-IMU control build not changing size at all is the check I care about most, since AIKONF7 sits at 93% flash.

The hardware timing measurement is still outstanding and still the thing that should gate merging.

The movement threshold is a number of raw counts, and raw counts mean different
rotation rates on different parts - a dual-IMU board is free to pair two unrelated
sensors. Passing the constant unchanged asked the secondary for the same number
rather than the same physical stillness, so a more sensitive secondary could fail a
calibration the primary passes.

Converting through both scales asks for the same stillness. Where the two sensors
are the same part the scales cancel and the value is exactly the old constant, so
nothing changes on the boards this has been tested on.

This is the same class of bug as iNavFlight#11905, which fixes it for the primary by moving
the constant into dps. That change and this one both edit gyroStartCalibration();
once it lands, both call sites become the same expression over each sensor's own
scale.
@Raffi1202

Copy link
Copy Markdown
Contributor

@MrScothh Thanks - you are right, and I had missed #11933 entirely. I only checked #11932 because that was the one named, which is exactly the kind of thing that bites later. Verified both of your points:

#11933 calls zeroCalibrationStartV(&gyroCalibration[1], ...) inside gyroStartCalibration() with CALIBRATING_GYRO_MORON_THRESHOLD * gyroDev[0].scale / gyroDev[1].scale, and #11905 deletes that constant in favour of CALIBRATING_GYRO_MORON_THRESHOLD_DPS. Same function, and the constant only exists on one side - so whichever lands second breaks, precisely as you describe. Your relative form is the right call in the meantime: identical parts cancel to the old value, so nothing moves on tested boards.

And your gyroDev[1].scale point is well taken. #11905 deliberately only touches the primary (gyroCalibration[0] with gyroDev[0].scale), so it is self-consistent today, but you are right that the secondary must ask its own sensor - pairing two unrelated parts is allowed, and the primary's scale would be the wrong reference.

Offer, your call since #11933 is yours: I can add a small helper to #11905 so the second call site is a one-liner rather than a merge conflict:

static float gyroMovementThreshold(uint8_t index)
{
    return CALIBRATING_GYRO_MORON_THRESHOLD_DPS / gyroDev[index].scale;
}

Then #11933's secondary becomes gyroMovementThreshold(1) and the primary gyroMovementThreshold(0), each over its own scale, and there is nothing to reconcile. If you would rather keep #11933 standing on its own and sort it out at merge time, that is fine too - say which and I will do it or leave it.

Either way I will note the dependency in #11905's description so whoever merges first sees it coming.

(No hardware or dual-IMU board here, and the firmware CI has not been released for #11905, so none of this is built on my side.)

@sensei-hacker
sensei-hacker changed the base branch from master to maintenance-10.x September 13, 2026 23:02
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown

Test firmware build ready — commit 15aaf4c

Download firmware for PR #11933

249 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.

@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown

RAM / Flash usage vs. base commit 7e82f68 — commit 15aaf4c

Target Flash Δ RAM Δ
MATEKF405 ±0 B (±0.00%) CCM: ±0 B (±0.00%)
RAM: ±0 B (±0.00%)
MATEKF722 ±0 B (±0.00%) ITCM_RAM: ±0 B (±0.00%)
RAM: ±0 B (±0.00%)
TCM: ±0 B (±0.00%)
MATEKF765 +720 B (+0.10%) DTCM_RAM: +136 B (+0.48%)
SRAM1: +80 B (+0.06%)
MATEKH743 +1696 B (+0.22%) D2_RAM: ±0 B (±0.00%)
DTCM_RAM: +116 B (+0.90%)
ITCM_RAM: -224 B (-1.38%)
RAM: -36 B (-0.03%)

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

@MrScothh

MrScothh commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@Raffi1202 Yes please, add the helper — that is the better shape and I would rather converge on it than leave two spellings of the same idea in one function.

One thing I would ask, purely about sequencing. I will keep this PR's self-contained form until #11905 actually lands:

CALIBRATING_GYRO_MORON_THRESHOLD * gyroDev[0].scale / gyroDev[1].scale

not out of reluctance, but because CALIBRATING_GYRO_MORON_THRESHOLD_DPS does not exist on any branch yet. Switching now would leave this PR unbuildable while yours is still open, and it is currently green — 24 checks including all 15 build shards and the four SITL platforms. The moment #11905 merges I change that expression to gyroMovementThreshold(1) and delete the local, which is a one-line edit in a single hunk.

So the reconciliation is: you add gyroMovementThreshold() and use it for the primary, I switch the secondary call site to it afterwards. Neither of us is blocked on the other, and whoever merges second does one small edit rather than resolving a conflict.

Noting the dependency in #11905's description is exactly right — I have just done the same here. While I was at it: #11932 was closed as a duplicate of this PR, so its fix now lives only in this PR's first commit. I have said so in the description, and quoted your "worth merging regardless", because with #11932 gone that one line could quietly disappear if someone dropped the first commit. Thanks for looking at it independently — that is the second time in this exchange that checking the PR that was not named turned out to be the useful thing.

On your closing note, for symmetry: I do have a dual-IMU board (TBS Lucid H7 Wing) and have built this branch on it, but the measurement that would actually justify merging — what the extra SPI transaction costs the gyro task — I have not run yet. So the arithmetic here is verified by construction and by build, not on hardware, same as yours.

Raffi1202 pushed a commit to Raffi1202/inav that referenced this pull request Sep 14, 2026
Asks each gyro for its own scale by index instead of hardcoding the
primary. A dual-IMU board may pair two unrelated parts, so the primary's
sensitivity is the wrong reference for a secondary sensor. Requested on
 iNavFlight#11933, which adds a second calibration call site in this function.
@Raffi1202

Copy link
Copy Markdown
Contributor

@MrScothh Helper is in, 668db009 on #11905:

/* Zero calibration works on raw gyro readings, so the movement threshold has to be
 * expressed in that sensor's LSB. Each gyro is asked for its own scale: a dual-IMU
 * board may pair two unrelated parts, and the primary's sensitivity would be the
 * wrong reference for the secondary. */
static float gyroMovementThreshold(uint8_t index)
{
    return CALIBRATING_GYRO_MORON_THRESHOLD_DPS / gyroDev[index].scale;
}

The primary call site is now gyroMovementThreshold(0). It is static and sits just above gyroStartCalibration() in gyro.c, so your secondary call site reaches it without a header change - gyroMovementThreshold(1) and delete the local, exactly as you described.

Your sequencing is right and I would not have you do it differently: CALIBRATING_GYRO_MORON_THRESHOLD_DPS exists on no branch yet, so switching before #11905 lands would break a PR that is currently green for no gain. Keep the self-contained form; I am not blocked by it.

Good catch on #11932 being closed as a duplicate - I had not seen that. Worth stating plainly for whoever merges this: with #11932 gone, gyroCal->params.state exists only in this PR's first commit, and dropping that commit silently restores a function that ignores its own parameter.

On the hardware point, noted and appreciated - your dual-IMU build is a good deal more than I have. My side is unbuilt upstream (CI still action_required), so I ran this through a build on my fork: Raffi1202#21. I will post the result here when it finishes, green or not.

And agreed on the pattern: twice now the useful thing was the PR nobody named. I have started checking who else touches a function before claiming independence.

@Raffi1202

Copy link
Copy Markdown
Contributor

Build is green: 22 jobs, all 15 target shards and the four SITL platforms, on 668db009 - https://github.com/Raffi1202/inav/actions/runs/34808171085

So gyroMovementThreshold() compiles everywhere #11905 is built. The one red check on that fork run is the Parameter Group Version Check, which fails on every branch that does not carry #11885's fix for it - unrelated to this change and the same on every PR right now.

Scaffold is cleaned up; nothing extra left on the fork.

The comment claimed a field appended to gyroConfig_t keeps the default
pgReset() installed. That is not unconditionally true, and the exception
is invisible: pgLoad() copies MIN(stored, current) bytes over the
defaults, so a new field that lands inside the old struct's tail padding
is still within what an older configuration stored, and comes back as the
zero that padding holds - pgResetInstance() copies the reset template
whole, padding included.

Nothing changes here, because this field defaults to OFF and zero is
therefore the right answer either way. The comment now says that, rather
than stating a rule that happens to hold for this field and would mislead
anyone appending one whose default is not zero.

No functional change. SITL builds clean.
@sensei-hacker

Copy link
Copy Markdown
Member

This looks interesting. Please let us know when you have flown it.

@MrScothh

Copy link
Copy Markdown
Contributor Author

@sensei-hacker thanks - I'll fly it this weekend and report back here with the results.

One conflict, and the two sides wanted the same thing from different ends.
Upstream cached the primary gyro's calibration state in a global to keep a
function call out of the hot path, and kept writing gyroCalibration[0] directly.
This branch had made the same line honour the zeroCalibrationVector_t it was
handed, which is the fix that PR iNavFlight#11932 carried before it was closed as a
duplicate.

Both survive: inside this branch of the test the pointer is gyroCalibration[0],
so the parameterised form is the same write with the secondary case no longer
assumed away, and upstream's cache is set alongside it.
SYNERDUINOH7/CMakeLists.txt is committed upstream with a CRLF ending while
.gitattributes marks it as text, so git reports it modified on every Windows
checkout and `git add -A` carries it along. It has nothing to do with the
second gyro; put the upstream blob back.
MrScothh added a commit to MrScothh/inav that referenced this pull request Sep 17, 2026
A board with two IMUs currently reads one of them. gyro_to_use picks which
physical sensor the single driver opens; the other is never sampled, so there is
nothing to compare, average or vote on. PR iNavFlight#11933 changed the first half of that
by sampling the second gyro as an instrumentation channel and logging it as
gyroRaw2. This is the second half: letting it into the loop.

gyro_fusion = AVERAGE feeds the mean of the two sensors to the filters and the
controller. That is what two gyros buy without a state estimator to weight them:
uncorrelated noise falls by about a third. Betaflight arrived at the same answer
- it sums the enabled gyros and divides by their count - after deprecating its
own gyro_to_use.

What it is not is redundancy, and the setting's description says so. Two sensors
that disagree establish that one of them is wrong and cannot establish which.
PX4, which does run one EKF instance per IMU and arbitrates between them, writes
exactly that in EKF2Selector: with three or more sensors the faulty one is the
one with the largest accumulated error, and with two "a fault is present, but the
faulty sensor identity cannot be determined".

Two guards, because averaging is only safe while both sensors are real:

  - a read that failed leaves zeroes behind, and averaging those would halve the
    rate the controller sees - an attenuation that presents as a tuning problem
    rather than as a broken sensor;
  - a calibration still in progress still has its bias in it.

Either one falls back to the first gyro for that cycle, silently and per-sample.

Asking for fusion now starts the second gyro on its own rather than requiring
gyro_secondary_enabled as well, so the setting cannot be turned on and do
nothing.

In the log, gyroRaw stays the first sensor as measured. With fusion on, the
second sensor follows the existing raw-gyro flag rather than its own: one
aircraft with two IMUs reads as the same field twice. The mean the controller
saw is their average, so nothing is lost.

Draft: flown by nobody yet, and the per-sample fallback is not visible in the
log. See the PR for what remains.
@MrScothh
MrScothh force-pushed the feature/secondary-gyro-logging branch from f6a9d57 to e5a4301 Compare September 17, 2026 07:43
…tead of two

All of this lives in the path that only exists once the second IMU is enabled.

The cached "calibration complete" flag was a single bool shared by both
sensors. The secondary starts its window first and is read first, so it always
reached the end of the window first and raised the flag; the primary then
skipped its own final sample, kept the zero it had been handed while still
calibrating, and left gyroCalibration[0] in progress for good. That is what
gyroIsCalibrationComplete() reports, and what areSensorsCalibrating() blocks
arming on, so a board with the second gyro enabled would never arm. There is
now one flag per sensor. Reproduced in SITL: with the setting off the arming
flags clear after a second, with it on the sensors-calibrating bit used to
stay up for as long as you cared to watch, and now clears in the same second.

The feature also had two switches, gyro_secondary_enabled and a GYRO_2
Blackbox include flag. With the first on and the second off the board
detected, calibrated and read the second sensor every gyro cycle and threw the
numbers away. The include flag is gone and the setting decides both: on a
board where the sensor is not sampled there is nothing to include, and while
it is off the second IMU is not initialised at all.

Finally, the secondary's calibration is asked to succeed rather than allowed
to fail, so a window that ends on a moving aircraft restarts exactly as the
primary's does and the two finish together on a still one. A channel meant to
be compared against the primary should have its zero measured the same way.
Nothing arms on this sensor, so the retry cannot keep the aircraft on the
ground; what it costs is that a model which never sits still logs zeroes
rather than a biased column.
Comment thread src/main/blackbox/blackbox.c Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit a524043

The second IMU feeds nothing but gyroRaw2, yet with gyro_secondary_enabled on
it was read on every gyro cycle from power-up: on the bench, disarmed, with no
log device, after the log had ended. It is now read while it measures its
zero and while the blackbox has a log open. The blackbox says so where it
changes state, so the gyro loop only tests a flag.

The rest is tidying with no change in behaviour. gyroUpdateAndCalibrate() and
performGyroCalibration() take the sensor index alone and find the device and
the calibration from it, instead of taking pointers and an index that had to
agree. The two sensors are named GYRO_PRIMARY and GYRO_SECONDARY rather than
0 and 1, and both start through gyroDevStart(), so their settings are the same
by construction and the two channels in a log compare like for like.

The condition cache assert explains why it needs LAST + 1 bits: a dual-gyro
target now uses the 64th. The setting and docs/Blackbox.md say when the sensor
is read, and that GYRO_RAW is needed alongside to compare the two.
The field condition asked whether a second sensor was running, which today is the
same thing as the setting being on, but need not stay that way: anything else
that starts the second gyro for its own reasons would have put its trace in every
log with no way to leave it out. It now asks for the setting as well.
@MrScothh

Copy link
Copy Markdown
Contributor Author

Fixed, though not with a flag of its own.

The condition asked whether a second sensor was running. Today that is the same thing as gyro_secondary_enabled being on, since nothing else starts one, so the field could not appear against the setting. It does not stay the same thing for long: in #11958, which is open, gyro.secondaryInitialized also becomes true when gyro_fusion is on, and then every log would carry gyroRaw2 whether or not anyone asked for it. So the condition now asks for the setting as well.

A blackbox GYRO_2 include flag would be a second switch for one thing, and the state it adds, second gyro read but not logged, is the state this feature exists to avoid: with the setting on, the sensor is read only while a log is being written. The documentation says which switch governs the field.

Bench unchanged, on SITL with a second gyro that moves only while armed:

case second gyro reads/s, disarmed / armed / after log
gyro_secondary_enabled = OFF 0 / 0 / 0 no gyroRaw2
gyro_secondary_enabled = ON 0 / 96 / 0 gyroRaw2 present, tracks gyroRaw exactly

The calibration threshold helper this branch asked for arrived upstream in the
meantime, so the secondary's threshold comes from gyroMovementThreshold() now
instead of being scaled by hand.
GEPRCF745_BT_HD has two IMUs and under a hundred bytes of ITCM to spare, so this
branch overflowed it by eight. The secondary is read only while it measures its
zero and while a log is being written, which is no reason to spend fast memory,
so its path moves into its own function outside FAST_CODE.
Parameterising the read by gyro index cost the fast section 176 bytes, which on a
GEPRCF745_BT_HD is most of what it has left. The body is now inlined into two
copies: the primary's keeps the constant addresses it had before this branch and
stays in the fast section, the secondary's sits in ordinary flash, where the read
it does while a log is being written is nobody's hurry.
@MrScothh

Copy link
Copy Markdown
Contributor Author

Which milestone would you like this one in?

By what you wrote on #11947 it is a feature, so 10.1, but this pull request has no milestone yet and I would rather ask than assume while you are cutting RC1.

Where it stands: merged with maintenance-10.x this morning, CI green, and everything review raised is addressed. It also costs less fast memory than it did. The secondary gyro is read only while a log is being written, so its path moved out of FAST_CODE, and the primary's read went back to the constant indices it had before this branch. The size report puts ITCM 224 bytes below the base on MATEKH743, and GEPRCF745_BT_HD, which is the tightest dual-IMU target at 98.7 % of its fast section, links with room to spare again.

What is not done is the flight you asked for. I have the test build from this pull request and will report back with a log: what I want to show is gyroRaw2 tracking gyroRaw for a whole flight, no gaps in the sampling, and what the second trace costs in log size.

So if the flight is what decides it, 10.1 is the honest answer. If you would rather have it in 10.0 on the strength of the bench work, say so today and I will fly it as soon as the weather allows either way.

@sensei-hacker sensei-hacker added this to the 10.0 milestone Sep 21, 2026
@sensei-hacker

Copy link
Copy Markdown
Member

I'm going to merge it for this RC1 so it makes the "new feature" deadline, then it can be adjusted as needed during the RC phases before final release.

@sensei-hacker sensei-hacker reopened this Sep 21, 2026
@sensei-hacker
sensei-hacker merged commit d1a87ed into iNavFlight:maintenance-10.x Sep 21, 2026
36 checks passed
Comment on lines +942 to +944
#ifdef USE_DUAL_GYRO
// The second gyro is read only for the log, so only while there is one
gyroSetSecondaryLogging(newState > BLACKBOX_STATE_STOPPED);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Paused logs still read the second gyro 🐞 Bug ➹ Performance

blackboxSetState() enables gyroSecondaryLogging for every state numerically above
BLACKBOX_STATE_STOPPED, rather than only BLACKBOX_STATE_RUNNING. Header generation, paused
logging, and shutdown therefore keep gyroUpdateSecondary() issuing SPI reads even though paused
and shutdown paths write no main frames that could contain gyroRaw2.
Agent Prompt
## Issue description
`gyroSetSecondaryLogging(newState > BLACKBOX_STATE_STOPPED)` enables the secondary IMU while headers are emitted, while Blackbox is paused, and while it is shutting down. Those states do not write main log frames, so the extra gyro transaction cannot produce `gyroRaw2` data and contradicts the intended logging-only sampling behavior.

## Fix Focus Areas
- src/main/blackbox/blackbox.c[942-944]

## Recommended Fix
Enable secondary sampling only when entering `BLACKBOX_STATE_RUNNING`, and disable it for every other state. Preserve the existing calibration behavior: `gyroUpdateSecondary()` already continues reading an incomplete secondary calibration even when logging is disabled.

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

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

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 15aaf4c

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.

3 participants