[core] Drop per-iteration app_state_ accumulation from main loop

Application::loop() used to, on every iteration of the inner component
loop, OR each component's state into a local accumulator and then OR
that into this->app_state_, finally overwriting this->app_state_ with
the accumulator after the loop. That was ~5 instructions per component
per loop iteration on the hot path, paying to rebuild state that's
already kept current elsewhere.

STATUS_LED_WARNING / STATUS_LED_ERROR are the only app_state_ bits
anything reads. Component::set_status_flag_() already writes them to
App.app_state_ directly the moment they're set, so any reader (status_led
components, feed_wdt's LED re-dispatch) already sees them without the
per-iter OR. The per-iter OR only ever re-set bits that were already
there — pure dead code on the hot path.

The clear path is handled by moving the work into
status_clear_warning/error_slow_path_(): on a real set->clear transition
the helpers walk App.components_ once to check if any other component
still has the flag, and clear App.app_state_ if not. Clears are rare
relative to loop iterations (a logged transition event), so O(N) per
clear is far cheaper than O(N) per loop.

Setup subtlety: Application::setup()'s slow-setup busy-wait forces
STATUS_LED_WARNING on to blink the LED while a slow component initializes.
A component clear during setup would prematurely wipe that forced bit,
so status_clear_warning_slow_path_() gates its walk-and-clear behind
APP_STATE_SETUP_COMPLETE (a free bit on app_state_ — zero RAM cost).
Application::setup() reconciles the warning state once at the end and
sets the flag. STATUS_LED_ERROR is never forced so its clear path
always walks.

