Skip to content

Fix LED output mode falsely flagging sibling timer channels - #11749

Merged
sensei-hacker merged 3 commits into
iNavFlight:maintenance-10.xfrom
sensei-hacker:fix/led-timer-sibling-pad-flags-10x
Jul 26, 2026
Merged

Fix LED output mode falsely flagging sibling timer channels#11749
sensei-hacker merged 3 commits into
iNavFlight:maintenance-10.xfrom
sensei-hacker:fix/led-timer-sibling-pad-flags-10x

Conversation

@sensei-hacker

@sensei-hacker sensei-hacker commented Jul 26, 2026

Copy link
Copy Markdown
Member

Summary

When a timer's output mode is set to LED, ws2811LedStripInit() only ever wires up the first (canonical) pad on that timer — but every pad sharing the timer was being flagged TIM_USE_LED, so sibling pads were reported as "Led" over MSP and shown that way in the configurator's Mixer/Output Mapping tab, despite being functionally dead. This mirrors the sibling-pad bug already fixed for BEEPER in #(8c16aee)/#(31bb99e), applied to LED.

Changes

  • timerHardwareOverride(): track the first-encountered (canonical) pad per physical timer for OUTPUT_MODE_LED, same pattern as the existing BEEPER canonical-pad tracking. Only that pad gets TIM_USE_LED; sibling pads fall back to TIM_USE_PINIO (GPIO on/off) instead of a false LED flag.
  • pwmAssignOutput()'s MAP_TO_LED_OUTPUT case: removed a pwmClaimTimer() call that was propagating the assigned pad's flags to every sibling on the same physical timer. That propagation is correct for motor/servo (a multi-channel timer legitimately offers multiple motor/servo outputs) but was silently re-overwriting the sibling's correct TIM_USE_PINIO back to TIM_USE_LED immediately after the canonical-pad logic set it correctly. This was the actual root cause of the symptom visible in the configurator — the first fix alone was not sufficient.
  • pinio.c: pinioInit() would happily configure a TIM_USE_PINIO sibling pad as timer-PWM, which reprograms the timer's shared period register — corrupting an LED/BEEPER output living on the same physical timer (BEEPER especially, since nothing re-asserts its period afterward). Added timerHasFixedPeriodSibling() and gated both pinioInitTimerPWM() call sites on it, so such pads correctly fall through to the GPIO-only fallback. Found via code review of the LED fix above, which depends on siblings actually behaving as GPIO-only.
  • Added pwm_mapping_led_unittest.cc (isolated timerHardwareOverride() coverage), pwm_mapping_led_e2e_unittest.cc (full-pipeline coverage reproducing both the bug and the fix, plus a regression guard proving motor/servo timer-claim propagation is untouched), and pinio_timer_sibling_unittest.cc (coverage for the pinio.c guard, including same-timer vs different-timer scoping).
  • Refreshed pwm_mapping_beeper_unittest.cc, which had drifted and was testing a superseded version of the BEEPER logic; rewritten to match current source, with a SourceSync suite that reads the live pwm_mapping.c at test-runtime to catch future drift.

Testing

  • Full unit test suite passes (233/233), including new/rewritten pwm_mapping_led_unittest.cc, pwm_mapping_led_e2e_unittest.cc, pwm_mapping_beeper_unittest.cc, and pinio_timer_sibling_unittest.cc.
  • SITL and hardware target builds (MATEKH743, MATEKF405, SPEDIXF405) rebuilt clean, no regressions.
  • Verified on physical hardware (SPEDIXF405): configured Timer 1 = MOTORS, Timer 4 = LED via CLI/MSP; confirmed via MSP2_INAV_OUTPUT_MAPPING_EXT2 and BOOTLOG tracing that only the canonical pad on Timer 4 carries TIM_USE_LED and the sibling carries TIM_USE_PINIO.
  • Independently confirmed fixed in the INAV Configurator's Mixer/Output Mapping tab against this build: only one of the two Timer 4 pads is now labeled "Led".

Related Issues

N/A

timerHardwareOverride() applied TIM_USE_LED to every pad sharing a timer
set to OUTPUT_MODE_LED, but ws2811LedStripInit() only ever wires up the
first matching pad — all channels of one timer share a single period
register, so only one can actually be the WS2811 output. This caused
sibling pads (e.g. S6 on a timer with S5 as the LED strip) to be
reported as "Led" over MSP and shown as such in the configurator Mixer
tab while being functionally dead. Confirmed on hardware via bootlog:
before the fix, a two-pad shared timer ended with both pads flagged
TIM_USE_LED even though only one is ever driven.

