Commit Graph
27994 Commits
Author SHA1 Message Date
J. Nick Koston 1e5a753e0e Merge branch 'output-power-scaling-optional' into integration 2026-04-25 13:10:27 -05:00
J. Nick Koston 4432516614 [output] Address Copilot review + add static_assert stubs for documented lambda API
- 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.
2026-04-25 13:07:12 -05:00
J. Nick Koston f797090f1d Merge remote-tracking branch 'origin/output-power-scaling-optional' into integration 2026-04-25 12:42:52 -05:00
J. Nick Koston 15cb2c0580 [output] Gate FloatOutput power scaling fields behind USE_OUTPUT_FLOAT_POWER_SCALING
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.
2026-04-25 12:39:32 -05:00
b5ccd55f4e [packages] Fix premature substitution of vars in remote package files (#15997)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-25 17:06:58 +00:00
J. Nick Koston 4f51057fc2 [api] Clarify fence comment per review
Plain non-atomic load on the fast path is not a 'relaxed load' in the
C++ atomic sense; describe what the fence accomplishes instead.
2026-04-25 11:00:18 -05:00
J. Nick Koston df86f3aa0d Merge remote-tracking branch 'upstream-ssh/api-idle-once-per-loop-fence' into integration 2026-04-25 10:54:50 -05:00
J. Nick Koston f9ad090bf9 [api] Hoist memw out of socket ready check to once per main-loop iter
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).
2026-04-25 10:47:41 -05:00
J. Nick Koston 2a02136752 Merge remote-tracking branch 'upstream/dashboard-logs-no-states' into integration 2026-04-25 08:49:04 -05:00
J. Nick Koston 1b059f7fb2 Log post-append cmd in EsphomeLogsHandler so debug shows --no-states 2026-04-25 08:07:55 -05:00
J. Nick Koston 1574cf6256 [dashboard] Add --no-states support to logs WebSocket handler
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.
2026-04-25 07:52:50 -05:00
J. Nick Koston c73311bf8b Merge remote-tracking branch 'upstream/bluetooth_proxy_revert_set_interval' into int_rev 2026-04-25 05:18:30 -05:00
J. Nick Koston b33878f71e [bluetooth_proxy] Restore inlined flush helper, verbose-log gating, and 100ms-gated cleanup
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.
2026-04-25 05:18:08 -05:00
J. Nick Koston 95d7335b61 Revert "[bluetooth_proxy] Replace loop() with set_interval for advertisement flushing (#15347)"
This reverts commit 27c662e73f.
2026-04-25 05:01:04 -05:00
J. Nick Koston 1416574b04 Revert "[bluetooth_proxy] Replace loop() with set_interval for advertisement flushing (#15347)"
This reverts commit 27c662e73f.
2026-04-25 04:55:25 -05:00
J. Nick Koston 5558702c46 Merge remote-tracking branch 'upstream-ssh/ble-mac-varint-48bit' into integration 2026-04-25 04:47:46 -05:00
J. Nick Koston 5f8e991ed8 [api] Populate schema defaults for transitive cpp test deps; add json override
- 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.
2026-04-25 04:44:35 -05:00
J. Nick Koston 45525c8a82 [api] Make api a buildable cpp unit test target
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).
2026-04-25 04:41:17 -05:00
J. Nick Koston 68ffd3b221 [api] Fix CI errors in MAC varint unit tests
- 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.
2026-04-25 04:36:32 -05:00
J. Nick Koston 946af91821 [api] Add unit tests for 48-bit MAC varint encoder
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.
2026-04-25 04:22:39 -05:00
J. Nick Koston 7ab701e162 [api] Gate 48-bit MAC range check behind ESPHOME_DEBUG_API
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.
2026-04-25 04:13:30 -05:00
J. Nick Koston ba362a7c95 [api] Narrow 48-bit MAC varint fast path to values < 2^48
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.
2026-04-24 21:43:28 -05:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a437b3086b Bump cryptography from 46.0.7 to 47.0.0 (#15990)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-25 02:30:10 +00:00
J. Nick Koston 45cddcd379 Merge branch 'dev' into ble-mac-varint-48bit 2026-04-24 21:29:01 -05:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c27f9e512b Bump aioesphomeapi from 44.21.0 to 44.22.0 (#15989)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-25 02:28:04 +00:00
J. Nick Koston 65f68a412a [api] Add 48-bit MAC address varint fast path for BLE advertisements
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.
2026-04-24 19:58:39 -05:00
J. Nick Koston f08ea48cc3 Revert "[core] Outline Scheduler::call cleanup slow path into cold combined helper"
This reverts commit b110f453f8.
2026-04-24 17:24:30 -05:00
J. Nick Koston b110f453f8 [core] Outline Scheduler::call cleanup slow path into cold combined helper
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.
2026-04-24 17:11:40 -05:00
J. Nick Koston f3b828242c Revert "[core] Collapse duplicate to_remove_ read in Scheduler::call fast path"
This reverts commit d1a73c119d.
2026-04-24 17:05:11 -05:00
J. Nick Koston 58b92a593d Merge branch 'scheduler-memw-only' into integration 2026-04-24 16:57:51 -05:00
J. Nick Koston d1a73c119d [core] Collapse duplicate to_remove_ read in Scheduler::call fast path
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.
2026-04-24 16:56:47 -05:00
J. Nick Koston 614d018e4b [core] Collapse duplicate to_remove_ read in Scheduler::call fast path
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.
2026-04-24 16:56:02 -05:00
J. Nick Koston edf884d86d Merge branch 'esp32-wdt-1000ms' into integration 2026-04-24 16:40:27 -05:00
J. Nick Koston c7dc55ea33 Revert "[core] Trim Scheduler::call fast path"
This reverts commit c2e6787e23.
2026-04-24 16:39:27 -05:00
J. Nick Koston 54fdc06322 Revert "[core] Thread Scheduler now_64 into next_schedule_in to skip duplicate clock read"
This reverts commit 75f5b17937.
2026-04-24 16:32:45 -05:00
J. Nick Koston d7e755fb83 Revert "[core] Hoist Scheduler::next_schedule_in zero-check to caller"
This reverts commit dfe591c5eb.
2026-04-24 16:32:15 -05:00
J. Nick Koston 044d42d395 Revert "[core] Switch Scheduler::call now_64 plumbing from struct to out-param"
This reverts commit 4d7033df4f.
2026-04-24 16:32:15 -05:00
J. Nick Koston c71f8b5ba9 [core] Raise ESP32 WDT feed interval to 1/5 of configured timeout
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.
2026-04-24 16:24:55 -05:00
J. Nick Koston 0709ba613f [core] Raise ESP32 WDT feed interval to 1/5 of configured timeout
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.
2026-04-24 16:24:07 -05:00
J. Nick Koston 864f096df3 [core] Raise ESP32 WDT feed interval to 1000ms
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.
2026-04-24 16:16:23 -05:00
J. Nick Koston c5e2f15889 [core] Raise ESP32 WDT feed interval to 1000ms
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.
2026-04-24 16:16:11 -05:00
J. Nick Koston 4d7033df4f [core] Switch Scheduler::call now_64 plumbing from struct to out-param
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.
2026-04-24 15:59:38 -05:00
J. Nick Koston 30ff363095 Merge remote-tracking branch 'upstream-ssh/scheduler-call-fastpath' into integration 2026-04-24 15:45:53 -05:00
J. Nick Koston b659eaf494 [core] Hoist Scheduler::next_schedule_in zero-check to caller
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.
2026-04-24 15:45:20 -05:00
J. Nick Koston dfe591c5eb [core] Hoist Scheduler::next_schedule_in zero-check to caller
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.
2026-04-24 15:45:15 -05:00
J. Nick Koston 0220e19680 Merge remote-tracking branch 'upstream-ssh/scheduler-call-fastpath' into integration
# Conflicts:
#	esphome/core/application.h
2026-04-24 15:34:18 -05:00
J. Nick Koston 75f5b17937 [core] Thread Scheduler now_64 into next_schedule_in to skip duplicate clock read
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.
2026-04-24 15:32:09 -05:00
J. Nick Koston 13ec0fac73 Merge remote-tracking branch 'upstream-ssh/scheduler-call-fastpath' into integration 2026-04-24 15:17:15 -05:00
J. Nick Koston c2e6787e23 [core] Trim Scheduler::call fast path
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.
2026-04-24 15:16:03 -05:00
J. Nick Koston 249d428a10 Merge remote-tracking branch 'upstream/dev' into integration 2026-04-24 14:57:06 -05:00