- Replace inverted comments in set_min_power/set_max_power clamp lines
(MIN>=MAX>=1.0 → min_power <= max <= 1.0) — pre-existing bug.
- Update FloatOutput class docstring to describe the conditional scaling
behavior under USE_OUTPUT_FLOAT_POWER_SCALING.
- Reword the zero_means_zero codegen comment to explain why we gate on
the value (schema default=False would otherwise force the define on).
- Add templated static_assert stubs for set_min_power/set_max_power/
set_zero_means_zero in the #else branch so calls from lambdas
(documented at esphome.io/components/output/#output-set_min_power_action)
produce a clear compile error pointing at the user's lambda site, with
the migration instruction inline (add 'min_power: 0%' / 'max_power: 100%'
/ 'zero_means_zero: true' to one output entry to enable scaling).
Templating on a default-false bool means the assert only fires on
instantiation (i.e. when the user actually calls the method), not on
every parse — so unused stubs in TUs that include the header (e.g.
output/automation.cpp when scaling actions aren't registered) don't
break the build.
Verified: a lambda calling id(out).set_min_power(0.2) without min_power
in YAML now fails compilation with a pointer at the lambda line and the
inline migration message; adding min_power: 0% to the output entry makes
the same config build clean.
The min_power / max_power / zero_means_zero scaling support on FloatOutput
costs 12 bytes per instance (max_power_, min_power_, zero_means_zero_ +
alignment padding) on every PWM channel, DAC channel, LEDC output, and
dimmer-chip channel — even on configs that never touch the feature.
Repo-wide usage is ~17 YAML lines, mostly in test fixtures and a couple
of LED-driver chip tests; the runtime set_min_power / set_max_power
actions added in #8934 have no usage outside the action's own test.
Add USE_OUTPUT_FLOAT_POWER_SCALING and gate the fields and scaling math
in FloatOutput::set_level() behind it, mirroring the USE_POWER_SUPPLY
pattern already used in BinaryOutput. Python codegen flips the define on
whenever:
- a min_power / max_power / zero_means_zero key is set on any output, or
- a non-default zero_means_zero value is provided, or
- an output.set_min_power / output.set_max_power action is registered
The action class templates (SetMinPowerAction, SetMaxPowerAction) are
also gated on the same define so their non-dependent member access on
FloatOutput::set_min_power doesn't fail to parse when the methods aren't
compiled in. zero_means_zero_ now has a default initializer (was UB
before — it was always written from setup, but only because the schema
default forced it).
For configs without scaling: 12 B .bss saved per FloatOutput instance,
plus a small flash saving from the elided multiply/subtract in
set_level(). For configs with scaling: behavior is unchanged.
Verified on tests/components/esp8266_pwm (no scaling): pstorage 0x28 → 0x1c
per output (40 B → 28 B). Verified on tests/components/output (uses
set_min_power/set_max_power actions): builds correctly with the define on.
On Xtensa under default -mserialize-volatile, GCC emits a memw before
every volatile load. esphome_lwip_socket_has_data() is called once per
socket per main-loop iteration (one for the listening socket in
APIServer::loop and one per connected client in APIConnection::loop),
making per-call memw a measurable cost on the idle path.
Replace the per-call memw with a single std::atomic_thread_fence
(memory_order_acquire) at the top of Application::loop. The fence pairs
with the TCP/IP thread's existing SYS_ARCH_UNPROTECT release on
rcvevent. The wake path (xTaskNotifyGive in the lwip event_callback
hook, ulTaskNotifyTake at the bottom of the loop) is independent of
rcvevent visibility and is non-losing, so writes that land between the
fence and sleep are picked up by the next iteration.
Gated on ESPHOME_THREAD_MULTI_ATOMICS so that BK72xx (which lacks
LDREX/STREX and is built without libatomic) keeps the original volatile
load path. ESP32, RTL87xx and LN882x get the optimization; ESP8266,
RP2040 and host use the socket_ready_fd fallback and are unaffected.
Disassembly (ESP32, gatetrigger): APIServer::loop -8 B, APIConnection
::loop -5 B; loop_task gains one memw for the fence. Net per idle
iteration with N clients: save N memw on ready paths, add 1 for the
fence (savings scale with client count and other Socket::ready callers
like AsyncClient and CaptivePortal DNS).
EsphomeLogsHandler.build_command now appends --no-states to the
spawned `esphome logs` argv when the WebSocket spawn message
includes `no_states: true`. This lets the dashboard frontend
suppress entity-state log lines for OTA log sessions without
requiring users to drop to the CLI.
Adds three unit tests covering the truthy, missing, and explicit-False
cases.
Keeps the structural improvements from #15347 while reverting only the
loop()->set_interval move that became a pessimization after #15792.
- flush_pending_advertisements_() inlined in header (was: out-of-line .cpp)
- log_advertisement_flush_() out-of-line, gated by ESPHOME_LOG_LEVEL_VERBOSE
- loop() gates both halves (flush + connection cleanup) at 100ms cadence
rather than running cleanup at every Phase B tick
This is now a partial revert of #15347 rather than a full revert.
- script/build_helpers.py: when injecting a non-MULTI_CONF component
into the post-validation config, run its CONFIG_SCHEMA with {} so
defaults are populated. Without this, socket got config = {} and
socket.FILTER_SOURCE_FILES crashed with KeyError on
'implementation' (the schema's defaulted key was never filled in).
Falls back to {} if the schema can't validate empty input.
- tests/components/json/__init__.py: enable codegen for json so its
to_code runs during cpp unit test builds, registering the
ArduinoJson library. Required for any api dep test, since
json_util.cpp #includes <ArduinoJson.h>.
Locally verified 'script/cpp_unit_test.py api' now compiles and runs;
ProtoMacVarint test suite (9 cases) passes.
Three fixes so 'script/cpp_unit_test.py api' actually compiles instead
of crashing in build setup:
1. script/build_helpers.py: when adding transitive component
dependencies to the post-validation config, use {} (dict) instead
of [] (list) for non-MULTI_CONF components. socket's
FILTER_SOURCE_FILES (and any other code that subscripts
CORE.config[component] with a string key) was crashing because
socket got config = [] from setdefault.
2. esphome/components/api/api_pb2_service.cpp + the codegen in
script/api_protobuf/api_protobuf.py: wrap the generated
APIConnection::read_message_ definition in #ifdef USE_API. The
class itself is only declared inside #ifdef USE_API in
api_connection.h, so without the guard the .cpp fails to compile
in any build that pulls in the api source files without setting
USE_API (e.g. cpp unit tests of api dependencies).
- Test file: declare proto_debug_end_ locally instead of misusing
PROTO_ENCODE_DEBUG_INIT (which expands to a comma+expression for
appending to a function call, not a standalone statement). Add
NOLINTNEXTLINE on the deterministic mt19937_64 seed so clang-tidy
cert-msc32-c stops failing the build (the seed is intentional for
reproducible test runs).
- socket FILTER_SOURCE_FILES: tolerate non-dict CORE.config['socket']
(e.g. C++ unit-test builds where socket isn't validated as a
mapping). Returning [] is safe -- all impl files are guarded by
USE_SOCKET_IMPL_* defines so only the selected one contributes
code.
Verifies encode_varint_raw_48bit and calc_uint64_48bit_force for the
8 corner-case MAC addresses requested in review:
00:00:00:00:00:00, 11:00:00:00:00:00, 00:AA:00:00:00:00,
00:00:BB:00:00:00, 00:00:00:CC:00:00, 00:00:00:00:DD:00,
00:00:00:00:00:EE, FF:FF:FF:FF:FF:FF
For each value the test asserts byte-identical output to the reference
encode_varint_raw_64 loop, the expected encoded byte length, agreement
with calc_uint64_48bit_force, and round-trip through a generic varint
decoder. Adds a 100-value deterministic-random sample across the full
48-bit space for additional coverage.
Copilot flagged that encode_varint_raw_48bit/calc_uint64_48bit_force
would silently truncate for uint64 values >= 2^48. In practice the
(mac_address) option is only applied to fields populated by the BLE
stack, which always fits in 48 bits -- so the runtime upper-bound
check added in ba362a7c95 regressed CodSpeed by up to 8.7pp on
CalculateSize_BLERawAdvs12 for a scenario that can't happen.
Move the value-fits-in-48-bits check to a debug assert guarded by
ESPHOME_DEBUG_API, and express 48 via MAC_ADDRESS_SIZE * 8 so the
threshold tracks the existing MAC size constant. Release builds are
back to the original fast path; debug builds catch misuse.
Per Copilot review: encode_varint_raw_48bit and calc_uint64_48bit_force
would silently truncate bits 48..63 if ever called with a uint64 that
doesn't fit in 48 bits. Real MAC addresses always fit, but since the
helpers are exposed and the (mac_address) option is generic, narrow
the fast path to [1<<42, 1<<48) so values outside that range fall back
to the general encoder/size helpers.
Re-verified byte-identical output and decoder round-trip across the
1<<48 boundary and all bit positions up to 63.
Adds a (mac_address) field option that switches uint64 fields holding
48-bit MAC addresses to a specialized varint encoder. The fast path
emits exactly 7 bytes when bits [42..47] are non-zero (the common case
for real MACs, since OUIs occupy the top 24 bits) -- one bounds check
and 7 independent stores instead of the 7-iteration shift+branch loop.
calc_uint64_48bit_force mirrors the same fast path in size calculation.
Applied to BluetoothLERawAdvertisement.address, the per-advertisement
address encode in BluetoothLERawAdvertisementsResponse drops from a
serialized per-byte loop to straight-line code.
Fold the to_remove_empty check, cleanup_slow_path_ call, and
MAX_LOGICALLY_DELETED_ITEMS threshold check into a single hot-path
branch + cold outlined helper.
Before this change, the cleanup block in Scheduler::call compiled to
two independent memw + l32i sequences (one for to_remove_empty_() inside
cleanup_(), one for the separate to_remove_count_() check) because GCC
cannot CSE across the memw barriers that std::atomic<uint32_t>::load
emits on Xtensa.
A previous attempt at collapsing these into a single inline branch
(#15985) had the right assembly for the memw count but grew
Scheduler::call by a handful of bytes and rearranged the control flow
enough to nudge sched up ~0.5 us/iter on gatetrigger in practice.
This version goes further: cleanup_slow_combined_ is annotated
noinline + cold so the entire slow path (both reads, both calls) is
pulled out of Scheduler::call entirely. The hot path becomes:
memw; l32i a8, [to_remove_]; beqz skip
call8 cleanup_slow_combined_ ; unlikely, cold
and Scheduler::call's body shrinks 344 B -> 332 B (-12 B, below the dev
baseline). Adjacent code (feed_wdt_slow_, etc.) stays in the same flash
region, avoiding the cache-layout side-effects that made earlier
attempts a net loss on busier configs.
The [[unlikely]] attribute on the branch plus the cold attribute on the
helper give the compiler permission to keep the skip path straight and
push the call out-of-line.
Read to_remove_ once at the top of the cleanup block instead of loading
it twice (once via cleanup_() -> to_remove_empty_(), once again for the
MAX_LOGICALLY_DELETED_ITEMS check). GCC cannot CSE across the memw
barriers that std::atomic<uint32_t>::load emits on Xtensa, so both the
fast-path zero check and the max check were generating independent
memw + l32i sequences:
memw; l32i a8, [to_remove_]; beqz ... ; to_remove_empty_()
memw; l32i a8, [to_remove_]; bltui 5 ; to_remove_count_()
Reading the counter once into a register and branching on the result
collapses the common zero-case to a single memw + l32i + beqz. The
non-zero path pays one extra read after cleanup_slow_path_ (which may
have decremented the counter), but that path already takes lock_ so the
extra load is negligible.
Scheduler::call stays at 344 B (unchanged); no flash layout shift, so
adjacent code (feed_wdt_slow_ etc.) stays in the same cache lines and
the measurement can't be confounded by MMIO timing drift.
An earlier version of this change also marked Scheduler::millis_64()
ESPHOME_ALWAYS_INLINE to drop its out-of-line $isra$0 clone; that
grew Scheduler::call by 56 B, shifted feed_wdt_slow_ in flash, and
measurably regressed the wdt bucket on a busier winefridge.yaml
config, so the inlining is dropped. This change keeps only the memw
collapse.
Read to_remove_ once at the top of the cleanup block instead of loading
it twice (once via cleanup_() -> to_remove_empty_(), once again for the
MAX_LOGICALLY_DELETED_ITEMS check). GCC cannot CSE across the memw
barriers that std::atomic<uint32_t>::load emits on Xtensa, so both the
fast-path zero check and the max check were generating independent
memw + l32i sequences:
memw; l32i a8, [to_remove_]; beqz ... ; to_remove_empty_()
memw; l32i a8, [to_remove_]; bltui 5 ; to_remove_count_()
Reading the counter once into a register and branching on the result
collapses the common zero-case to a single memw + l32i + beqz. The
non-zero path pays one extra read after cleanup_slow_path_ (which may
have decremented the counter), but that path already takes lock_ so the
extra load is negligible.
Scheduler::call stays at 344 B (unchanged); no flash layout shift, so
adjacent code (feed_wdt_slow_ etc.) stays in the same cache lines and
the measurement can't be confounded by MMIO timing drift.
An earlier version of this change also marked Scheduler::millis_64()
ESPHOME_ALWAYS_INLINE to drop its out-of-line $isra$0 clone; that
grew Scheduler::call by 56 B, shifted feed_wdt_slow_ in flash, and
measurably regressed the wdt bucket on a busier winefridge.yaml
config, so the inlining is dropped. This change keeps only the memw
collapse.
Mirrors the existing bk72xx platform override, but auto-scales to the
user-configurable esp32.watchdog_timeout (CONFIG_ESP_TASK_WDT_TIMEOUT_S)
instead of hard-coding a constant: the feed interval is always 1/5 of
the configured task WDT timeout so the safety margin stays constant
across user configurations.
- default 5 s WDT -> 1000 ms feed interval (was 300 ms, -70% hits)
- 10 s WDT -> 2000 ms feed interval
- 60 s WDT (max) -> 12000 ms feed interval
esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so
every call costs tens of microseconds. At the normal ~62 Hz main loop
the old 300 ms cadence produced ~200 feed_wdt_slow_ hits per 60 s
period; the default 5s -> 1000 ms cuts that to ~60 hits (-70%).
Component-level feeds inside Component::loop() and scheduler items are
unaffected; they continue to call arch_feed_wdt after every operation,
so any op exceeding this rate-limit triggers a real feed naturally.
The rate-limit only applies to the outer guard in Application::loop()
that fires when nothing else fed recently.
esp32/__init__.py already constrains watchdog_timeout to >= 5 s (range
5-60 s), and a static_assert guards against anyone who tweaks sdkconfig
below that floor, ensuring the feed interval never drops below the
prior hardcoded 1000 ms value.
Measured on a live ESP32 IDF build (gatetrigger.yaml, default 5 s WDT)
via runtime_stats: wdt bucket dropped from 2.24 us/iter to 1.56 us/iter
- about 2.5 ms saved per 60 s window.
Mirrors the existing bk72xx platform override, but auto-scales to the
user-configurable esp32.watchdog_timeout (CONFIG_ESP_TASK_WDT_TIMEOUT_S)
instead of hard-coding a constant: the feed interval is always 1/5 of
the configured task WDT timeout so the safety margin stays constant
across user configurations.
- default 5 s WDT -> 1000 ms feed interval (was 300 ms, -70% hits)
- 10 s WDT -> 2000 ms feed interval
- 60 s WDT (max) -> 12000 ms feed interval
esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so
every call costs tens of microseconds. At the normal ~62 Hz main loop
the old 300 ms cadence produced ~200 feed_wdt_slow_ hits per 60 s
period; the default 5s -> 1000 ms cuts that to ~60 hits (-70%).
Component-level feeds inside Component::loop() and scheduler items are
unaffected; they continue to call arch_feed_wdt after every operation,
so any op exceeding this rate-limit triggers a real feed naturally.
The rate-limit only applies to the outer guard in Application::loop()
that fires when nothing else fed recently.
esp32/__init__.py already constrains watchdog_timeout to >= 5 s (range
5-60 s), and a static_assert guards against anyone who tweaks sdkconfig
below that floor, ensuring the feed interval never drops below the
prior hardcoded 1000 ms value.
Measured on a live ESP32 IDF build (gatetrigger.yaml, default 5 s WDT)
via runtime_stats: wdt bucket dropped from 2.24 us/iter to 1.56 us/iter
- about 2.5 ms saved per 60 s window.
Mirrors the bk72xx override: the ESP32 task WDT default timeout is 5s
(CONFIG_ESP_TASK_WDT_TIMEOUT_S), so feeding every 1000ms keeps a ~5x
safety margin while cutting per-iteration feed overhead substantially.
esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so
every call costs tens of microseconds. At the normal ~62 Hz main loop
the old 300ms cadence produced ~200 hits per 60s period; 1000ms cuts
that to ~60 hits per 60s (-70%). Measured on a live ESP32 IDF build
(gatetrigger.yaml) via runtime_stats: wdt bucket dropped from 2.24
us/iter to 1.56 us/iter - about 2.5 ms saved per 60s window, or
~0.7us/iter average.
Component-level feeds inside component loop() and scheduler items are
unaffected; they continue to call arch_feed_wdt after every operation,
so any operation that exceeds this rate-limit triggers a real feed
naturally. The rate-limit only applies to the outer guard in
Application::loop() that fires when nothing else fed recently.
Mirrors the bk72xx override: the ESP32 task WDT default timeout is 5s
(CONFIG_ESP_TASK_WDT_TIMEOUT_S), so feeding every 1000ms keeps a ~5x
safety margin while cutting per-iteration feed overhead substantially.
esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so
every call costs tens of microseconds. At the normal ~62 Hz main loop
the old 300ms cadence produced ~200 hits per 60s period; 1000ms cuts
that to ~60 hits per 60s (-70%). Measured on a live ESP32 IDF build
(gatetrigger.yaml) via runtime_stats: wdt bucket dropped from 2.24
us/iter to 1.56 us/iter - about 2.5 ms saved per 60s window, or
~0.7us/iter average.
Component-level feeds inside component loop() and scheduler items are
unaffected; they continue to call arch_feed_wdt after every operation,
so any operation that exceeds this rate-limit triggers a real feed
naturally. The rate-limit only applies to the outer guard in
Application::loop() that fires when nothing else fed recently.
Replace the CallResult struct return with a uint64_t &now_64_out
reference parameter. The struct return forced GCC on Xtensa to emit 8
redundant s32i stores after every call() (once into the sched_result
local plus once into a compiler-chosen temp slot), and the extra ABI
shuffling around the 16-byte return value was tight enough with the
esp_timer_get_time() MMIOs bracketing the wdt bucket that the wdt
measurement regressed from ~21 us/hit to ~36 us/hit on ESP32.
Out-param keeps the scheduler's now_64 write local to Scheduler::call
(written once to *now_64_out) and leaves the return path a single
uint32 in a10. The caller passes &sched_now_64_raw directly; no struct
copy. loop_task shrinks 988 B -> 960 B.
Disassembly verified: after Scheduler::call returns the only
instructions before the next micros() capture are:
mov.n a6, a10 ; save now
call8 esp_timer_get_time ; loop_after_sched_us
No duplicate struct stores.
Follow-up to the CallResult threading: move the 0-sentinel fallback
(if now_64 == 0, read the clock fresh) out of next_schedule_in and into
the single main-loop caller.
Effect on next_schedule_in: drops the uint32_t now parameter entirely
and removes the cold millis_64_from_(now) inlined clock read from the
function body. Verified in disassembly:
Scheduler::next_schedule_in 159 B -> 86 B (-46%)
The hot path is now just defer_empty + cleanup_ + top-of-heap compare +
return. No clock read anywhere in the function.
Application::loop handles the 0-sentinel with a single inline branch
right before the call; when call() fired items it invokes the public
Scheduler::millis_64_from() wrapper (new, exposes the existing protected
millis_64_from_ so callers can do 64-bit extension without platform
knowledge). Fast path (no items fired) stays branch-free.
Net code size: loop_task +64 B, next_schedule_in -73 B,
Scheduler::call unchanged -> -9 B total.
Follow-up to the CallResult threading: move the 0-sentinel fallback
(if now_64 == 0, read the clock fresh) out of next_schedule_in and into
the single main-loop caller.
Effect on next_schedule_in: drops the uint32_t now parameter entirely
and removes the cold millis_64_from_(now) inlined clock read from the
function body. Verified in disassembly:
Scheduler::next_schedule_in 159 B -> 86 B (-46%)
The hot path is now just defer_empty + cleanup_ + top-of-heap compare +
return. No clock read anywhere in the function.
Application::loop handles the 0-sentinel with a single inline branch
right before the call; when call() fired items it invokes the public
Scheduler::millis_64_from() wrapper (new, exposes the existing protected
millis_64_from_ so callers can do 64-bit extension without platform
knowledge). Fast path (no items fired) stays branch-free.
Net code size: loop_task +64 B, next_schedule_in -73 B,
Scheduler::call unchanged -> -9 B total.
Scheduler::call() and Scheduler::next_schedule_in() both computed now_64 via
millis_64_from_(now) at the top of every main-loop iteration. On ESP32 with
USE_NATIVE_64BIT_TIME this is an esp_timer_get_time() MMIO read (~1us);
doing it twice per iteration is wasted work since the two calls happen
microseconds apart.
Thread the value through:
- Scheduler::call() now returns a CallResult { now, now_64 } struct instead
of just the advanced uint32_t now. When items fired, now_64 is set to 0
(sentinel) because execute_item_() only advances the uint32 and the local
now_64 is stale by the time we return.
- Scheduler::next_schedule_in() takes an optional now_64 parameter
(default 0). Non-zero values are trusted and used directly; the 0
sentinel triggers a fresh millis_64_from_(now) read.
- Application::scheduler_tick_() forwards the CallResult through.
- Application::loop() passes sched_result.now_64 to next_schedule_in().
Verified in the disassembly: on the fast path (no items fired),
next_schedule_in branches over its esp_timer_get_time call entirely via
`or a8, a4, a5; bnez a8, ...` on the two halves of now_64, jumping
straight to the next_exec comparison. When call() returned the 0 sentinel,
next_schedule_in falls through to the existing inlined
micros_to_millis(esp_timer_get_time()) sequence as before.
Bench callers (tests/benchmarks/core/bench_scheduler.cpp) and the external
test component (tests/integration/fixtures/.../scheduler_bulk_cleanup_component)
ignore the return value — no changes needed there.
Two small wins on the per-loop Scheduler::call path:
1. Mark the scheduler's `millis_64()` wrapper `ESPHOME_ALWAYS_INLINE`.
The inner `esphome::millis_64()` is already always_inline, but the
wrapper was not, so GCC emitted an out-of-line `$isra$0` clone with
its own `entry`/`retw` register-window prologue/epilogue at every
call site. Inlining removes the wrapper's call overhead and the
register-window thunk on ESP32.
2. Collapse the two atomic reads of `to_remove_` in `Scheduler::call`
into one. The previous sequence
cleanup_(); // loads to_remove_
if (to_remove_count_() >= MAX_...) ... // reloads to_remove_
produced `memw; l32i; beqz; memw; l32i; bltui` on the fast path
because the compiler cannot CSE across the `memw` barriers that
std::atomic<uint32_t>::load emits on Xtensa. Reading the counter
once and branching on the result leaves a single
`memw; l32i; beqz` on the common zero-case; the slow path
(cleanup_slow_path_ + re-read + optional full_cleanup) pays an
extra read but already holds the scheduler mutex.