Track which pad is canonical (first-encountered, matching the scan
order ws2811LedStripInit()'s timerGetByUsageFlag() uses) per physical
timer; only that pad gets TIM_USE_LED, mirroring the existing BEEPER
canonical-pad tracking. Siblings fall back to TIM_USE_PINIO (GPIO
on/off), same as BEEPER siblings.

A second, subtler bug had to be fixed for this to actually work:
pwmAssignOutput()'s MAP_TO_LED_OUTPUT case called pwmClaimTimer(),
which propagates the assigned pad's flags to every sibling sharing the
same physical timer. That propagation is correct for motor/servo (a
multi-channel timer should offer multiple outputs) but was silently
re-overwriting the sibling's correct TIM_USE_PINIO back to TIM_USE_LED
immediately after the canonical-pad logic had set it correctly.
Removed the pwmClaimTimer() call from the LED case; motor/servo keep it.
pwm_mapping_beeper_unittest.cc still hand-reproduced an older,
since-superseded timerHardwareOverride() (a simple early-return guard,
no canonical-pad tracking, no PINIO fallback) rather than the version
that actually landed in commits 8c16aee/31bb99ea0b. It kept passing
because it's fully self-contained and only ever exercised its own
stale inline copy, so it had stopped verifying real BEEPER behavior.

Rewritten to mirror the current source, with a SourceSync suite that
reads pwm_mapping.c at test time and asserts the inline reproduction's
key logic tokens still match the live file, so this can't silently
drift again.
@sensei-hacker sensei-hacker added this to the 10.0 milestone Jul 26, 2026
@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Fix LED timer overrides incorrectly flagging sibling pads as LED outputs

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Mark only the first (canonical) pad on an LED-mode timer as TIM_USE_LED.
• Prevent LED assignment from propagating TIM_USE_LED flags to sibling timer pads.
• Add unit + end-to-end tests with SourceSync drift guards for timer override logic.
Diagram

graph TD
  A["pwmBuildTimerOutputList()"] --> B["timerHardwareOverride()"] --> C["Timer pad usageFlags"] --> D["ws2811LedStripInit()"] --> E["timerGetByUsageFlag(TIM_USE_LED)"] --> F["Canonical LED pad"]
  A --> G["pwmAssignOutput()"] --> H["pwmClaimTimer() (propagates flags)"]
  I["Unit tests (LED/BEEPER) + SourceSync"] --> A
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add a non-propagating timer-claim API
  • ➕ Centralizes propagation behavior (motor/servo propagate; LED does not) behind one function.
  • ➕ Reduces risk of future call-site mistakes reintroducing propagation for LED-like modes.
  • ➖ Requires API/ABI churn and updates to all call sites using pwmClaimTimer().
  • ➖ Harder to reason about if multiple claim modes are introduced without clear naming.
2. Make LED override timer-level exclusive (disable siblings entirely)
  • ➕ Eliminates ambiguity by preventing any alternative use of sibling pads on an LED timer.
  • ➕ Simplifies MSP/configurator reporting (no sibling exposed as usable output).
  • ➖ Removes a useful capability: siblings can still act as GPIO-only PINIO.
  • ➖ Potentially breaks existing setups relying on PINIO fallback behavior.

Recommendation: Keep the PR’s approach: canonical-pad tracking in timerHardwareOverride() plus removing pwmClaimTimer() from LED assignment is the minimal, targeted fix that matches how ws2811LedStripInit() selects the LED pad, while preserving motor/servo propagation semantics and keeping sibling pads usable as PINIO.

Files changed (4) +2086 / -218

Bug fix (1) +26 / -5
pwm_mapping.cAdd canonical LED pad tracking and stop LED flag propagation +26/-5

Add canonical LED pad tracking and stop LED flag propagation

• Extends timerHardwareOverride() to accept an isCanonicalLedPad parameter and makes LED-mode timers default siblings to TIM_USE_PINIO, granting TIM_USE_LED only to the first encountered pad per timer. Removes pwmClaimTimer() from the MAP_TO_LED_OUTPUT assignment path so sibling pads are not overwritten back to TIM_USE_LED after the override pass.

src/main/drivers/pwm_mapping.c

Tests (3) +2060 / -213
pwm_mapping_beeper_unittest.ccRewrite BEEPER sibling-pad tests and add SourceSync drift detection +566/-213

Rewrite BEEPER sibling-pad tests and add SourceSync drift detection

• Replaces a stale guard-based test with coverage matching the current canonical-pad + PINIO-fallback behavior for OUTPUT_MODE_BEEPER. Adds mixed-scan regression cases and a SourceSync suite that reads the live pwm_mapping.c to detect future divergence between the inline reproduction and firmware source.

src/test/unit/pwm_mapping_beeper_unittest.cc

pwm_mapping_led_unittest.ccAdd isolated unit tests for LED canonical-pad override behavior +705/-0

Add isolated unit tests for LED canonical-pad override behavior

• Introduces focused tests for timerHardwareOverride() ensuring only the canonical pad on an LED-mode timer gets TIM_USE_LED and siblings fall back to TIM_USE_PINIO. Includes regression coverage for other output modes and SourceSync checks against the live pwm_mapping.c implementation.

src/test/unit/pwm_mapping_led_unittest.cc

pwm_mapping_led_e2e_unittest.ccAdd end-to-end tests reproducing LED sibling overwrite via pwmClaimTimer +789/-0

Add end-to-end tests reproducing LED sibling overwrite via pwmClaimTimer

• Adds a full-pipeline simulation of pwmBuildTimerOutputList()’s two-loop flow to reproduce the real bug where pwmAssignOutput()’s LED case propagated flags to sibling pads. Verifies the fix (no pwmClaimTimer() in LED case) while asserting motor propagation remains intact, plus SourceSync checks to prevent regression/drift.

src/test/unit/pwm_mapping_led_e2e_unittest.cc

@qodo-code-review

qodo-code-review Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

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

Grey Divider


Remediation recommended

1. PINIO reconfigures fixed timers ✓ Resolved 🐞 Bug ≡ Correctness
Description
timerHardwareOverride() now sets TIM_USE_PINIO on non-canonical LED pads, but pinioInit() may
configure them as timer-PWM PINIO and call timerConfigBase() on the shared timer, contradicting the
intended GPIO-only behavior. Because pinioInit() runs before ledStripInit() and after beeperInit(),
this can clobber timer base/period settings used for WS2811/BEEPER and make those outputs
unreliable.
Code

src/main/drivers/pwm_mapping.c[R225-235]

+            // Other channels of this timer share its period register, so they can't
+            // be repurposed as motor/servo/BEEPER either. Only the first (canonical)
+            // pad is the one ws2811LedStripInit() actually wires up; siblings can still
+            // drive a GPIO-only PINIO (binary on/off, no PWM), since that doesn't need
+            // the timer's fixed WS2811-bitrate period.
+            timer->usageFlags &= ~(TIM_USE_MOTOR|TIM_USE_SERVO|TIM_USE_BEEPER|TIM_USE_LED);
+            timer->usageFlags |= TIM_USE_PINIO;
+            if (isCanonicalLedPad) {
+                timer->usageFlags &= ~TIM_USE_PINIO;
+                timer->usageFlags |= TIM_USE_LED;
+            }
Evidence
The PR changes LED override handling to set TIM_USE_PINIO on sibling pads, while pinioInit() will
configure any TIM_USE_PINIO pad using timer PWM setup that reprograms the timer base/period.
fc_init.c shows pinioInit() executes before ledStripInit() and after beeperInit(), so this PWM
configuration can occur at a conflicting point in initialization. ws2811LedStripInit() later
reconfigures the same timer via timerConfigBase(), so timer period ownership is shared and sensitive
to such reconfiguration.

src/main/drivers/pwm_mapping.c[214-236]
src/main/drivers/pinio.c[91-168]
src/main/fc/fc_init.c[320-364]
src/main/fc/fc_init.c[482-619]
src/main/drivers/light_ws2811strip.c[96-138]

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

### Issue description
`timerHardwareOverride()` assigns `TIM_USE_PINIO` to non-canonical pads on an `OUTPUT_MODE_LED` timer, but `pinioInit()` currently treats any `TIM_USE_PINIO` pad as eligible for timer-based PWM init (AF + `timerConfigBase()` + `timerPWMStart()`). On timers that are (or will be) used for fixed-period features like WS2811 LED or BEEPER PWM, this reconfigures the *shared* timer base/period and conflicts with the intended “GPIO-only PINIO” fallback.

### Issue Context
- `OUTPUT_MODE_LED` siblings now get `TIM_USE_PINIO`.
- `pinioInit()` runs before `ledStripInit()` and can run after `beeperInit()`.
- `ws2811LedStripInit()` also calls `timerConfigBase()` later on the LED timer.

### Fix Focus Areas
- src/main/drivers/pinio.c[91-168]
- src/main/drivers/pwm_mapping.c[224-236]

### Suggested fix approach
1. In `pinioInit()` pass-2 (TIM_USE_PINIO pads), detect whether the pad’s *physical timer* is associated with a fixed-period feature (e.g. any sibling pad on the same `tim` has `TIM_USE_LED` or `TIM_USE_BEEPER`).
2. If so, **force GPIO-only** initialization for that PINIO pad (skip `pinioInitTimerPWM()`), so PINIO does not call `timerConfigBase()` on that shared timer.
3. Optionally log/debug-count that a PINIO was forced to GPIO-only due to shared fixed timer usage, to aid troubleshooting.

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



Informational

2. SourceSync path is fragile 🐞 Bug ☼ Reliability
Description
The new SourceSync unit tests compute the on-disk path to src/main/drivers/pwm_mapping.c from
__FILE__ and a fixed relative layout, then hard-fail if the file cannot be opened. If a build setup
rewrites __FILE__ (e.g., path remapping) or runs unit tests without a full source tree present,
these tests will fail even when behavior is correct.
Code

src/test/unit/pwm_mapping_beeper_unittest.cc[R611-637]

+// Locates the live pwm_mapping.c relative to this test file's own path.
+// __FILE__ is emitted as an absolute path by this project's CMake/Makefiles
+// (verified: `c++ ... -c /abs/path/to/pwm_mapping_beeper_unittest.cc`), so
+// this resolves correctly regardless of the build directory or CWD the test
+// is invoked from.
+std::string pwmMappingSourcePath()
+{
+    std::string thisFile = __FILE__; // .../src/test/unit/pwm_mapping_beeper_unittest.cc
+    size_t lastSlash = thisFile.find_last_of("/\\");
+    std::string testUnitDir = (lastSlash == std::string::npos) ? "." : thisFile.substr(0, lastSlash);
+    return testUnitDir + "/../../main/drivers/pwm_mapping.c";
+}

-    timerHardwareOverride_fixed(&timer, OUTPUT_MODE_LED);
-    EXPECT_EQ(timer.usageFlags, originalFlags);
+// Reads and normalizes pwm_mapping.c. Fails the calling test LOUDLY (not
+// silently returning empty / skipping) if the file can't be found, since a
+// missing file here means this drift-detector isn't detecting anything.
+::testing::AssertionResult loadNormalizedPwmMappingSource(std::string *out)
+{
+    const std::string path = pwmMappingSourcePath();
+    std::ifstream file(path);
+    if (!file.is_open()) {
+        return ::testing::AssertionFailure()
+            << "Could not open live source file at computed path: " << path
+            << " -- SourceSync tests cannot verify anything against dead/absent "
+               "source. Check that src/test/unit/ and src/main/drivers/ still "
+               "have their expected relative layout.";
+    }
Evidence
The SourceSync suite explicitly derives the pwm_mapping.c path from __FILE__ and fails when the
file can’t be opened, making test success dependent on build/path behavior rather than only logic
correctness.

src/test/unit/pwm_mapping_beeper_unittest.cc[611-646]

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

### Issue description
SourceSync tests derive the live source path using `__FILE__` and `../../main/drivers/pwm_mapping.c`, and they fail the test if the file is missing/unopenable. This is intentionally strict, but it can produce false negatives under build configurations that don’t preserve a usable `__FILE__` path or when tests run without the source checkout.

### Issue Context
The implementation assumes `__FILE__` is emitted with a directory component that matches the source tree, and assumes the source tree exists at runtime.

### Fix Focus Areas
- src/test/unit/pwm_mapping_beeper_unittest.cc[611-646]

### Suggested fix approach
- Pass the repository source root into unit tests via a compile definition from CMake (e.g., `-DINAV_SOURCE_DIR="${CMAKE_SOURCE_DIR}"`) and build paths from that instead of `__FILE__`.
- Alternatively, if the source file cannot be located, use `GTEST_SKIP()` to avoid failing CI runs that execute tests from a packaged/relocated environment.

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


Grey Divider

Qodo Logo

Comment thread src/main/drivers/pwm_mapping.c
Comment on lines +611 to +637
// Locates the live pwm_mapping.c relative to this test file's own path.
// __FILE__ is emitted as an absolute path by this project's CMake/Makefiles
// (verified: `c++ ... -c /abs/path/to/pwm_mapping_beeper_unittest.cc`), so
// this resolves correctly regardless of the build directory or CWD the test
// is invoked from.
std::string pwmMappingSourcePath()
{
std::string thisFile = __FILE__; // .../src/test/unit/pwm_mapping_beeper_unittest.cc
size_t lastSlash = thisFile.find_last_of("/\\");
std::string testUnitDir = (lastSlash == std::string::npos) ? "." : thisFile.substr(0, lastSlash);
return testUnitDir + "/../../main/drivers/pwm_mapping.c";
}

timerHardwareOverride_fixed(&timer, OUTPUT_MODE_LED);
EXPECT_EQ(timer.usageFlags, originalFlags);
// Reads and normalizes pwm_mapping.c. Fails the calling test LOUDLY (not
// silently returning empty / skipping) if the file can't be found, since a
// missing file here means this drift-detector isn't detecting anything.
::testing::AssertionResult loadNormalizedPwmMappingSource(std::string *out)
{
const std::string path = pwmMappingSourcePath();
std::ifstream file(path);
if (!file.is_open()) {
return ::testing::AssertionFailure()
<< "Could not open live source file at computed path: " << path
<< " -- SourceSync tests cannot verify anything against dead/absent "
"source. Check that src/test/unit/ and src/main/drivers/ still "
"have their expected relative layout.";
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Informational

2. Sourcesync path is fragile 🐞 Bug ☼ Reliability

The new SourceSync unit tests compute the on-disk path to src/main/drivers/pwm_mapping.c from
__FILE__ and a fixed relative layout, then hard-fail if the file cannot be opened. If a build setup
rewrites __FILE__ (e.g., path remapping) or runs unit tests without a full source tree present,
these tests will fail even when behavior is correct.
Agent Prompt
### Issue description
SourceSync tests derive the live source path using `__FILE__` and `../../main/drivers/pwm_mapping.c`, and they fail the test if the file is missing/unopenable. This is intentionally strict, but it can produce false negatives under build configurations that don’t preserve a usable `__FILE__` path or when tests run without the source checkout.

### Issue Context
The implementation assumes `__FILE__` is emitted with a directory component that matches the source tree, and assumes the source tree exists at runtime.

### Fix Focus Areas
- src/test/unit/pwm_mapping_beeper_unittest.cc[611-646]

### Suggested fix approach
- Pass the repository source root into unit tests via a compile definition from CMake (e.g., `-DINAV_SOURCE_DIR="${CMAKE_SOURCE_DIR}"`) and build paths from that instead of `__FILE__`.
- Alternatively, if the source file cannot be located, use `GTEST_SKIP()` to avoid failing CI runs that execute tests from a packaged/relocated environment.

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

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

Test firmware build ready — commit 1475a6c

Download firmware for PR #11749

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

pinioInit() would happily configure a TIM_USE_PINIO-flagged pad as
timer-PWM via pinioInitTimerPWM(), which calls timerConfigBase() to set
the timer's period/prescaler. That register is shared by every channel
on the physical timer, so if the pad's sibling channel is dedicated to
a WS2811 LED strip or BEEPER tone, this silently reprograms the shared
period. beeperInit() runs before pinioInit() with nothing to re-assert
it afterward, so a BEEPER sibling getting PWM-configured as PINIO can
permanently corrupt the tone frequency.

Add timerHasFixedPeriodSibling() and gate both pinioInitTimerPWM() call
sites in pinioInit() on it, so any pad sharing a timer with an LED or
BEEPER output falls through to the existing GPIO-only fallback instead.

Found via code review of the LED sibling-pad-flags fix in this branch,
which relies on siblings correctly falling back to GPIO-only PINIO.
@sensei-hacker
sensei-hacker merged commit bad0c53 into iNavFlight:maintenance-10.x Jul 26, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant