Skip to content

service cleanup after removing return FRAMETIME#5443

Open
softhack007 wants to merge 14 commits intomainfrom
service_cleanup
Open

service cleanup after removing return FRAMETIME#5443
softhack007 wants to merge 14 commits intomainfrom
service_cleanup

Conversation

@softhack007
Copy link
Member

@softhack007 softhack007 commented Mar 24, 2026

Clean-up and simplification of strip.service() after individual effect FRAMETIME was removed.

  • Activation "_frametime" is now the same for all effects, so effects scheduling based on individual frametime is not needed any more
  • Special handling for mode_static is obsolete, because the solid effect does not have a different timing any more

Summary by CodeRabbit

  • Refactor
    • Unified frame/timing so effects refresh on a single global schedule for more consistent visuals.
    • Only active segments now determine when a frame is shown, reducing irregular timing.
    • Triggered updates and unlimited‑FPS still force immediate refreshes for snappier responses.
    • Early timing gate skips work when no update is due, lowering CPU use.
    • Ensures a safe fallback segment after servicing to avoid stale state.
    • Brightness changes use a capped refresh threshold to avoid excessive redraws.

* Activation "_frametime"  is now the same for all effects, so conditionally running effects based on their own frametime is obsolete
* Special handling for mode_static is also obsolete
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 5a7699f7-4bf0-45ab-b274-cfb445d143b8

📥 Commits

Reviewing files that changed from the base of the PR and between 7a12e1d and 9d5c35e.

📒 Files selected for processing (1)
  • wled00/FX_fcn.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • wled00/FX_fcn.cpp

Walkthrough

Compute a single timeToShow per WS2812FX::service() call (from frame-timing, _triggered, or unlimited FPS), set now = nowUp + timebase earlier, early-return when no render is needed, skip inactive segments, and call show() only if any active segment ran and not suspended.

Changes

Cohort / File(s) Summary
Service timing & render gating
wled00/FX_fcn.cpp
Reworked WS2812FX::service() to compute one timeToShow, set now earlier, return early if no render, initialize doShow from _triggered, skip inactive segments, remove previous per-segment static-mode gating, call show() after the loop only when doShow && !_suspend. Adjusted WS2812FX::setBrightness() immediate-redraw threshold to min(_frametime, FRAMETIME_FIXED).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • willmmiles
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly refers to the main cleanup objective: simplifying WS2812FX::service() after removing per-effect FRAMETIME returns.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch service_cleanup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@softhack007 softhack007 requested a review from DedeHai March 24, 2026 11:15
@softhack007 softhack007 marked this pull request as draft March 24, 2026 11:15
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wled00/FX_fcn.cpp`:
- Around line 1271-1272: Replace the addition-based strict comparison in the
timeToShow assignment with a subtraction-based elapsed-time check consistent
with the pattern used on line 1275: compute elapsed = nowUp - _lastServiceShow
and set timeToShow = (elapsed >= _frametime) || _triggered (ensuring the
equality case triggers a frame); also correct the comment text "all segment are"
to "all segments are". Use the existing variables nowUp, _lastServiceShow,
_frametime, _triggered and keep the same behavior for the trigger override.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a1177f23-c73b-4570-b120-ee2a08db2a64

📥 Commits

Reviewing files that changed from the base of the PR and between 710f897 and 7f8c65e.

📒 Files selected for processing (1)
  • wled00/FX_fcn.cpp

preserve previous behaviour: strip.now must also be updsted in case of "early return"
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wled00/FX_fcn.cpp`:
- Line 1277: The current loop sets doShow and unconditionally calls
_mode[seg.mode]() for every active segment (seg), causing static/frozen segments
to recompute and retransmit identical frames; change the loop to first determine
per-segment whether it truly needs rendering by adding a fast-path flag (e.g.,
needsRender) that is computed from segment state (frozen/static,
lastRenderedFrame/timestamp, or a dirty bit) and only call _mode[seg.mode]()
when needsRender is true, while keeping doShow/needsShow separate so segments
that require only a show() refresh (but no recompute) are handled without
invoking the effect callback; update any uses around doShow and the show() call
accordingly so unchanged segments skip expensive work.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8c74a840-e5e1-4960-a2df-392039373238

📥 Commits

Reviewing files that changed from the base of the PR and between 7f8c65e and 933c025.

📒 Files selected for processing (1)
  • wled00/FX_fcn.cpp

@softhack007 softhack007 added this to the 16.1 milestone Mar 24, 2026
invert the logic - instead of "if (!isActive) continue", make the logic explicit by embedding the rest of the loop body into "if (isActive)".

Moved the final "_segment_index++;" into the conditional body, to preserve previous behavior. Warning: the previous behavior might be buggy.
previously "inactive" segment were skipped, leading to SegmentID becoming misaligned with the actual list of segments.
compiler for 8266 complained about "comparing singed to unsigned"
@softhack007 softhack007 marked this pull request as ready for review March 24, 2026 14:27
coderabbitai[bot]

This comment was marked as resolved.

@softhack007
Copy link
Member Author

softhack007 commented Mar 24, 2026

@DedeHai @willmmiles @netmindz I'm happy now with the cleaned-up result, but not sure if this should go into 16.0 or into 16.1. The service() function is central for effects, and its still kind of a hack in how it adresses corner cases.

Maybe better to keep the code as-is in 16.0 (bugfixes already backported to main) and wait until 16.1 with the full cleanup 🤔 or maybe by "quality manager mindset" is too cautious? What do you think?

@softhack007 softhack007 added Awaiting testing optimization re-working an existing feature to be faster, or use less memory labels Mar 24, 2026
fixes a corner case: if the segments list only has inactive segments, we should still run strip.show() if requested by strip.trigger().
@DedeHai
Copy link
Collaborator

DedeHai commented Mar 24, 2026

IMHO 16.0 will be a leap forward from 0.15, better pack whatever changes we have into the beta and get it tested instead of waiting and breaking things again in 16.1

@softhack007 softhack007 removed this from the 16.1 milestone Mar 24, 2026
@softhack007 softhack007 added this to the 16.0.0 beta milestone Mar 24, 2026
@DedeHai
Copy link
Collaborator

DedeHai commented Mar 25, 2026

@coderabbitai please do a thorough check if there are any bugs or missing special case handling in service(). Also compile a list of tests to be performed on real hardware to check if all cases are handled properly.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 25, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me read the current state of the service() function to do a thorough analysis.


🏁 Script executed:

#!/bin/bash
# Read the full service() function and surrounding context
rg -n "WS2812FX::service" --type cpp -A 150

Length of output: 23329


Good, I have the full function. Let me also check some key definitions and the git diff to compare old vs new behavior.


🏁 Script executed:

#!/bin/bash
# Check MIN_FRAME_DELAY and FPS_UNLIMITED definitions
rg -n "MIN_FRAME_DELAY|FPS_UNLIMITED|_frametime|_lastServiceShow|_triggered|_isServicing" --type cpp --type h -l | head -20

echo "--- MIN_FRAME_DELAY definition ---"
rg -n "MIN_FRAME_DELAY" --type h --type cpp

echo "--- FPS_UNLIMITED definition ---"
rg -n "FPS_UNLIMITED" --type h --type cpp

echo "--- _segment_index reset context ---"
rg -n "_segment_index" --type cpp -C2

echo "--- _currentSegment reset after service ---"
rg -n "_currentSegment" --type cpp -A2 -B2

echo "--- handleTransition and resetIfRequired in old loop (git diff for context) ---"
git diff HEAD~5..HEAD -- wled00/FX_fcn.cpp 2>/dev/null | head -200

Length of output: 7475


Let me check a couple more things — the trigger() implementation and how _isServicing is used as a guard.


🏁 Script executed:

#!/bin/bash
echo "=== trigger() implementation ==="
ast-grep --pattern 'void WS2812FX::trigger() {
  $$$
}'

echo "=== inline trigger in header ==="
rg -n "trigger\(\)" --type h --type cpp -A3 -B1 | head -60

echo "=== _isServicing usage as guard ==="
rg -n "_isServicing|isServicing" --type cpp --type h -A2 -B2 | head -80

echo "=== handleTransition in old code check (was it outside isActive in pre-PR code?) ==="
# The diff already showed it was outside. Confirm resetIfRequired too
# Check if there's any guard on resetIfRequired for inactive segs
rg -n "resetIfRequired\|handleTransition" --type cpp -A1 -B1 | head -40

echo "=== FPS_UNLIMITED value and _frametime in unlimited mode ==="
rg -n "FPS_UNLIMITED\|_frametime\s*=" --type cpp --type h | head -30

