Replace paired before_component_phase_() / after_component_phase_() calls
with a ComponentPhaseGuard whose constructor processes pending enable_loop
requests and sets in_loop_ = true, and whose destructor clears in_loop_
at scope exit.
The guard methods are ESPHOME_ALWAYS_INLINE. Verified byte-identical
.text/.data/.bss output on an ESP32 IDF build, and identical disassembly
for both esphome::loop_task (where Application::loop is inlined) and
Application::setup.
Benefit: symmetry is now enforced by the type system. Future early
returns or refactors inside the component phase can't accidentally leave
in_loop_ set without a matching reset.
Change the view to hold `const unique_ptr<APIConnection>&` so external
callers can't reset() or move slots — which would break the
api_connection_count_ invariant. Callers still get a non-const
APIConnection& on dereference (via `(*ptr).method()`), so all existing
call sites work unchanged.
active_clients() is now const-qualified since it only returns a view
over const unique_ptrs.
With N=5 the static-RAM trade becomes net-negative at just 1 client
(the typical ESP32 deployment — just Home Assistant):
+8 B static (5 slots × 4 B − 12 B removed vector object)
−12 B heap (8 B header + 4 B slot) at 1 client
= −4 B net
N=6 had breakeven at 2 clients; N=5 puts breakeven at 1 client so the
common case is pure win.
5 slots covers HA + dashboard + 1 reconnecting socket, which is all
most deployments ever use. Users needing more can bump explicitly in
YAML (schema max is 20).
host stays at 8 (no BSS-slot concern). esp8266 stays at 4, rp2040
stays at 4.
Revert host=6 to host=8 — the host platform has no BSS-slot concern
(no flash/RAM pressure). The lowering to 6 still applies to
esp32/bk72xx/rtl87xx/ln882x where every array slot costs static RAM.
Users who need more than 6 concurrent clients can bump max_connections
up to the schema's max of 20.
is_connected_with_state_subscription() was the only const caller of
active_clients(). Iterating by index directly in that method lets us
drop the entire ConstActiveClientsView class from the header, leaving
a single ActiveClientsView for the mutable range-for sites.
Convert APIServer's client container from
`std::vector<std::unique_ptr<APIConnection>>` to a compile-time sized
`std::array<..., MAX_API_CONNECTIONS>` with an inline `uint8_t
api_connection_count_` tracking the active slice.
Why:
- Eliminates a persistent heap-held buffer (one of APIServer's
fragmentation sources). The vector grows via doubling reallocations
(0 -> 1 -> 2 -> 4 -> 8) and keeps its capacity for the life of the
process.
- Kills ~100-150 bytes of `_M_realloc_insert` template instantiation
per build (measured: -92 B on ESP32-S3, -152 B on ESP8266).
- is_connected() becomes `api_connection_count_ != 0` — a single-byte
load, down from two pointer loads + compare.
- The count lives in what was already 1 byte of padding after
shutting_down_, replacing the removed max_connections_ runtime
setter. Zero size overhead in that slot.
- Max connections is now a compile-time constant via
cg.add_define("MAX_API_CONNECTIONS", ...), so the accept cap check
and the array size derive from the same value.
Also lower default max_connections on mid/high-RAM platforms from 8 to
6. With a compile-time array, every slot costs 4 bytes of static RAM
whether a connection ever uses it or not — 6 covers HA + dashboard +
spares without paying the full 8-slot static cost. esp8266 stays at 4,
rp2040 stays at 4.
Mechanical churn:
- 13 `for (auto &c : this->clients_)` -> `for (auto &c : this->active_clients())`,
where active_clients() returns a pointer-range view over the active
slice.
- `.empty()` / `.size()` -> comparisons against api_connection_count_.
- `.emplace_back()` / `.pop_back()` / `.back()` rewritten in the
swap-and-pop path to index into the array and reset() the freed slot
(maintains the invariant that slots [count, N) are always nullptr).
Measured on ESP32-S3 (zwave-proxy-seeedw5500, N=6):
RAM: 32464 -> 32480 B (+16 B static, but no heap buffer)
Flash: 469011 -> 468919 B (-92 B)
Measured on ESP8266 (basic8266, N=4):
RAM: 29268 -> 29268 B (unchanged at this granularity)
Flash: 323847 -> 323695 B (-152 B)
Add static_asserts that the SEND_ACK/CAN/NAK states stay contiguous, so the
inline range check in response_handler_() fails at compile time if the enum
is ever reordered.
Document that process_uart_slow_() requires available() > 0 at the
declaration (the .cpp definition already carries the rationale).
Move the api_is_connected() definition from util.cpp to util.h and mark it
ESPHOME_ALWAYS_INLINE. The body is trivial — a nullptr check on
global_api_server plus APIServer::is_connected() (which is
!clients_.empty()) — so the out-of-line call8 was pure overhead for hot
paths that check connectivity every loop tick (e.g. zwave_proxy::loop,
serial_proxy::loop).
With USE_API disabled the function collapses to "return false" at compile
time and folds away entirely.
util.h now pulls in api_server.h under USE_API. Only 25 .cpp files include
util.h and all of them are on USE_API builds in practice (wifi, safe_mode,
nextion, web_server, etc.), so the additional transitive include is
essentially free on the platforms that matter.
Measured on ESP32-S3: the call8 in ZWaveProxy::loop() is replaced by
5 inline Xtensa instructions (load global_api_server, deref, null check,
clients_ start/finish compare) — no call overhead, no stack frame.
Split response_handler_ and process_uart_ into tiny inline wrappers in the
header that short-circuit the common case, plus _slow_ bodies in the .cpp.
- response_handler_: most loop() ticks have parsing_state_ outside the three
SEND_* states; inline the range check and skip the call8 entirely.
- process_uart_: most ticks have no UART bytes pending; inline an available()
check and skip the out-of-line call + its stack frame. Inside the slow
path, switch the while() to do/while() since the caller has already
confirmed available() > 0.
ESPHOME_ALWAYS_INLINE is required — with -Os gcc otherwise clones the
wrapper into a shared \$isra\$ outline and keeps the call8.
Measured on ESP32-S3 zwave-proxy-seeedw5500 build: idle-tick out-of-line
calls in ZWaveProxy::loop() drop from 4 (response_handler_, api_is_connected,
virtual parent_->is_connected, process_uart_ which nested available()) to 3
(api_is_connected, virtual is_connected, available).
Convert buf_append_printf calls that use only literal format strings
or %s specifiers over to buf_append_str, which avoids pulling in
printf machinery on all platforms and keeps literals in flash on
ESP8266 via the PSTR()-wrapping macro introduced in #15738.
Format strings with numeric specifiers (PRIu32, %u, PRIX32, etc.)
are kept on buf_append_printf. Runtime %s calls in debug_esp8266.cpp
stay as-is because the ESP8266 buf_append_str macro requires a
string literal for PSTR().
Previously loop_tail_start_us defaulted to loop_before_end_us, so on
ticks where Phase B was gated out (loop_interval_ not yet elapsed, no
wake, no high-frequency request) tail_us measured "time from end of
Phase A to stats recording" — i.e. gate-check + stats-prefix overhead —
and was accumulated into the per-tick "tail" bucket even though no
component tail had actually run.
That mis-attributed gate-check + stats-prefix overhead to the tail
metric, which is supposed to represent trailing overhead of the
component phase specifically (after_component_phase_ + the little bit
before record_loop_active). On a device whose loop_interval_ is raised
for power savings, Phase A-only ticks dominate and the mis-attributed
tail would skew the stats report.
Fix: gate the tail_us computation on do_component_phase. Initialize
loop_tail_start_us to 0 (unused on Phase A-only ticks) and only
subtract from loop_now_us when Phase B ran. The overhead that used to
land in "tail" now falls into "residual" (active − before − components
− tail), which is the correct bucket for per-iteration bookkeeping that
is not phase-specific.
Matches the before_component_phase_ / after_component_phase_ symmetry
now that Phase A and Phase B are separated. Also move the call to the
very end of the `if (do_component_phase) { ... }` block so the helper
semantically closes out the phase, rather than sitting before the
last_loop_ / now bookkeeping.
No functional change: in_loop_ is read only inside the component
iteration (disable_looping_component's swap fixup), so setting it false
at any point after the for-loop ends is equivalent. Runtime-stats tail
timing still captures the same region (the micros() sample happens
before any of the moved lines).