Also fixes a pre-existing bug: the old per-iter accumulation only iterated
looping_components_, so non-looping components' status bits would get
clobbered by the end-of-loop 'app_state_ = new_app_state' overwrite.
The walk-on-clear approach iterates components_ (all of them), so
non-looping components are respected.
This commit is contained in:
J. Nick Koston
2026-04-11 16:05:46 -10:00
parent bef4c8a86c
commit d9c6b27cf2
4 changed files with 66 additions and 10 deletions
+30 -4
View File
@@ -85,8 +85,12 @@ void Application::setup() {
if (component->can_proceed())
continue;
// Force the status LED to blink WARNING while we wait for a slow
// component to come up. Cleared after setup() finishes if no real
// component has warning set.
this->app_state_ |= STATUS_LED_WARNING;
do {
uint8_t new_app_state = STATUS_LED_WARNING;
uint32_t now = millis();
// Process pending loop enables to handle GPIO interrupts during setup
@@ -96,17 +100,26 @@ void Application::setup() {
// Update loop_component_start_time_ right before calling each component
this->loop_component_start_time_ = millis();
this->components_[j]->call();
new_app_state |= this->components_[j]->get_component_state();
this->app_state_ |= new_app_state;
this->feed_wdt();
}
this->after_loop_tasks_();
this->app_state_ = new_app_state;
yield();
} while (!component->can_proceed() && !component->is_failed());
}
// Setup is complete. Reconcile STATUS_LED_WARNING: the slow-setup path
// above may have forced it on, and any status_clear_warning() calls
// from components during setup were intentional no-ops (gated by
// APP_STATE_SETUP_COMPLETE). Walk components once here to pick up the
// real state. STATUS_LED_ERROR is never artificially forced, so its
// clear path always works and needs no reconciliation. Finally, set
// APP_STATE_SETUP_COMPLETE so subsequent warning clears go through
// the normal walk-and-clear path.
if (!this->any_component_has_status_flag_(STATUS_LED_WARNING))
this->app_state_ &= ~STATUS_LED_WARNING;
this->app_state_ |= APP_STATE_SETUP_COMPLETE;
ESP_LOGI(TAG, "setup() finished successfully!");
#ifdef USE_SETUP_PRIORITY_OVERRIDE
@@ -211,6 +224,19 @@ void HOT Application::feed_wdt(uint32_t time) {
#endif
}
}
bool Application::any_component_has_status_flag_(uint8_t flag) const {
// Walk all components (not just looping ones) so non-looping components'
// status bits are respected. Only called from the slow-path clear helpers
// (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an
// actual set→clear transition, so walking O(N) here is paid once per
// transition — not once per loop iteration.
for (auto *component : this->components_) {
if ((component->get_component_state() & flag) != 0)
return true;
}
return false;
}
void Application::reboot() {
ESP_LOGI(TAG, "Forcing a reboot");
for (auto &component : std::ranges::reverse_view(this->components_)) {
+18 -6
View File
@@ -401,7 +401,24 @@ class Application {
*/
void teardown_components(uint32_t timeout_ms);
uint8_t get_app_state() const { return this->app_state_; }
/// Return the public app state bits (STATUS_LED_* and lifecycle).
/// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked
/// out so external readers (status_led components, etc.) never see them.
uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; }
/// True once Application::setup() has finished walking all components
/// and finalized the initial status flags. Before this point, the
/// slow-setup busy-wait may be forcing STATUS_LED_WARNING on, and
/// status_clear_* intentionally skips its walk-and-clear step so the
/// forced bit doesn't get wiped. Stored as a free bit on app_state_
/// (bit 6) to avoid costing additional RAM.
bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; }
/// Walk all registered components looking for any whose component_state_
/// has the given flag set. Used by Component::status_clear_*_slow_path_()
/// to decide whether to clear the corresponding bit on this->app_state_
/// (which is the app-wide "any component has this status" indicator).
bool any_component_has_status_flag_(uint8_t flag) const;
// Helper macro for entity getter method declarations
#ifdef USE_DEVICES
@@ -838,8 +855,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_
}
inline void ESPHOME_ALWAYS_INLINE Application::loop() {
uint8_t new_app_state = 0;
// Get the initial loop time at the start
uint32_t last_op_end_time = millis();
@@ -859,13 +874,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
// Use the finish method to get the current time as the end time
last_op_end_time = guard.finish();
}
new_app_state |= component->get_component_state();
this->app_state_ |= new_app_state;
this->feed_wdt(last_op_end_time);
}
this->after_loop_tasks_();
this->app_state_ = new_app_state;
#ifdef USE_RUNTIME_STATS
// Process any pending runtime stats printing after all components have run
+13
View File
@@ -411,10 +411,23 @@ void Component::status_set_error(const LogString *message) {
}
void Component::status_clear_warning_slow_path_() {
this->component_state_ &= ~STATUS_LED_WARNING;
// Clear the app-wide STATUS_LED_WARNING bit only if setup has finished
// AND no other component still has it set. During setup the forced
// STATUS_LED_WARNING (from the slow-setup busy-wait) must not be wiped
// by a transient component clear — Application::setup() reconciles
// the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE.
// The set path is unchanged (set_status_flag_ still writes directly).
if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING))
App.app_state_ &= ~STATUS_LED_WARNING;
ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str()));
}
void Component::status_clear_error_slow_path_() {
this->component_state_ &= ~STATUS_LED_ERROR;
// STATUS_LED_ERROR is never artificially forced — it only ever lands
// in app_state_ via a real set_status_flag_ call. So the walk-and-clear
// path is always safe, including during setup.
if (!App.any_component_has_status_flag_(STATUS_LED_ERROR))
App.app_state_ &= ~STATUS_LED_ERROR;
ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str()));
}
void Component::status_momentary_warning(const char *name, uint32_t length) {
+5
View File
@@ -89,6 +89,11 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08;
inline constexpr uint8_t STATUS_LED_ERROR = 0x10;
// Component loop override flag uses bit 5 (set at registration time)
inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20;
// Bit 6 on Application::app_state_ (ONLY) — set at the end of
// Application::setup(). Component::status_clear_*_slow_path_() uses this to
// decide whether to propagate clears to App.app_state_. Never set on a
// Component's component_state_.
inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40;
// Remove before 2026.8.0
enum class RetryResult { DONE, RETRY };