echo "=== _triggered set locations (where trigger() sets it) ==="
rg -n "_triggered\s*=" --type cpp --type h -B1 -A1 | head -40

echo "=== _lastServiceShow init ==="
rg -n "_lastServiceShow" --type cpp --type h -A1 -B1 | head -30

Length of output: 8434


@DedeHai Here is the thorough analysis of service() after this PR's changes, plus a hardware test checklist.


Code Analysis: WS2812FX::service() — Bug & Special-Case Review

✅ What the diff actually changes (confirmed from git diff)

Old New
bool doShow = false bool doShow = _triggered
_segment_index = 0 before loop; _segment_index++ only for active segs _segment_index = i for every segment; reset to 0 after loop
Range-based for (Segment &seg : _segments) Index-based for (size_t i = 0; ...)
if (!seg.isActive()) continue; skipped inactive if (seg.isActive()) { ... } wraps active work

handleTransition() and resetIfRequired() were already outside the isActive() guard in the old code — this is not a new behaviour change.


🐛 Findings

1. doShow = _triggered — Correct, intentional, no bug

With _triggered = true and all segments inactive, show() is now called even with no active segments processed. Per the learning already stored, this is architecturally correct: frozen segments' externally-written pixel buffers must reach the hardware via show(), and strip.trigger() is the flushing mechanism for those.

2. _segment_index bug fix confirmed ✅

In the old code, _segment_index was incremented only for active segments (it lived below the continue in the !isActive() branch). So with segment 0 inactive and segment 1 active, getCurrSegmentId() inside effects for segment 1 would wrongly return 0. The new code fixes this by assigning _segment_index = i at the top of every iteration.

3. FX_MODE_STATIC explicit trigger() in json.cpp — Still present, still valid

json.cpp:254 retains:

if (seg.mode == FX_MODE_STATIC) strip.trigger(); //instant refresh

Since service() now renders static segments uniformly at the standard frametime, this trigger() is still useful — it provides an instant response to color changes via the JSON API without waiting for the next frame. Not a regression.

4. MIN_FRAME_DELAY blocks trigger() and FPS_UNLIMITED — Intentional, pre-existing

MIN_FRAME_DELAY is platform-dependent:

  • ESP32: 2 ms → max effective trigger rate ≈ 500 Hz
  • S2/C3: 3 ms → max ≈ 333 Hz
  • ESP8266: 8 ms → max ≈ 125 Hz

The guard at line 1275 (elapsed <= MIN_FRAME_DELAY) applies even when _triggered=true or _targetFps==FPS_UNLIMITED. This is intentional ("keep wifi alive — no matter if triggered or unlimited"). However, a trigger() call is not lost when blocked — _triggered is only cleared at line 1332, so the show fires on the very next eligible service call.

5. Suspend mid-loop handling — Correct ✅

If _suspend is set mid-loop:

  • break exits the loop (line 1282)
  • if (doShow && !_suspend) prevents show() (line 1322)
  • if (!_suspend) _triggered = false preserves _triggered (line 1332)
  • _lastServiceShow is not updated → on resume, elapsed will be large → first post-resume service immediately fires

This means effects that use strip.now for timing may experience a time jump after a long suspend. This is inherent to the design, not introduced by this PR.

6. Minor: Dead comment on line 1291

//if (timeToShow) // removed - if we arrive here, its always showtime

This is a leftover from an earlier refactor stage. It accurately explains why the condition was removed but could be cleaned up.

7. _segment_index type vs. loop counter type — Safe but worth noting

_segment_index is uint8_t (FX.h:1047), while the loop uses size_t i. The assignment _segment_index = i would truncate if _segments.size() > 255, but MAX_NUM_SEGMENTS ≤ 255 so this is safe in practice.

8. _currentSegment not reset to nullptr after the loop — Pre-existing

After service(), _currentSegment points to the last segO (old segment during transition) or the last active segment. This is pre-existing and not a regression, but if SEGMENT/SEGENV macros were ever accessed outside of isServicing(), it would be a stale pointer.


🧪 Hardware Test List

Timing & FPS

  1. Default FPS — Normal multi-segment operation; verify frame rate matches configured target (e.g. 42 FPS)
  2. FPS_UNLIMITED mode — Set target FPS to unlimited; verify no crashes, measure actual achieved FPS vs. MIN_FRAME_DELAY floor
  3. Very low FPS (1 FPS) — Confirm effects still update at 1 FPS without stutter
  4. FPS change at runtime — Change FPS while effects are running; verify no glitch or freeze

strip.trigger() paths

  1. JSON color change on FX_MODE_STATIC — Change color via REST API; verify LED updates immediately (via json.cpp:254 trigger path)
  2. JSON segment update (non-static effect) — Change effect parameters via JSON; verify strip.trigger() at json.cpp:358 causes immediate refresh
  3. Brightness change via led.cpp — Adjust master brightness; verify strip.trigger() fires and LEDs update promptly
  4. Rapid back-to-back trigger() calls — Call trigger faster than MIN_FRAME_DELAY; confirm no shows are dropped (last trigger must always result in a show), and WiFi stays alive

Frozen segments

  1. Frozen segment + strip.trigger() — Freeze a segment, write pixels via single-pixel JSON API, call trigger; confirm LEDs update (validates doShow = _triggered path with frozen/inactive segment)
  2. Frozen segment + NO trigger — Freeze a segment; confirm pixel content persists and does not flicker or go dark

Suspend / Resume

  1. Suspend during active effectsstrip.suspend() while effects are running; verify LEDs hold last frame and no corruption
  2. Resume after suspend — Resume after a multi-second suspend; verify effects restart cleanly without flash/jump (tests _lastServiceShow not being updated during suspend)
  3. Rapid suspend/resume cycles — Toggle suspend rapidly; verify no _isServicing deadlock (the 2×frametime+100ms wait in suspend() should never timeout under normal load)

Segment index correctness (_segment_index bug fix)

  1. Mixed active/inactive segments — Create e.g. 4 segments, deactivate segment 0 and segment 2, run effects on 1 and 3; verify getCurrSegmentId() returns correct index (1 and 3) inside those effects — this is the key regression test for the _segment_index fix
  2. All segments inactive — Deactivate all segments; confirm no crash and show() is only called when triggered

Realtime / UDP

  1. E1.31 / DDP / TPM2.NET — Send realtime data via UDP; udp.cpp uses strip.trigger() when useMainSegmentOnly=true — verify LEDs update correctly and not stale
  2. Realtime + useMainSegmentOnly=false — The strip.show() direct call path in udp.cpp:452; verify this still bypasses service() timing correctly

Transitions

  1. Effect transition (fade) — Switch effects mid-run; verify blend transition completes cleanly with old/new segment running in parallel
  2. On/Off segment transitions — Toggle segment on flag with various blend styles; verify no flash or incorrect blending
  3. Color/CCT transitions — Apply color and CCT changes while effect is running; verify smooth transition

Platform-specific

  1. ESP8266 — Full regression test; MIN_FRAME_DELAY=8ms means trigger-response latency is more noticeable — test with fast JSON updates
  2. ESP32 multi-core — While less of an issue (WLED runs service on one core), stress-test with Wi-Fi heavy load to verify MIN_FRAME_DELAY guard keeps networking alive

Edge cases

  1. Single segment, single pixel — Verify no off-by-one in segment geometry with degenerate 1-pixel segment
  2. Max number of segments — Configure maximum allowed segments simultaneously; verify _segment_index (uint8_t) doesn't truncate and all effects render
  3. handleRandomPalette() with no active segments + trigger — Verify it's called (line 1324 inside doShow block) and does not crash with zero active segments

🧠 Learnings used
Learnt from: softhack007
Repo: wled/WLED PR: 5443
File: wled00/FX_fcn.cpp:1277-1277
Timestamp: 2026-03-24T12:10:29.964Z
Learning: In WLED's `WS2812FX::service()` (wled00/FX_fcn.cpp), the old condition `|| (doShow && seg.mode == FX_MODE_STATIC)` was an **inclusion** guard — it caused FX_MODE_STATIC to render only when another segment had already set doShow=true. It did NOT skip or protect FX_MODE_STATIC from rendering. The PR `#5443` simplification removes this condition, meaning FX_MODE_STATIC now renders on every `timeToShow` tick uniformly. This is intentional and not a regression. Do not flag FX_MODE_STATIC special-casing as missing in future reviews of this function.

