The single-message case (via write_protobuf_packet) is the most common
path. Peeling the first loop iteration and outlining the multi-message
batch path avoids the ~300-byte StaticVector<iovec> stack allocation
on the hot path.
Plaintext write_protobuf_messages:
- Stack frame: 352 → 64 bytes
- Code size: 246 → 127 bytes
Noise write_protobuf_messages:
- Extracted encrypt_noise_message_ helper for reuse
- Same peeling pattern with outlined batch path
Use real TCP sockets instead of AF_UNIX socketpair so TCP_NODELAY
succeeds during init() and the benchmark exercises the full write
path. Replace hardcoded message type 38 with SensorStateResponse::MESSAGE_TYPE.
Avoid benchmarking heap allocation by pre-reserving the buffer
to typical TCP MSS size and reusing it across iterations, matching
real-world usage where the buffer persists across writes.
Extract the gamma table generation into a public generate_gamma_table()
function and add unit tests covering table properties and the
zero_means_zero regression from #15055.
The gamma LUT refactor (#14123) introduced a regression where small
brightness values (e.g. 1%) get quantized to exactly 0.0 because
the uint16 LUT entries round down to 0 for indices 1-3 with the
default gamma of 2.8.
This breaks zero_means_zero: true in FloatOutput because the
min_power scaling is skipped when state == 0.0, causing LEDs to
turn off completely instead of respecting the configured min_power.
Fix by clamping non-zero LUT entries to a minimum of 1, preserving
the invariant that non-zero input always produces non-zero output.
Fixes#15055
- Combine tag byte + fixed32 value into single write_tag_and_fixed32()
method: pos[0] = tag, memcpy(pos+1, &value, 4), pos += 5
- Extract calculate_tag() from duplicated computation in
calculate_field_id_size() and encode_content
- Combine tag byte + fixed32 value into single write_tag_and_fixed32()
method: pos[0] = tag, memcpy(pos+1, &value, 4), pos += 5
- Extract calculate_tag() from duplicated computation in
calculate_field_id_size() and encode_content
Instead of ALWAYS_INLINE on encode_field_raw (which bloated all
callers), have the code generator precompute the tag byte and emit
write_raw_byte(tag) + write_fixed32_raw(value) directly.
This gives the same tight codegen (single byte store + memcpy) for
key fields without inflating encode_bool/encode_uint32/etc.
+108 bytes flash vs baseline, -48 bytes vs the ALWAYS_INLINE approach.
- Add ESPHOME_ALWAYS_INLINE to encode_fixed32 so the compiler inlines it
on hot paths despite -Os heuristics (removing noinline alone was not
enough — gcc still chose not to inline at 51 call sites)
- Mark all fixed32 key fields in api.proto with [(force) = true] since
entity keys are FNV hashes and never zero, eliminating the zero-check
branch and making calculate_size() use constants
+96 bytes flash (555167 → 555263) on ESP32 — negligible for removing
branch + call overhead on every sensor state encode.
Profiling showed the noinline call overhead dominated hot paths like
SensorStateResponse encoding, where encode_fixed32 is called twice
per message (once for key, once via encode_float for state).
The function body is trivial (tag byte + 4-byte memcpy), so the
function call prologue/epilogue cost exceeded the actual work.
Despite 51 call sites, removing noinline shows no measurable flash
size increase (555167 bytes before and after on ESP32).
On some ESP32 boards (especially cheap clones), the eFuse custom MAC
area contains random garbage that passes the existing all-zeros/all-ones
validation. Additionally, esp_efuse_mac_get_default() can fail with CRC
errors, but the return value was being ignored, causing garbage MAC
addresses to be advertised via mDNS.
This caused Home Assistant to report false "MAC address changed" device
conflicts on every boot.
Two fixes:
- Check return values from eFuse MAC read functions and add a fallback
chain: custom MAC -> default MAC -> raw eFuse bytes -> zeroed MAC.
- Reject multicast MACs (bit 0 of first byte set) in mac_address_is_valid()
since device MACs must always be unicast.
Closes https://github.com/esphome/esphome/issues/14501
Regression tests for #15040 using a single compiled binary with two
cwww lights to verify:
- constant_brightness: true maintains constant total CW+WW power
output across all color temperatures with gamma correction
- constant_brightness: false correctly varies total power (higher
at mid-range where both channels contribute)
The gamma LUT refactor (#14123) moved gamma correction to after
the constant_brightness balancing formula (max/sum ratio). This
broke constant_brightness because gamma is nonlinear and does not
commute with the ratio calculation, causing a severe brightness
dip at mid-range color temperatures.
Fix by applying gamma to individual CW/WW/brightness values
before the constant_brightness formula, restoring the original
behavior where total power output remains constant across all
color temperatures.
Closes#15040
- Update comments to reflect that ProtoService methods moved to
APIConnection, not APIServerConnectionBase
- Fix comment referring to read_message as "override"
- Wrap #include "api_connection.h" and read_message_ implementation
in #ifdef USE_API guards
The virtual destructor was unnecessary since APIConnection is only
stored as unique_ptr<APIConnection>, never via a base class pointer.
Removing it eliminates the vtable entirely.
Rename read_message to read_message_ per clang-tidy naming convention
for protected methods.
ProtoService was an abstract interface with 6 pure virtual methods,
but APIConnection was the only concrete implementation. Move all
functionality directly into APIConnection and remove the unnecessary
virtual dispatch and vtable overhead.
check_connection_setup_() and check_authenticated_() call virtual
methods (is_connection_setup, on_no_setup_connection). When defined
in ProtoService, the compiler cannot devirtualize these calls.
Moving them to APIConnection (final) enables devirtualization.
Move read_message() from APIServerConnectionBase into APIConnection
and drop the virtual keyword from all 64 on_* handler declarations.
Since APIConnection is final and the only subclass, the virtual
dispatch was unnecessary. With read_message and the on_* handlers
in the same class, the compiler can devirtualize the calls and
inline small handlers directly into the switch cases.
Move before_loop_tasks_() to application.h as always_inline.
This removes one stack frame between the main loop and
scheduler.call(), which matters for timer callbacks that
trigger deeply nested operations.
Inline yield_with_select_ for ESP8266/RP2040 (socket_delay) and
no-socket (delay) paths in addition to the LWIP_FAST_SELECT path.
Only the select() fallback (host platform) remains in the .cpp.
This ensures yield_with_select_ is inlined into the loop on all
embedded platforms, not just ESP32/LibreTiny.
The play_complex overrides cost flash per template instantiation
across every automation. The always_inline on the forwarding chain
(Trigger::trigger, Automation::trigger, ActionList::play) is a
fixed cost that collapses 3 frames into 1.
LambdaAction and ContinuationAction overrides caused flash bloat
by replacing shared base class play_complex instantiations with
per-class copies. StatelessLambdaAction is the most common
automation action and its no-args variant is only 24 bytes.