Restructures Application::loop() into two independent phases to stop the
scheduler from silently pulling the component loop cadence forward.
Before: Application::loop() bounded its sleep by
min(loop_interval_ - elapsed, next_schedule_in())
with a delay_time/2 floor. Any scheduler item due sooner than
loop_interval_/2 dragged the whole component phase with it. On a typical
ESP32 config with default loop_interval_=16ms, combined scheduler
activity from api / esp32_ble / esp32_ble_tracker / debug was keeping
every component's loop() running at ~128 Hz instead of the documented
~62 Hz.
This has become more visible recently as more components convert to
PollingComponent (which uses set_interval internally) and more in-tree
code uses set_interval / set_timeout directly. Adding or removing any
scheduled item silently changed every other component's loop cadence.
App.set_loop_interval() for power savings was also silently defeated.
After:
- Phase A (every tick): drain wake notifications, run scheduler.call(),
feed WDT
- Phase B (gated by loop_interval_ or HighFrequencyLoopRequester):
iterate registered components and update last_loop_
Sleep = min(time-until-next-component-phase, next_schedule_in()). When
a scheduler event wakes us early, Phase A services it and the component
phase stays gated independently. loop_interval_ is now a true minimum
interval between component phases.
The delay_time/2 floor is removed. Any legitimate need to wake faster
than loop_interval_ has proper mechanisms:
- HighFrequencyLoopRequester for sustained fast-loop needs
- Application::wake_loop_threadsafe() from any context (new in 2026.4.0)
for one-shot wake-on-event
Also guards against set_interval(0) misuse — it asks the main loop to
spin forever, which was never the intended API. Warns at creation time
pointing authors at HighFrequencyLoopRequester. set_timeout(0)/defer()
is unaffected; zero-delay one-shots remain legitimate.
Runtime stats: process_pending_stats is now called on every tick (not
just when the component phase runs) so log_interval_ isn't quantized to
the component-phase cadence. Added an inline fast-path gate in
runtime_stats.h that early-outs unless now >= next_log_time_, keeping
Application::loop() slim; the log_stats_ work stays out-of-line.
Ordering constraints preserved:
- defer() callbacks still FIFO before components same-tick (Phase A
runs before Phase B)
- Scheduled items still execute before components when both due
- Scheduled callbacks still run on main thread only
- loop_component_start_time_ is still set fresh at each component's loop
- WDT is still fed at least once per tick
- Widen period/total iteration counters to uint64_t to avoid wrapping on
long-running high-frequency loops.
- Compute total component-time sum over all components (not just the
period-active subset) so total overhead is not inflated by components
that ran earlier but are idle now.
- Extend integration test to parse the main_loop line and validate the
iters/active_avg/active_total/overhead_total fields.
- mock_addressable_light.h: add direct <memory>/<cstdint>/<cstddef> includes
- test: use asyncio.get_running_loop() instead of deprecated get_event_loop()
- test: rebase timing to command-issue time (not first-nonzero) and use
absolute progress for assertion 2, so late-transition check can't skew
when the first nonzero sample happens to land near the assertion-1 limit
When a uniform-colored addressable strip transitions from one color to
another, interpolate math-only against a cached start color instead of
reading each LED's current value back through the 8-bit stored byte.
The old algorithm used led.get_red()/etc. every step as the source for
the delta, which round-tripped through gamma uncorrect/correct and the
8-bit stored byte. At gamma 2.8, any pre-gamma value below ~27 rounds
to stored byte 0, so small early-transition steps produced stored 0 and
the next step read back 0, stalling progress until ~90% of the transition
before a single step produced a large-enough pre-gamma value to clear
the gamma threshold. Result: dark for the first 9s of a 10s fade, then
jump on in the final 1s.
Detect uniform start state in start() and take a cheap math-only lerp
path when true, so the stored byte advances through each gamma threshold
as smoothed_progress crosses it. Falls back to the existing per-LED
read-back algorithm when the buffer is non-uniform (e.g. when
transitioning out of an addressable effect).
- get_app_state(): say 'STATUS_LED_* only', not 'STATUS_LED_* and lifecycle'
since lifecycle bits are no longer maintained in app_state_.
- STATUS_LED_SETTLE_S: remove incorrect mention of feed_wdt re-dispatch;
status_led_light is driven by the main loop, not feed_wdt.
- snapshot_led service: say 'status_led_light output' not 'pin state'
since the fixture uses a template output, not GPIO.
Extract the 0.3s magic sleep into a named constant explaining why that
duration is chosen (feed_wdt re-dispatches every ~3 ms; 300 ms gives
~100 opportunities). Fix the idle-write check to snapshot AFTER the
clear instead of before it, so writes still in-flight from the error
phase don't inflate the delta.
Verifies that after clearing all status flags, re-setting a flag makes
status_led_light resume writing to its output. Guards against a future
idle optimization (like #15642) where status_led disables its own
loop() when idle: if the re-enable path were broken, the second set
would not produce writes.
Also checks that writes STOP after all flags are cleared (counter
should not keep growing), proving status_led_light correctly stops
blinking in steady state.
Drop the trailing underscore from any_component_has_status_flag now
that the method is public. Trailing underscores in the codebase are
reserved for protected/private members per clang-tidy naming rules,
which caused a CI failure on the previously-named public helper.
Add integration tests covering:
- Single-component status_set/clear for warning and error
- Multi-component OR semantics (both clear orders)
- Warning and error independence
- End-to-end proof that status_led_light::loop() reads App.app_state_
and writes its output when the bits are set (via a fake template
output whose write_action bumps a counter exposed as a sensor)
When an interval fires in Scheduler::call(), push it directly back into
items_ via push_back() + push_heap() instead of routing through the
to_add_ staging vector and process_to_add_slow_path_().
This eliminates per-cycle overhead of process_to_add_slow_path_() which
acquires a lock, iterates to_add_, pushes each item into the heap, clears
the vector, and resets the atomic counter.
- Midea: frost protection preset now set once in on_status_change()
when autoconf completes, guarded by a flag
- BedJet: custom fan modes and presets moved from traits() to setup()
- Test fixture: use has_custom_fan_mode() instead of nullptr check