Learnt from: softhack007
Repo: wled/WLED PR: 5443
File: wled00/FX_fcn.cpp:1277-1277
Timestamp: 2026-03-24T12:13:17.908Z
Learning: In WLED's `WS2812FX::service()` (wled00/FX_fcn.cpp), `seg.freeze` means "do not run the effect function (_mode[seg.mode]())" — it does NOT mean "skip show()". A frozen segment's pixel buffer can still be updated externally (e.g., realtime control or single-pixel JSON API). `strip.trigger()` is the primary mechanism to flush those externally written pixels to the LEDs on the next service tick. Therefore, frozen segments must remain part of the `doShow`/`show()` path, and it is architecturally wrong to exclude frozen segments from `doShow`. Do not suggest skipping frozen segments from the show path in future reviews.

Learnt from: DedeHai
Repo: wled/WLED PR: 4798
File: wled00/FX.cpp:7531-7533
Timestamp: 2025-08-26T11:51:21.817Z
Learning: In WLED PR `#4798`, DedeHai confirmed that certain gamma-related calls in FX.cpp/FX_fcn.cpp/particle systems are intentional for effect-level shaping (e.g., brightness curves, TV sim, Pride 2015 pre-mix), distinct from final output gamma. Do not flag or remove these in future reviews; add comments when feasible to clarify intent.

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2026-03-15T13:21:32.008Z
Learning: In WLED code reviews (and generally), when a PR discussion involves "is this worth doing?" uncertainty about a proposed reliability, safety, or data-integrity mechanism (e.g. CRC checks, backups, power-loss protection), suggest performing a software FMEA (Failure Mode and Effects Analysis). The FMEA should: enumerate failure modes for the feared event, assess each mitigation's effectiveness per failure mode, note common-cause/common-mode failures, and rate credibility for the typical WLED use case. This was successfully used in PR `#5421` to evaluate CRC-based config file protection and was well-received by the WLED maintainers (softhack007 explicitly requested this be remembered for future reviews).

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2025-11-14T13:37:11.994Z
Learning: In WLED code reviews, when code is modified or added, look for "magic numbers" (hardcoded numeric literals) and suggest replacing them with appropriate defined constants when those constants are meaningful in the context of the PR. For example, the hardcoded value 32 should be replaced with WLED_MAX_SEGNAME_LEN when it represents a segment name length limit. This improves code maintainability and reduces the risk of inconsistencies.

Learnt from: BobLoeffler68
Repo: wled/WLED PR: 5293
File: usermods/user_fx/user_fx.cpp:167-171
Timestamp: 2026-01-13T03:07:20.560Z
Learning: In WLED effects (usermods/user_fx/user_fx.cpp and similar), when storing future timestamps, assignments like `stored_time = now + delay` are correct and handle uint32_t wraparound properly. Only comparison statements need to use subtraction-based elapsed-time checks (e.g., `(now - stored_time) >= delay`). Do not flag assignment statements as needing fixes for wraparound handling.

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2025-12-28T14:06:48.772Z
Learning: In WLED post-commit ee9ac947, the rendering pipeline uses per-segment buffers and per-pixel bus updates. Unmapped (0xFFFF) mapping entries are now skipped in WS2812FX::show() (no “clear to black”), which can leave physical gap LEDs with stale/random colors unless they are explicitly cleared. This is a behavior change from pre-0.16 where a full physical buffer was effectively refreshed each frame.

Learnt from: BobLoeffler68
Repo: wled/WLED PR: 5440
File: usermods/user_fx/user_fx.cpp:1307-1327
Timestamp: 2026-03-24T07:59:25.586Z
Learning: In WLED's Dissolve Plus effect (usermods/user_fx/user_fx.cpp, mode_dissolveplus), when the "Last one" checkbox (check3/lastOneMode) is turned off while the effect is in PHASE_PAUSE_SURVIVOR, the phase is intentionally NOT immediately reset. The pause expires naturally via the normal delay timing (a few hundred ticks), after which the filling phase begins. PHASE_DISSOLVE_SURVIVOR is reset to PHASE_DISSOLVE and PHASE_FILL_SURVIVOR is reset to PHASE_FILL on toggle-off, but PHASE_PAUSE_SURVIVOR is left to expire on its own. Do not flag this as a bug in future reviews.

@DedeHai
Copy link
Collaborator

DedeHai commented Mar 25, 2026

I ran most of the tests suggested by the rabbit, except:

  • API calls
  • explicit check of "suspend"
  • CCT transitions (doubt there is anything noteworthy)
  • the last two points, i.e. platform specifics and edge cases.

I have found one bug, but that is probably not related to this PR:
to reproduce: set transition to 5s, choose any transition type except fade, fairy dust for example. power leds off and while in transition power on again, then off again before the transition completes -> now the on off transition is broken and wont do fairy dust.

other than that, all tests passed, including live streaming (used the video tool over WS at 2FPS starget -> still runs at 50FPS).
I found some odd things we may want to look at (or just leave it be, they are edge cases): when setting FPS very low, like 1 or 2 fps, brightness change still causes an FX speedup (due to trigger probably). When changing the color, it respects the FPS but there is a weird "double fps" just before a frame update (tested with akemi on palette 0)

@softhack007
Copy link
Member Author

softhack007 commented Mar 25, 2026

Thanks for the review and tests 🥇

brightness change still causes an FX speedup (due to trigger probably). When changing the color, it respects the FPS but there is a weird "double fps" just before a frame update (tested with akemi on palette 0)

Looks like both FPS jumps are due to brightness handling, and especially it comes from "responsiveness improvement" hacks from the times when the SOLID effect was running at 3fps fixed, causing brightness/color changes to stutter

WLED/wled00/FX_fcn.cpp

Lines 1755 to 1758 in d8cb20a

if (!direct) {
unsigned long t = millis();
if (t - _lastShow > MIN_SHOW_DELAY) trigger(); //apply brightness change immediately if no refresh soon
}

MIN_SHOW_DELAY is 3ms, so effectively the strip would jump into "unlimited FPS" mode whenever brightness changes.

--> solution: MIN_SHOW_DELAY --> min(FRAMETIME, uint16_t(FRAMETIME_FIXED)) will still improve responsiveness, without making all effects run wild

WLED/wled00/led.cpp

Lines 76 to 82 in d8cb20a

//applies global brightness and sets it as the "current" brightness (no transition)
void applyFinalBri() {
briOld = bri;
briT = bri;
applyBri();
strip.trigger(); // force one last update
}

--> solution: this is ancient legacy code, don't touch 😉

@coderabbitai I intend to implement the first solution - please check if this would cause new side-effects on other features like transitions, blending, cross-fading, and palette transitions.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 25, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== FX_fcn.cpp setBrightness / MIN_SHOW_DELAY area ==="
rg -n "MIN_SHOW_DELAY|setBrightness|_frametime|FRAMETIME_FIXED|FRAMETIME\b" --type cpp --type h | head -80

echo ""
echo "=== FX_fcn.cpp around line 1750-1780 ==="
awk 'NR>=1740 && NR<=1790' wled00/FX_fcn.cpp

echo ""
echo "=== led.cpp:70-90 ==="
awk 'NR>=65 && NR<=95' wled00/led.cpp

echo ""
echo "=== MIN_SHOW_DELAY definition ==="
rg -rn "MIN_SHOW_DELAY" --type h --type cpp -A1 -B1

echo ""
echo "=== FRAMETIME_FIXED definition ==="
rg -rn "FRAMETIME_FIXED\|FRAMETIME\b" --type h -A1 -B1 | head -40

echo ""
echo "=== How _frametime is used in transitions ==="
rg -n "_frametime" --type cpp -C3 | head -120

echo ""
echo "=== handleTransition and _transitionDur usage ==="
ast-grep --pattern 'void WS2812FX::handleTransition($$$) {
  $$$
}' 2>/dev/null | head -100

rg -n "handleTransition\|_transitionDur\|_modeBlend\|_blendSpeed" --type cpp -A2 -B2 | head -80

echo ""
echo "=== palette transition / _paletteFade / loadCustomPalette ==="
rg -n "_paletteFade\|paletteFade\|paletteBlend\|loadPalette\|_targetPalette\|palTransition" --type cpp -A2 -B2 | head -80

echo ""
echo "=== full setBrightness function ==="
ast-grep --pattern 'void WS2812FX::setBrightness($_, $_) {
  $$$
}'

Length of output: 13717


min(FRAMETIME, uint16_t(FRAMETIME_FIXED))"

The issue is: MIN_SHOW_DELAY is too small (3ms for high FPS, 8-15ms for others). When brightness changes, t - _lastShow > MIN_SHOW_DELAY is almost always true (the last show was at least one frame ago), so trigger() is always called, which forces an immediate show → the effect runs at "unlimited" speed for that frame, disrupting the configured low FPS cadence.

So the proposed fix increases the threshold from MIN_SHOW_DELAY (3-15ms) to min(FRAMETIME, FRAMETIME_FIXED). This means:

  • At 1 FPS: min(1000, 23) = 23ms → trigger() only fires if last show was > 23ms ago. Since at 1 FPS, the interval is ~1000ms, the last show WAS >23ms ago, so trigger STILL fires...

Wait, that can't be right. Let me re-read more carefully.

Oh! I think I misread MIN_SHOW_DELAY. Let me look again:

`#define` MIN_SHOW_DELAY   (_frametime < 16 ? 8 : 15)

So: if _frametime < 16ms (FPS > ~62), MIN_SHOW_DELAY = 8ms. Otherwise (FPS ≤ 62), MIN_SHOW_DELAY = 15ms.

This seems reasonable. At 1 FPS (_frametime = 1000ms), MIN_SHOW_DELAY = 15ms. If last show was > 15ms ago, trigger(). At 1 FPS, the last show is always > 15ms ago (it's 1000ms ago), so trigger always fires on brightness change. This immediately causes one "fast" frame.

With the fix min(FRAMETIME, FRAMETIME_FIXED):

  • At 1 FPS: min(1000ms, 23ms) = 23ms → threshold is 23ms → still fires since last show is 1000ms ago
  • At 42 FPS: min(23ms, 23ms) = 23ms → threshold is 23ms (vs old 15ms) → slightly higher threshold, almost same

Hmm, this still seems like it would trigger at 1 FPS. Unless the intent is to NOT use min() but rather to use the current _frametime directly?

Wait, re-reading the original: "MIN_SHOW_DELAY --> min(FRAMETIME, uint16_t(FRAMETIME_FIXED))"

Maybe the fix is not about the trigger threshold, but about something else. Let me look at the actual trigger path again.

trigger() sets _triggered = true. In service():

if (_triggered || _targetFps == FPS_UNLIMITED) timeToShow = true;

So with trigger, the next service() call will run effects and show regardless of frametime. This is the "speedup" — at 1 FPS, the next service() call is in ~2ms (MIN_FRAME_DELAY), which runs effects prematurely.

The fix to setBrightness() is to not call trigger() if the next frame is "soon enough". With min(FRAMETIME, FRAMETIME_FIXED) as the threshold:

  • At 42 FPS (_frametime=23ms): threshold = min(23, 23) = 23ms
  • At 1 FPS (_frametime=1000ms): threshold = min(1000, 23) = 23ms
  • At 120 FPS (_frametime≈8ms): threshold = min(8, 23) = 8ms

So the threshold is always capped at FRAMETIME_FIXED ≈ 23ms. At 1 FPS, the last show was 1000ms ago, so t - _lastShow = 1000ms > 23ms → trigger still fires! This still causes the speedup.

Wait... Maybe I'm misunderstanding the direction of the fix. Let me look at this from the opposite angle.

At very low FPS (1-2 FPS), the "speedup" problem is: the brightness change fires trigger(), which forces an immediate frame. With _frametime=1000ms and trigger, the next service() runs effects. This causes an "extra" frame at the wrong time.

Maybe the idea is different: instead of increasing the threshold so trigger() is NOT called, the fix is to make the threshold be FRAMETIME (current frametime) so trigger() is only called if we're "close enough to the next frame anyway" (i.e., last show was nearly a full frametime ago).

If threshold = min(_frametime, FRAMETIME_FIXED):

  • At 1 FPS: threshold = min(1000, 23) = 23ms → trigger fires if >23ms since last show → since it's been 1000ms, trigger fires → STILL the problem

Unless the fix is meant to use max not min? max(FRAMETIME, FRAMETIME_FIXED) would:

  • At 1 FPS: max(1000, 23) = 1000ms → trigger only fires if last show was >1000ms ago → at 1 FPS, the show interval IS ~1000ms, so trigger would fire when the next show is "late" by a full second, which essentially never. This would break responsiveness entirely.

Hmm, I'm confused. Let me re-read softhack007's proposal more carefully:

"solution: MIN_SHOW_DELAY --> min(FRAMETIME, uint16_t(FRAMETIME_FIXED)) will still improve responsiveness, without making all effects run wild"

The current MIN_SHOW_DELAY = 3ms effectively (since it was designed for old code where _frametime was 33ms for static effects). "min(FRAMETIME, FRAMETIME_FIXED)" at standard 42 FPS = min(23, 23) = 23ms.

The old min show delay for standard FPS (> 62 FPS): 8ms. For ≤ 62 FPS: 15ms.

Oh wait! Look at this again:

`#define` MIN_SHOW_DELAY   (_frametime < 16 ? 8 : 15)

At UNLIMITED mode: _frametime = MIN_FRAME_DELAY = 2ms. So MIN_SHOW_DELAY = 8ms (since 2 < 16).

The problem: MIN_SHOW_DELAY says "trigger if no show for >8ms (or 15ms)" but in reality at unlimited mode, a show happens every ~2ms, so t - _lastShow is typically ~2ms < 8ms → trigger would NOT fire. But at 1 FPS, t - _lastShow is typically ~1000ms > 15ms → trigger fires → speedup.

Hmm, so for 1-2 FPS effects, every brightness change causes an immediate frame. With min(FRAMETIME, FRAMETIME_FIXED) = min(1000ms, 23ms) = 23ms as the new threshold... that's still less than the 1000ms between frames, so trigger still fires.

Wait. I think I need to look at this from a completely different angle. Maybe "min(FRAMETIME, FRAMETIME_FIXED)" means "don't trigger if the next regular show is within FRAMETIME_FIXED ms".

In other words, the condition becomes:
"only trigger if the last show was > FRAMETIME_FIXED ms ago, OR the last show was > _frametime ms ago"

No, min(FRAMETIME, FRAMETIME_FIXED) means: the threshold is the smaller of the two. Hmm.

Actually wait - if FRAMETIME at 1 FPS = 1000ms and FRAMETIME_FIXED = 23ms, then min = 23ms. At 1 FPS, last show was 500ms ago (on average) → 500ms > 23ms → trigger fires. Still broken.

I think maybe the intent is to NOT call trigger() at all from setBrightness(), and instead just let the next frame pick up the brightness change naturally. But that's too responsive-breaking.

OR - maybe the fix is to change what trigger() does: instead of forcing timeToShow=true immediately, it should set a "trigger within the next FRAMETIME_FIXED ms" flag. But that's a much bigger change.

Let me just take softhack007's proposal at face value and analyze the potential side effects of that specific change (replacing MIN_SHOW_DELAY with min(FRAMETIME, uint16_t(FRAMETIME_FIXED)) in line 1754). I should gather the relevant code and provide the analysis, noting that for very low FPS configurations, the behavior improvement may be limited.

Actually, I think I may be overthinking this. Let me read the MIN_SHOW_DELAY definition once more:

#define MIN_SHOW_DELAY (_frametime < 16 ? 8 : 15)

This checks if _frametime < 16ms. At the time setBrightness() is called:

  • Standard 42 FPS: _frametime = 23ms > 16 → MIN_SHOW_DELAY = 15ms
  • FPS=120: _frametime ≈ 8ms < 16 → MIN_SHOW_DELAY = 8ms
  • FPS=1: _frametime = 1000ms > 16 → MIN_SHOW_DELAY = 15ms
  • Unlimited: _frametime = 2ms < 16 → MIN_SHOW_DELAY = 8ms

Now DedeHai tested at 1-2 FPS and observed speedup. At 1 FPS with MIN_SHOW_DELAY=15ms and last show typically 1000ms ago: trigger always fires → speed up happens. This is the confirmed issue.

With fix min(FRAMETIME, FRAMETIME_FIXED) = min(1000ms, 23ms) = 23ms:

  • At 1 FPS, last show ~1000ms ago → 1000ms > 23ms → trigger still fires

Hmm, that doesn't fix it either! Unless the intent is to look at how close we are to the NEXT show, not how long since last show? i.e. "how long until next show = _frametime - (t - _lastShow)". If that's less than FRAMETIME_FIXED, don't trigger. But the code doesn't compute it that way.

OK let me look at this from a fresh perspective. Actually, maybe the solution as described only addresses the case where MIN_SHOW_DELAY is very small (like 8ms at high FPS) and not where it's 15ms but the frame is 1000ms long. The problem DedeHai saw at 1-2 FPS may be a distinct/related issue. The fix as proposed prevents the "effects run wild" on unlimited FPS or high FPS, not necessarily 1-2 FPS.

I need to look at the actual code paths more carefully. Let me just gather more context and provide the analysis.


🏁 Script executed:

#!/bin/bash
echo "=== handleTransition full impl ==="
awk '/void WS2812FX::handleTransition/,/^}/' wled00/FX_fcn.cpp | head -80

echo ""
echo "=== resetIfRequired full impl ==="
awk '/void Segment::resetIfRequired/,/^}/' wled00/FX_fcn.cpp | head -60

echo ""
echo "=== Segment transition fields ==="
rg -n "progress\b|_dur\b|transitionDur\|startT\b|_startT\|_modeBlend\|step_progress\|blendT\b" --type h -A1 -B1 | head -60

echo ""
echo "=== color_fade / color_blend usage in transitions ==="
rg -n "color_fade\|color_blend\|blendT\b\|progress\b" --type cpp -A2 -B2 | head -80

echo ""
echo "=== Segment::handleTransition context ==="
rg -n "handleTransition\|_transitionDur\|transitionDur" --type cpp -C4 | head -80

echo ""
echo "=== palette transition code - how often it ticks ==="
rg -n "paletteFade\|_targetPal\|paletteBlend\|loadPalette\|_blendT\|tCycle\|palT\b" --type cpp -A3 -B2 | head -80

echo ""
echo "=== FX_fcn.cpp lines 1280-1340 (full service loop with transition handling) ==="
awk 'NR>=1280 && NR<=1340' wled00/FX_fcn.cpp

echo ""
echo "=== FRAMETIME_FIXED and WLED_FPS ==="
rg -n "WLED_FPS\|FRAMETIME_FIXED\|FPS_CALC_SHIFT\|FPS_MULTIPLIER" --type h --type cpp | head -20

echo ""
echo "=== FPS_UNLIMITED ==="
rg -n "FPS_UNLIMITED" --type h --type cpp | head -10

echo ""
echo "=== led.cpp applyFinalBri / briT / briOld transition ==="
awk 'NR>=1 && NR<=150' wled00/led.cpp

echo ""
echo "=== colorUpdated and stateUpdated trigger paths ==="
rg -n "colorUpdated\|stateUpdated\|applyBri\|applyFinalBri\|strip\.trigger" --type cpp -B1 -A1 | head -80

Length of output: 11872


@softhack007 Here is the detailed side-effect analysis for replacing MIN_SHOW_DELAY with min(FRAMETIME, uint16_t(FRAMETIME_FIXED)) in setBrightness().


Analysis: Side Effects of the MIN_SHOW_DELAY Fix

What the fix actually does at each FPS setting

Configured FPS _frametime Old threshold (MIN_SHOW_DELAY) New threshold (min(FRAMETIME, FRAMETIME_FIXED)) Effect
42 (default) 23 ms 15 ms min(23, 23) = 23 ms ✅ No more premature trigger (~8 ms early)
60 16 ms 15 ms min(16, 23) = 16 ms ✅ Marginal improvement
120 8 ms 8 ms (_frametime < 16) min(8, 23) = 8 ms ↔ No change
Unlimited 2–8 ms 8 ms min(2–8, 23) = 2–8 ms ↔ No change
2 500 ms 15 ms min(500, 23) = 23 ms ⚠️ Trigger still fires (last show ~500 ms ago >> 23 ms) — not fixed
1 1000 ms 15 ms min(1000, 23) = 23 ms ⚠️ Same — not fixed

The fix correctly prevents "premature" triggering at standard FPS by aligning the threshold with the actual frametime. For 1–2 FPS, t - _lastShow is always >> FRAMETIME_FIXED, so trigger still fires on brightness change — that edge case remains a known limitation (as you noted, the led.cpp path is "don't touch" territory).


Impact on transitions, blending, cross-fading, palette transitions

1. Brightness fade transitions (applyBri()setBrightness(briT) path)

  • This IS in the affected path. During a slow briT fade, handleTime() calls applyBri()setBrightness() repeatedly.
  • With the fix at 42 FPS: brightness renders at exactly 42 FPS rather than triggering premature frames. BusManager::setBrightness() applies the hardware register immediately regardless; show() pushes it to LEDs at the scheduled cadence. No regression, cleaner cadence.

2. applyFinalBri() (transition duration = 0)

  • Calls strip.trigger() directly, bypassing setBrightness()'s trigger path entirely. Completely unaffected.

3. Segment effect transitions (fade, fairy dust, etc.)

  • handleTransition() / updateTransitionProgress() / progress() all use millis() directly — fully time-based, not frame-count dependent.
  • An extra or premature show() frame just samples the blend at a slightly different time ratio. Removing premature frames makes this more accurate, not less.

4. On/Off power transitions (with non-FADE blend styles)

  • Same time-based progress() mechanism. Bug DedeHai found (fairy dust broken after rapid on/off during transition) is in the transition state machine — unrelated to this change.

5. Cross-fading / blendingStyle transitions

  • beginDraw(prog) uses the time-based prog = seg.progress(). No regression.

6. Palette transitions (handleRandomPalette())

  • Called inside if (doShow) { ... show(); } — runs once per rendered frame, driven by the normal FPS cadence. No regression.

One implementation note

Inside WS2812FX::setBrightness(), the FRAMETIME macro expands to strip.getFrameTime() which is just _frametime. It's cleaner to use _frametime directly inside the class to avoid the macro resolving through the global strip object:

// proposed change in setBrightness():
if (t - _lastShow > std::min(_frametime, (uint16_t)FRAMETIME_FIXED)) trigger();

Or using the C macro style consistent with the existing codebase (no std::):

if (t - _lastShow > min(_frametime, (uint16_t)FRAMETIME_FIXED)) trigger();

Verdict

Safe to implement. No regressions expected in transitions, blending, cross-fading, or palette transitions. All those subsystems are time-based (not frame-count-based) and are either unaffected or benefit from the more disciplined trigger cadence. The remaining 1–2 FPS edge case is a pre-existing separate issue.


🧠 Learnings used
Learnt from: softhack007
Repo: wled/WLED PR: 5443
File: wled00/FX_fcn.cpp:1277-1277
Timestamp: 2026-03-24T12:10:29.964Z
Learning: In WLED's `WS2812FX::service()` (wled00/FX_fcn.cpp), the old condition `|| (doShow && seg.mode == FX_MODE_STATIC)` was an **inclusion** guard — it caused FX_MODE_STATIC to render only when another segment had already set doShow=true. It did NOT skip or protect FX_MODE_STATIC from rendering. The PR `#5443` simplification removes this condition, meaning FX_MODE_STATIC now renders on every `timeToShow` tick uniformly. This is intentional and not a regression. Do not flag FX_MODE_STATIC special-casing as missing in future reviews of this function.

Learnt from: DedeHai
Repo: wled/WLED PR: 4798
File: wled00/FX.cpp:7531-7533
Timestamp: 2025-08-26T11:51:21.817Z
Learning: In WLED PR `#4798`, DedeHai confirmed that certain gamma-related calls in FX.cpp/FX_fcn.cpp/particle systems are intentional for effect-level shaping (e.g., brightness curves, TV sim, Pride 2015 pre-mix), distinct from final output gamma. Do not flag or remove these in future reviews; add comments when feasible to clarify intent.

Learnt from: softhack007
Repo: wled/WLED PR: 5443
File: wled00/FX_fcn.cpp:1277-1277
Timestamp: 2026-03-24T12:13:17.908Z
Learning: In WLED's `WS2812FX::service()` (wled00/FX_fcn.cpp), `seg.freeze` means "do not run the effect function (_mode[seg.mode]())" — it does NOT mean "skip show()". A frozen segment's pixel buffer can still be updated externally (e.g., realtime control or single-pixel JSON API). `strip.trigger()` is the primary mechanism to flush those externally written pixels to the LEDs on the next service tick. Therefore, frozen segments must remain part of the `doShow`/`show()` path, and it is architecturally wrong to exclude frozen segments from `doShow`. Do not suggest skipping frozen segments from the show path in future reviews.

Learnt from: BobLoeffler68
Repo: wled/WLED PR: 5440
File: usermods/user_fx/user_fx.cpp:1307-1327
Timestamp: 2026-03-24T07:59:25.586Z
Learning: In WLED's Dissolve Plus effect (usermods/user_fx/user_fx.cpp, mode_dissolveplus), when the "Last one" checkbox (check3/lastOneMode) is turned off while the effect is in PHASE_PAUSE_SURVIVOR, the phase is intentionally NOT immediately reset. The pause expires naturally via the normal delay timing (a few hundred ticks), after which the filling phase begins. PHASE_DISSOLVE_SURVIVOR is reset to PHASE_DISSOLVE and PHASE_FILL_SURVIVOR is reset to PHASE_FILL on toggle-off, but PHASE_PAUSE_SURVIVOR is left to expire on its own. Do not flag this as a bug in future reviews.

Learnt from: BobLoeffler68
Repo: wled/WLED PR: 5440
File: usermods/user_fx/user_fx.cpp:1304-1313
Timestamp: 2026-03-25T07:03:31.345Z
Learning: In WLED `mode_dissolveplus` (usermods/user_fx/user_fx.cpp), using `hw_random16(SEGLEN)` to select the survivor pixel index is correct and safe for this 1D-only effect. The 0xFFFF unmapped-entry concern from the physical bus mapping does not apply to 1D segments because virtual indices 0..SEGLEN-1 always map to valid physical LEDs without gaps. Do not flag this as a bug in future reviews of 1D effects.

Learnt from: DedeHai
Repo: wled/WLED PR: 5434
File: wled00/colors.cpp:79-83
Timestamp: 2026-03-21T11:43:36.767Z
Learning: In WLED's `color_fade()` (wled00/colors.cpp), the `video=true` path computes `maxc = (maxc>>2) + 1` and only preserves a channel if its value strictly exceeds that threshold (`r > maxc`, etc.). This means very dark non-zero channels (e.g., r==1 or r==2) can be dropped to zero in video mode. This is intentional: it prevents flickering in corner regions of gradients and is an accepted design compromise. Do not flag this as a bug in future reviews.

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2025-12-28T14:06:48.772Z
Learning: In WLED post-commit ee9ac947, the rendering pipeline uses per-segment buffers and per-pixel bus updates. Unmapped (0xFFFF) mapping entries are now skipped in WS2812FX::show() (no “clear to black”), which can leave physical gap LEDs with stale/random colors unless they are explicitly cleared. This is a behavior change from pre-0.16 where a full physical buffer was effectively refreshed each frame.

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2026-03-08T00:57:36.134Z
Learning: In WLED (wled00/cfg.cpp), `deserializeConfig()` is called with an empty JsonObject when cfg.json doesn't exist on fresh install. Any value read without the ArduinoJSON `|` fallback operator or CJSON macro will override correct constructor defaults with 0/null. Known affected values: `strip.setTargetFps(hw_led["fps"])` at line 179 (sets FPS to 0/unlimited instead of WLED_FPS=42). Fix: `strip.setTargetFps(hw_led["fps"] | WLED_FPS)`. The gamma issue (gammaCorrectCol/Bri) had the same root cause and was fixed in commit d1d9dec402 (Jan 2026) using inline `| default` fallbacks.

Learnt from: BobLoeffler68
Repo: wled/WLED PR: 5293
File: usermods/user_fx/user_fx.cpp:167-171
Timestamp: 2026-01-13T03:07:20.560Z
Learning: In WLED effects (usermods/user_fx/user_fx.cpp and similar), when storing future timestamps, assignments like `stored_time = now + delay` are correct and handle uint32_t wraparound properly. Only comparison statements need to use subtraction-based elapsed-time checks (e.g., `(now - stored_time) >= delay`). Do not flag assignment statements as needing fixes for wraparound handling.

Learnt from: BobLoeffler68
Repo: wled/WLED PR: 5109
File: wled00/FX.cpp:3174-3343
Timestamp: 2025-11-27T06:33:11.436Z
Learning: WLED Ants effect (wled00/FX.cpp): The author prefers the current velocity initialization using hw_random16(1000, 5000)/5000.0f, resulting in an effective range of ~3.6–10.0 (with VELOCITY_MIN=2.0, VELOCITY_MAX=10.0), and wants the code kept as-is with comments updated to document this behavior. Avoid suggesting changes to span the full 2.0–10.0 range in future reviews.

Learnt from: DedeHai
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2026-02-22T20:40:46.281Z
Learning: At low brightness (<30), color_blend() suffers from integer rounding that destroys RGB channel ratios, causing visible hue shifts during transitions (e.g., greenish tints). This can be fixed by applying hue preservation logic similar to color_fade()'s video mode: add 1 to channels that are non-zero and > 13% of max channel `(channel << 5) > maxc`. This preserves color ratios and prevents premature channel zeroing at low brightness without affecting performance at normal brightness levels.

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2026-03-15T13:21:32.008Z
Learning: In WLED code reviews (and generally), when a PR discussion involves "is this worth doing?" uncertainty about a proposed reliability, safety, or data-integrity mechanism (e.g. CRC checks, backups, power-loss protection), suggest performing a software FMEA (Failure Mode and Effects Analysis). The FMEA should: enumerate failure modes for the feared event, assess each mitigation's effectiveness per failure mode, note common-cause/common-mode failures, and rate credibility for the typical WLED use case. This was successfully used in PR `#5421` to evaluate CRC-based config file protection and was well-received by the WLED maintainers (softhack007 explicitly requested this be remembered for future reviews).

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2025-11-14T13:37:11.994Z
Learning: In WLED code reviews, when code is modified or added, look for "magic numbers" (hardcoded numeric literals) and suggest replacing them with appropriate defined constants when those constants are meaningful in the context of the PR. For example, the hardcoded value 32 should be replaced with WLED_MAX_SEGNAME_LEN when it represents a segment name length limit. This improves code maintainability and reduces the risk of inconsistencies.

@coderabbitai

This comment was marked as duplicate.

During a slow briT fade, handleTime() calls applyBri() → setBrightness() repeatedly.
With the fix at 42 FPS: brightness renders at  42 FPS rather than triggering frames with unlimited FPS.
SEGMENT/SEGENV should not be used outside of the service loop. If it hppens, this fix prevents crashes and use-after-free problems.
coderabbitai[bot]

This comment was marked as resolved.

@softhack007
Copy link
Member Author

I have found one bug, but that is probably not related to this PR:
to reproduce: set transition to 5s, choose any transition type except fade, fairy dust for example. power leds off and while in transition power on again, then off again before the transition completes -> now the on off transition is broken and wont do fairy dust.

@coderabbitai please analyze the chain of events and state changes that lead to this problem. Do you see a simple and minimal-impact fix?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 25, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Good question — I need to trace the full state machine for on/off transitions before I can pinpoint the bug. Let me gather the relevant code.


🏁 Script executed:

#!/bin/bash
echo "=== startTransition() full implementation ==="
LINE=$(rg -n "void.*startTransition\b" wled00/FX_fcn.cpp | head -1 | cut -d: -f1)
echo "startTransition at line: $LINE"
if [ -n "$LINE" ]; then
  grep -n "" wled00/FX_fcn.cpp | sed -n "${LINE},$((LINE+80))p"
fi

echo ""
echo "=== handleTransition() full implementation ==="
LINE2=$(rg -n "void.*handleTransition\b" wled00/FX_fcn.cpp | head -1 | cut -d: -f1)
echo "handleTransition at line: $LINE2"
if [ -n "$LINE2" ]; then
  grep -n "" wled00/FX_fcn.cpp | sed -n "${LINE2},$((LINE2+80))p"
fi

echo ""
echo "=== Segment::resetIfRequired() ==="
LINE3=$(rg -n "resetIfRequired\b" wled00/FX_fcn.cpp | head -1 | cut -d: -f1)
echo "resetIfRequired at line: $LINE3"
if [ -n "$LINE3" ]; then
  grep -n "" wled00/FX_fcn.cpp | sed -n "${LINE3},$((LINE3+50))p"
fi

echo ""
echo "=== transition struct definition in FX.h ==="
rg -n "struct.*[Tt]ransition\|_transitionData\|_t\b\|_dur\b\|_start\b\|_progress\b\|_modeNew\|_modeBlend\|_paletteFade" wled00/FX.h -A2 -B1 | head -80

echo ""
echo "=== Segment::progress() implementation ==="
rg -n "progress\(\)" wled00/FX.h wled00/FX_fcn.cpp -A5 -B1 | head -50

echo ""
echo "=== on/off toggle path: stateUpdated / colorUpdated and bri in led.cpp ==="
grep -n "" wled00/led.cpp | sed -n '1,200p'

echo ""
echo "=== handleTransitions() (main loop brightness fade) ==="
LINE4=$(rg -n "void handleTransitions\b" wled00/led.cpp | head -1 | cut -d: -f1)
echo "handleTransitions at line: $LINE4"
if [ -n "$LINE4" ]; then
  grep -n "" wled00/led.cpp | sed -n "${LINE4},$((LINE4+60))p"
fi

echo ""
echo "=== where startTransition is called (who triggers it?) ==="
rg -n "startTransition" --type cpp -A2 -B2 | head -80

echo ""
echo "=== _briOld / briOld / briT / _bri transitions ==="
rg -n "\bbriOld\b\|briT\b\|_briOld\b\|briTransition\|bri0\b\|briLast\b" --type cpp --type h -A2 -B1 | head -60

echo ""
echo "=== TRANSITION_ON/TRANSITION_OFF / transitionStyle / blendingStyle ==="
rg -n "TRANSITION_ON\|TRANSITION_OFF\|transitionStyle\|blendingStyle\|BLEND_TRANSITION\|_blendingStyle" --type cpp --type h -A2 -B1 | head -80

echo ""
echo "=== Segment.on / seg.on state in startTransition ==="
rg -n "seg\.on\b\|\.on\s*=\|_on\b\|isOn\(\)" --type cpp --type h -A1 -B1 | head -60

echo ""
echo "=== where seg.on is set during power toggle ==="
rg -n "\.on\s*=\s*\(bri\|setOn\|toggleOn\|powerOn\|bri\s*=\s*0\|bri\s*=\s*briLast\|briOld\s*=" --type cpp -B2 -A2 | head -80

Length of output: 24664


@softhack007 Here is the full chain of events and a minimal-impact fix proposal.


Bug Analysis: Interrupted On/Off Transition Breaks Fairy Dust

State machine trace

T1 — First power OFF (seg.on: true → false, bri > 0 → 0):

setOption(SEG_OPTION_ON, false) calls startTransition(5000, true) before clearing the flag. Because isInTransition() == false, the normal path runs:

  • _t allocated, _t->_bri = on ? opacity : 0captures 255 (still on=true at call time)
  • _t->_oldSegment created (copy of running effect, e.g. Fairy Dust state)
  • _t->_start = millis() (T₀)
  • Then on = false is set

T1 now running correctly toward a dark frame.


T2 — Power ON mid-T1 (seg.on: false → true, elapsed ~2500ms at time T₀+2500):

setOption(SEG_OPTION_ON, true) calls startTransition(5000, true) before setting on.

// FX_fcn.cpp:289-303
if (isInTransition()) {
    if (segmentCopy && !_t->_oldSegment) { // <-- _oldSegment EXISTS, so condition is FALSE
        ...
    }
    return;  // ← silently returns, does nothing
}

_t->_start, _t->_bri, and _t->_oldSegment are all left stale from T1. Then on = true is set. The segment is "in transition" but the clock is still counting from T₀.


T3 — Power OFF again before T1 timer expires (elapsed < 5000ms from T₀):

startTransition(5000, true) is called — same silent return at line 303.
Then on = false is set.

At this point:

  • _t->_start = T₀ (from T1 start!)
  • _t->_bri = 255 (from T1 start!)
  • elapsed since T₀ may already be ≥ 5000ms (T1 + T2 + T3 together)

On the next service() tick, updateTransitionProgress() computes:

unsigned diff = millis() - _t->_start; // >> 5000ms
// diff >= _dur → _progress = 0xFFFF → stopTransition() fires immediately

Fairy dust never runs — the transition fires at 0xFFFF and terminates instantly.


Root cause (one line)

startTransition() silently no-ops when isInTransition() && _t->_oldSegment != nullptr, leaving _t->_start and _t->_bri stale. By the time of T3, the stale clock has already exceeded _dur, causing instant termination.


Minimal-impact fix

The fix is to update _t->_bri and reset _t->_start / _t->_dur when a transition is re-triggered mid-flight — even if _oldSegment already exists. The pixel buffer in _oldSegment remains valid enough for continuity.

void Segment::startTransition(uint16_t dur, bool segmentCopy) {
  if (dur == 0 || !isActive()) {
    if (isInTransition()) _t->_dur = 0;
    return;
  }
  if (isInTransition()) {
    // Always restart timing and capture current visual brightness when interrupted
    _t->_bri   = currentBri();  // capture mid-transition brightness as new "from" value
    _t->_start = millis();      // reset the clock for the new transition
    _t->_dur   = dur;
    _t->_prevPaletteBlends = 0;
    if (segmentCopy && !_t->_oldSegment) {
      // existing path: segment copy was requested but not yet created
      _t->_oldSegment = new(std::nothrow) Segment(*this);
      if (_t->_oldSegment) {
        _t->_oldSegment->palette = _t->_palette;
        for (unsigned i = 0; i < NUM_COLORS; i++) _t->_oldSegment->colors[i] = _t->_colors[i];
        DEBUGFX_PRINTF_P(PSTR("-- Updated transition with segment copy: S=%p T(%p) O[%p] OP[%p]\n"), this, _t, _t->_oldSegment, _t->_oldSegment->pixels);
        if (!_t->_oldSegment->isActive()) stopTransition();
      }
    }
    return;
  }
  // ... rest of function unchanged

What this fixes:

  • _t->_start is reset to millis() → the new 5s transition plays from now, not from the stale T1 epoch
  • _t->_bri is updated to the current visible (blended) brightness → currentBri() from mid-T1 or mid-T2 becomes the new "old bri" for the next transition direction
  • _t->_oldSegment (the effect pixel copy) is reused for continuity — it's not a perfect mid-transition snapshot but avoids a flash cut
  • The !_t->_oldSegment branch is preserved as-is for the existing color-change path

Known residual limitation: The _oldSegment pixel buffer holds the state from T1's start, not a true mid-T1 snapshot. For complex blending effects like Fairy Dust this means the "old frame" might look slightly off on a T2→T3 re-trigger, but it will at least complete its full 5000ms duration rather than skipping instantly.


✏️ Learnings added
Learnt from: softhack007
URL: https://github.com/wled/WLED/pull/5443

Timestamp: 2026-03-25T12:25:11.598Z
Learning: In WLED's `Segment::startTransition()` (wled00/FX_fcn.cpp:284), when `isInTransition()` is true and `_t->_oldSegment` already exists, the function silently returns without updating `_t->_start` or `_t->_bri`. This causes a bug where rapid successive on/off toggles during a non-FADE blending transition (e.g., fairy dust) leave the transition clock stale: by the time of the second re-trigger, elapsed time may already exceed `_dur`, so `updateTransitionProgress()` sets `_progress = 0xFFFF` immediately on the next service tick and `stopTransition()` fires — the blending effect never plays. The fix is to always reset `_t->_start = millis()`, `_t->_dur = dur`, and `_t->_bri = currentBri()` (current visible brightness) in the `isInTransition()` branch, regardless of whether `_oldSegment` exists.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: softhack007
Repo: wled/WLED PR: 5443
File: wled00/FX_fcn.cpp:1277-1277
Timestamp: 2026-03-24T12:10:29.964Z
Learning: In WLED's `WS2812FX::service()` (wled00/FX_fcn.cpp), the old condition `|| (doShow && seg.mode == FX_MODE_STATIC)` was an **inclusion** guard — it caused FX_MODE_STATIC to render only when another segment had already set doShow=true. It did NOT skip or protect FX_MODE_STATIC from rendering. The PR `#5443` simplification removes this condition, meaning FX_MODE_STATIC now renders on every `timeToShow` tick uniformly. This is intentional and not a regression. Do not flag FX_MODE_STATIC special-casing as missing in future reviews of this function.

Learnt from: softhack007
Repo: wled/WLED PR: 5443
File: wled00/FX_fcn.cpp:1277-1277
Timestamp: 2026-03-24T12:13:17.908Z
Learning: In WLED's `WS2812FX::service()` (wled00/FX_fcn.cpp), `seg.freeze` means "do not run the effect function (_mode[seg.mode]())" — it does NOT mean "skip show()". A frozen segment's pixel buffer can still be updated externally (e.g., realtime control or single-pixel JSON API). `strip.trigger()` is the primary mechanism to flush those externally written pixels to the LEDs on the next service tick. Therefore, frozen segments must remain part of the `doShow`/`show()` path, and it is architecturally wrong to exclude frozen segments from `doShow`. Do not suggest skipping frozen segments from the show path in future reviews.

Learnt from: DedeHai
Repo: wled/WLED PR: 4798
File: wled00/FX.cpp:7531-7533
Timestamp: 2025-08-26T11:51:21.817Z
Learning: In WLED PR `#4798`, DedeHai confirmed that certain gamma-related calls in FX.cpp/FX_fcn.cpp/particle systems are intentional for effect-level shaping (e.g., brightness curves, TV sim, Pride 2015 pre-mix), distinct from final output gamma. Do not flag or remove these in future reviews; add comments when feasible to clarify intent.

Learnt from: BobLoeffler68
Repo: wled/WLED PR: 5293
File: usermods/user_fx/user_fx.cpp:167-171
Timestamp: 2026-01-13T03:07:20.560Z
Learning: In WLED effects (usermods/user_fx/user_fx.cpp and similar), when storing future timestamps, assignments like `stored_time = now + delay` are correct and handle uint32_t wraparound properly. Only comparison statements need to use subtraction-based elapsed-time checks (e.g., `(now - stored_time) >= delay`). Do not flag assignment statements as needing fixes for wraparound handling.

Learnt from: BobLoeffler68
Repo: wled/WLED PR: 5440
File: usermods/user_fx/user_fx.cpp:1307-1327
Timestamp: 2026-03-24T07:59:25.586Z
Learning: In WLED's Dissolve Plus effect (usermods/user_fx/user_fx.cpp, mode_dissolveplus), when the "Last one" checkbox (check3/lastOneMode) is turned off while the effect is in PHASE_PAUSE_SURVIVOR, the phase is intentionally NOT immediately reset. The pause expires naturally via the normal delay timing (a few hundred ticks), after which the filling phase begins. PHASE_DISSOLVE_SURVIVOR is reset to PHASE_DISSOLVE and PHASE_FILL_SURVIVOR is reset to PHASE_FILL on toggle-off, but PHASE_PAUSE_SURVIVOR is left to expire on its own. Do not flag this as a bug in future reviews.

Learnt from: DedeHai
Repo: wled/WLED PR: 5434
File: wled00/colors.cpp:79-83
Timestamp: 2026-03-21T11:43:36.767Z
Learning: In WLED's `color_fade()` (wled00/colors.cpp), the `video=true` path computes `maxc = (maxc>>2) + 1` and only preserves a channel if its value strictly exceeds that threshold (`r > maxc`, etc.). This means very dark non-zero channels (e.g., r==1 or r==2) can be dropped to zero in video mode. This is intentional: it prevents flickering in corner regions of gradients and is an accepted design compromise. Do not flag this as a bug in future reviews.

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2026-03-08T00:57:36.134Z
Learning: In WLED (wled00/cfg.cpp), `deserializeConfig()` is called with an empty JsonObject when cfg.json doesn't exist on fresh install. Any value read without the ArduinoJSON `|` fallback operator or CJSON macro will override correct constructor defaults with 0/null. Known affected values: `strip.setTargetFps(hw_led["fps"])` at line 179 (sets FPS to 0/unlimited instead of WLED_FPS=42). Fix: `strip.setTargetFps(hw_led["fps"] | WLED_FPS)`. The gamma issue (gammaCorrectCol/Bri) had the same root cause and was fixed in commit d1d9dec402 (Jan 2026) using inline `| default` fallbacks.

Learnt from: BobLoeffler68
Repo: wled/WLED PR: 5440
File: usermods/user_fx/user_fx.cpp:1304-1313
Timestamp: 2026-03-25T07:03:31.345Z
Learning: In WLED `mode_dissolveplus` (usermods/user_fx/user_fx.cpp), using `hw_random16(SEGLEN)` to select the survivor pixel index is correct and safe for this 1D-only effect. The 0xFFFF unmapped-entry concern from the physical bus mapping does not apply to 1D segments because virtual indices 0..SEGLEN-1 always map to valid physical LEDs without gaps. Do not flag this as a bug in future reviews of 1D effects.

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2025-12-28T14:06:48.772Z
Learning: In WLED post-commit ee9ac947, the rendering pipeline uses per-segment buffers and per-pixel bus updates. Unmapped (0xFFFF) mapping entries are now skipped in WS2812FX::show() (no “clear to black”), which can leave physical gap LEDs with stale/random colors unless they are explicitly cleared. This is a behavior change from pre-0.16 where a full physical buffer was effectively refreshed each frame.

Learnt from: freakintoddles2
Repo: wled/WLED PR: 4904
File: wled00/FX.cpp:3915-3930
Timestamp: 2025-09-02T19:42:57.475Z
Learning: WLED wled00/FX.cpp::mode_percent — For time-based options (% of Minute/Hour/Day), behavior is clock-like: at rollover (elapsed==0) intentionally show SEGLEN lit for one tick to represent completion, then move to 1 LED at the next second. This is by design (not a progress bar) and should not be “fixed”.

Learnt from: DedeHai
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2026-01-13T21:23:35.514Z
Learning: In WLED, the global `paletteBlend` variable (wled.h:603) and the `WS2812FX::paletteBlend` member (FX.h:940) are duplicates without synchronization code. The global is loaded/saved in cfg.cpp and set via UI in set.cpp, but never copied to the strip member. This is the only such case in the codebase; other settings are either strip-only members (autoSegments, correctWB, cctFromRgb, isMatrix) or global-only (gammaCorrectCol/Bri/Val, blendingStyle).

Learnt from: softhack007
Repo: wled/WLED PR: 5442
File: wled00/bus_manager.cpp:462-468
Timestamp: 2026-03-23T23:33:42.207Z
Learning: In WLED's TYPE_ANALOG_2CH_HBRIDGE (wled00/bus_manager.cpp), the dead zone at the CCT midpoint (level=127 and level=128 both producing _data[0]=0, _data[1]=0) is intentional by design. The map() blending is directional: WW channel (_data[0]) is brightest at level=0 and fades to 0 at level=127; CW channel (_data[1]) starts at 0 at level=128 and is brightest at level=255. The zero-output at the midpoint is the H-bridge polarity transition boundary, ensuring no overlap between the two polarity directions. Do not flag this dead zone as a bug in future reviews.

Learnt from: blazoncek
Repo: wled/WLED PR: 5140
File: wled00/data/settings_time.htm:66-76
Timestamp: 2025-12-01T07:01:16.949Z
Learning: In WLED PR `#5134`, the fix for macros being initialized with the enable bit set only handles new configurations, not existing ones. If there is a bug in timer/macro handling code (e.g., in settings_time.htm), it must be fixed to work correctly for existing configurations as well.

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2025-11-14T13:37:11.994Z
Learning: In WLED code reviews, when code is modified or added, look for "magic numbers" (hardcoded numeric literals) and suggest replacing them with appropriate defined constants when those constants are meaningful in the context of the PR. For example, the hardcoded value 32 should be replaced with WLED_MAX_SEGNAME_LEN when it represents a segment name length limit. This improves code maintainability and reduces the risk of inconsistencies.

Learnt from: softhack007
Repo: wled/WLED PR: 0
File: :0-0
Timestamp: 2026-03-15T13:21:32.008Z
Learning: In WLED code reviews (and generally), when a PR discussion involves "is this worth doing?" uncertainty about a proposed reliability, safety, or data-integrity mechanism (e.g. CRC checks, backups, power-loss protection), suggest performing a software FMEA (Failure Mode and Effects Analysis). The FMEA should: enumerate failure modes for the feared event, assess each mitigation's effectiveness per failure mode, note common-cause/common-mode failures, and rate credibility for the typical WLED use case. This was successfully used in PR `#5421` to evaluate CRC-based config file protection and was well-received by the WLED maintainers (softhack007 explicitly requested this be remembered for future reviews).

@DedeHai
Copy link
Collaborator

DedeHai commented Mar 25, 2026

Looks like both FPS jumps are due to brightness handling, and especially it comes from "responsiveness improvement" hacks from the times when the SOLID effect was running at 3fps fixed, causing brightness/color changes to stutter

the hack was to not use frametime in transitions, updating brightness through "trigger" is correct - otherwise the brightness of frozen segments is unresponsive. The "issue" I pointed at really only matters if you go below 10FPS deliberately, otherwise its not really noticeable. If it is, we will hear about it ;)

@DedeHai
Copy link
Collaborator

DedeHai commented Mar 25, 2026

RE: turn off transition issue - I would be cautios with any changes to transition code as it handles many edge cases and changing one thing can break another. I can check again on when this happens (i.e. only on turn-off/on or also on normal transitions) and then open a seperate issue to properly track it.

@softhack007
Copy link
Member Author

RE: turn off transition issue - I would be cautios with any changes to transition code as it handles many edge cases and changing one thing can break another. I can check again on when this happens (i.e. only on turn-off/on or also on normal transitions) and then open a seperate issue to properly track it.

makes sense - I think its a bigger topic and the risk to break stuff is high

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Awaiting testing optimization re-working an existing feature to be faster, or use less memory

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants