Commit Graph
27271 Commits
Author SHA1 Message Date
J. Nick Koston eb7799631d Merge remote-tracking branch 'origin/ltr390-reduce-blocking' into integration 2026-04-12 10:50:48 -10:00
J. Nick Koston 09e544125a Merge remote-tracking branch 'upstream/dev' into integration 2026-04-12 10:07:28 -10:00
Jonathan Swoboda 2f684bf4f3 [esp32] Bump platform to 55.03.38, Arduino to 3.3.8, ESP-IDF to 5.5.4 (#15666) 2026-04-12 10:07:04 -10:00
Jonathan Swoboda 45af21bf38 [canbus] Fix canbus.send can_id compile error (#15668) 2026-04-12 09:58:51 -10:00
Jonathan Swoboda e6318a2d16 [mdns] Bump espressif/mdns to 1.11.0 (#15670) 2026-04-12 09:54:30 -10:00
J. Nick Koston b286a67334 Merge remote-tracking branch 'upstream/fast-millis-esp32' into integration 2026-04-12 09:50:55 -10:00
J. Nick Koston e5a0246c80 [core] Document clock divergence between millis() and millis_64_from_()
On ESP32, millis() uses xTaskGetTickCount (tick clock) but
millis_64_from_() discards the 32-bit value and calls millis_64()
(esp_timer clock). Safe because scheduling only compares millis_64
against millis_64. On ESP8266, both use the same accumulator clock.
2026-04-12 09:49:28 -10:00
J. Nick Koston 0249c30cbf [esp32] Document why millis_64() uses a different clock than millis()
millis() uses xTaskGetTickCount (tick clock) while millis_64() uses
esp_timer_get_time (hardware timer). Safe because they are never
cross-compared: Scheduler::millis_64_from_() on ESP32 discards the
32-bit millis parameter and calls millis_64() directly, keeping all
64-bit scheduling on the esp_timer clock.
2026-04-12 09:48:23 -10:00
J. Nick Koston e06c54d100 Merge branch 'fast-millis-esp32' into integration 2026-04-12 09:37:12 -10:00
J. Nick Koston 2cd54b209d Merge branch 'fast-millis-esp8266' into integration 2026-04-12 09:37:02 -10:00
J. Nick Koston b5a2d58b80 [esp32] Keep millis() out-of-line with IRAM_ATTR, add ISR context check
Revert the inline hal.h approach — millis() must remain IRAM_ATTR
because Wiegand and ZyAura call it from IRAM_ATTR ISR handlers on
all platforms including ESP32.

Use xPortInIsrContext() to dispatch to xTaskGetTickCountFromISR()
from ISR or xTaskGetTickCount() from task context, satisfying the
FreeRTOS API contract. ISR check is [[unlikely]] so the hot path
stays fast.

Benchmarked: 686 ns -> 361 ns (1.9x faster). Correctness verified.
2026-04-12 09:07:08 -10:00
J. Nick Koston c155f41110 [esp32] Inline millis() in hal.h, drop IRAM_ATTR
Move millis() to an inline function in hal.h that just returns
xTaskGetTickCount() -- eliminates the function call entirely at every
call site. No IRAM consumed, no flash function call.

Drop IRAM_ATTR from the fallback path. The original IRAM placement was
inherited from ESP8266 where Arduino millis() is called from ISR
handlers (Wiegand, ZyAura). ESPHome does not call millis() from ISR
on ESP32.
2026-04-12 08:49:04 -10:00
J. Nick Koston a8cb5b44cd [esp8266] Address review: fix misleading comment, add named constants
- Remove 'No __umulsidi3' claim from top comment — the rare path
  does use it via /1000. Reworded to 'pure 32-bit ops on the common
  path'.
- Extract MILLIS_RARE_PATH_THRESHOLD_US and US_PER_MS constants to
  replace magic numbers 10000 and 1000.
2026-04-12 00:03:23 -10:00
J. Nick Koston c863a204e8 [esp8266] Fix incorrect claim about delay() usage scope
delay() is called from component loop() and update() methods too
(NFC, RTTTL, sensors), not just setup/OTA.
2026-04-11 23:36:40 -10:00
J. Nick Koston ebbc3b57c7 [esp8266] Document delay() semantic difference from Arduino
Arduino's delay() uses esp_suspend() with a one-shot os_timer for
efficient single-suspension waiting. Our replacement polls millis()
with optimistic_yield(), which also enters esp_schedule/esp_suspend
via yield() but resumes repeatedly. Functionally correct for ESPHome
(delay is cold path, SDK tasks run via yield), just less power-
efficient for long delays.
2026-04-11 23:34:14 -10:00
J. Nick Koston b882d8c721 [esp8266] Fix stale s_last_us reference in comment
Updated to state.last_us to match the struct refactor.
2026-04-11 23:31:48 -10:00
J. Nick Koston da9c44d28d [esp8266] Bound the critical section in millis() accumulator
Split the μs→ms conversion into two paths to keep the interrupt-
disabled critical section bounded:

- Common path (delta < 10 ms): while loop runs at most 10 iterations
  (~100 ns). This covers the normal hot-path case where millis() is
  called thousands of times per second.

- Rare path (delta >= 10 ms): constant-time multiply-by-reciprocal
  via /1000 (compiled to __umulsidi3, ~2.5 μs). Only fires after a
  long block (WiFi scan, boot, component stall), where the extra
  latency is negligible relative to the block that caused it.

Addresses the concern that a multi-second WiFi scan block could cause
the unbounded while loop to hold interrupts for tens of microseconds.
2026-04-11 23:21:15 -10:00
J. Nick Koston aec0be356f [esp8266] Remove unused USE_FAST_MILLIS_ACCUMULATOR define 2026-04-11 23:16:06 -10:00
J. Nick Koston 3d3e26d141 [esp8266] Merge millis_accumulator into millis(), use struct for statics
Merge the separate millis_accumulator() function directly into millis()
to eliminate the extra function call overhead and prologue/epilogue.
Pack the three statics into a struct so the compiler loads one base
address instead of three literal pool entries.

Saves ~12 bytes of IRAM (87+6 → 81 bytes).
2026-04-11 23:10:19 -10:00
J. Nick Koston bf20403558 [esp8266] Revert init() wrap — micros64() needs the overflow timer
Arduino's micros64() reads the same overflow tracking statics that
init() sets up. Wrapping init() as a no-op would cause micros64()
to silently return wrong values after 71 minutes. The overflow timer
cost is negligible (~3 μs per 60 seconds).
2026-04-11 23:08:53 -10:00
J. Nick Koston dff7757d1e [esp8266] Suppress clang-tidy for __wrap_millis reserved identifier
The __wrap_ prefix is required by the GNU linker's --wrap feature.
It's a reserved identifier by C++ rules but mandated by the toolchain.
2026-04-11 23:02:16 -10:00
J. Nick Koston cb654a1e50 [esp8266] Handle delay(0) as a yield point
Arduino's delay(0) calls yield() once — used as a yield point by some
code. Our millis-based loop would skip yielding entirely since
0 < 0 is false. Add explicit delay(0) → yield() handling.
2026-04-11 22:59:12 -10:00
J. Nick Koston 325d9db499 [esp8266] Replace delay() with yield loop to allow GC of original millis
Arduino's ::delay() is implemented in core_esp8266_wiring.cpp alongside
millis(). Because __delay calls millis() within the same compilation
unit, --wrap=millis can't intercept that intra-object reference, which
prevents the linker from garbage-collecting the original millis body
(~80 bytes of IRAM).

Replace esphome::delay() with a simple yield loop that uses our fast
millis() accumulator. This has the same behavior as Arduino's delay:
feeds the watchdog and processes SDK tasks via yield(), keeping WiFi
alive. With __delay unreferenced, the linker can now GC both __delay
and the original millis function.
2026-04-11 22:56:59 -10:00
J. Nick Koston dff6199fc8 [esp8266] Add USE_FAST_MILLIS_ACCUMULATOR define for benchmark guard 2026-04-11 22:37:45 -10:00
J. Nick Koston 16f7effcf3 [esp8266] Fix clang-format: drop trailing underscore from static function 2026-04-11 22:21:57 -10:00
J. Nick Koston 406546876a [esp8266] Replace millis() with fast accumulator, wrap Arduino callers
Arduino ESP8266's millis() uses 4x 64-bit multiplies with magic
constants to convert system_get_time() to ms while tracking overflow.
On the LX106 (no hardware multiply-high instruction), each 64-bit
multiply goes through the __umulsidi3 software helper — costing
~3.3 us per call.

Replace with a simple accumulator that tracks a running millis counter
from system_get_time() deltas using pure 32-bit integer ops (subtract,
add, compare, subtract). No 64-bit math, no __umulsidi3.

Use -Wl,--wrap=millis to intercept all ::millis() calls site-wide so
Arduino libraries and ISR handlers (Wiegand, ZyAura) also get the fast
version. Brief interrupt disable (~125 ns) protects the static state.

Overflow safety: unsigned 32-bit delta arithmetic handles the 71-minute
system_get_time() wrap correctly for one wrap. ESPHome calls millis()
thousands of times per second, so missing a full wrap is not a realistic
concern. At boot, both the accumulator and system_get_time() start at 0,
so no special initialization is needed.

Benchmarked on real ESP8266 hardware:
  Before: 3348 ns/call (Arduino 4x 64-bit multiply)
  After:  ~800-900 ns/call (accumulator, estimated)
2026-04-11 22:16:38 -10:00
J. Nick Koston 6ac705cd66 [esp32] Use xTaskGetTickCount() for millis() when tick rate is 1kHz
ESPHome sets CONFIG_FREERTOS_HZ=1000, so one FreeRTOS tick equals one
millisecond and xTaskGetTickCount() returns ms directly. This replaces
esp_timer_get_time() + micros_to_millis() (a hardware timer read plus
a 64-bit multiply-shift conversion) with a single volatile memory read.

Benchmarked on real ESP32 hardware:
  Before: 686 ns/call (esp_timer_get_time + micros_to_millis)
  After:  ~20 ns/call (volatile read of xTickCount)

millis() is called 1+N times per main loop iteration (once at the top
and once per component via WarnIfComponentBlockingGuard::finish()), so
on a 5-component device this saves ~3.4 μs per loop iteration.

millis_64() is left on esp_timer_get_time() for full 64-bit μs
precision — it is only called once per loop by the Scheduler.
micros() is also unchanged (runtime_stats needs μs precision).

Falls back to the original implementation if CONFIG_FREERTOS_HZ != 1000
(non-standard user override).
2026-04-11 22:13:33 -10:00
J. Nick Koston 8800c10181 [core] Address review: fix docstrings per Copilot feedback
- get_app_state(): say 'STATUS_LED_* only', not 'STATUS_LED_* and lifecycle'
  since lifecycle bits are no longer maintained in app_state_.
- STATUS_LED_SETTLE_S: remove incorrect mention of feed_wdt re-dispatch;
  status_led_light is driven by the main loop, not feed_wdt.
- snapshot_led service: say 'status_led_light output' not 'pin state'
  since the fixture uses a template output, not GPIO.
2026-04-11 17:37:59 -10:00
J. Nick Koston 79f27d7a19 Merge branch 'drop-redundant-app-state-or' into integration 2026-04-11 16:39:14 -10:00
J. Nick Koston 515ec544ce [tests] Extract STATUS_LED_SETTLE_S constant, fix idle count check
Extract the 0.3s magic sleep into a named constant explaining why that
duration is chosen (feed_wdt re-dispatches every ~3 ms; 300 ms gives
~100 opportunities). Fix the idle-write check to snapshot AFTER the
clear instead of before it, so writes still in-flight from the error
phase don't inflate the delta.
2026-04-11 16:32:15 -10:00
J. Nick Koston 542b1fc7f3 [tests] Add set→clear→re-set round-trip check for status_led
Verifies that after clearing all status flags, re-setting a flag makes
status_led_light resume writing to its output. Guards against a future
idle optimization (like #15642) where status_led disables its own
loop() when idle: if the re-enable path were broken, the second set
would not produce writes.

Also checks that writes STOP after all flags are cleared (counter
should not keep growing), proving status_led_light correctly stops
blinking in steady state.
2026-04-11 16:28:54 -10:00
J. Nick Koston 1b1adb841a [core] Fix clang-format: remove extra blank line 2026-04-11 16:21:43 -10:00
J. Nick Koston 424f1c7b0a [core] Rename any_component_has_status_flag and add integration tests
Drop the trailing underscore from any_component_has_status_flag now
that the method is public. Trailing underscores in the codebase are
reserved for protected/private members per clang-tidy naming rules,
which caused a CI failure on the previously-named public helper.

Add integration tests covering:
- Single-component status_set/clear for warning and error
- Multi-component OR semantics (both clear orders)
- Warning and error independence
- End-to-end proof that status_led_light::loop() reads App.app_state_
  and writes its output when the bits are set (via a fake template
  output whose write_action bumps a counter exposed as a sensor)
2026-04-11 16:20:05 -10:00
J. Nick Koston d9c6b27cf2 [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.
2026-04-11 16:05:46 -10:00
J. Nick Koston 57592fa664 Merge remote-tracking branch 'upstream/feed-wdt-inline-fastpath' into integration 2026-04-11 15:22:33 -10:00
J. Nick Koston 5926ca5369 [core] Split feed_wdt into hot and cold entries
Separate Application::feed_wdt() into two entry points so the hot path
callers stop paying for the time==0 check they never trigger:

- feed_wdt_with_time(time): inline, hot path. Rate-limit check in 3
  Xtensa instructions (load + sub + branch). [[unlikely]] tells the
  compiler the slow branch is rare so the common path stays
  fall-through.
- feed_wdt(): cold, out of line. Fetches millis() and forwards through
  the same rate limit. Used by setup loops, upload helpers, yield(),
  and any other non-hot caller.

feed_wdt_slow_() is now pure worker code — 11 bytes. It just calls
arch_feed_wdt(), updates last_wdt_feed_, and runs the status LED
re-dispatch. Both entries have already confirmed the rate limit was
exceeded before calling.

Hot call sites updated:
- Application::loop() per-component feed
- Scheduler::execute_item_() after each scheduled item runs
- Application::teardown_components() inner loop (already has 'now')
2026-04-11 15:19:35 -10:00
J. Nick Koston 1815398062 Merge remote-tracking branch 'upstream/feed-wdt-inline-fastpath' into integration 2026-04-11 15:05:52 -10:00
J. Nick Koston dc5626eb85 [core] Invert feed_wdt condition so slow-path call is inside the if
Cleaner than the early-return form — the action (calling feed_wdt_slow_)
reads as the body of the conditional instead of falling through past a
guard clause. Logically identical and compiles to the same code.
2026-04-11 14:57:01 -10:00
J. Nick Koston 715f0ca6f7 [core] Extract WDT_FEED_INTERVAL_MS constexpr
Replace the magic 3 in both the inline feed_wdt check and the slow path
with a named constexpr so the rate-limit threshold is defined once.
2026-04-11 14:54:02 -10:00
J. Nick Koston a70ec9ec06 [scheduler] Feed watchdog after each scheduled item, drop top-of-loop feed
The main loop used to feed the watchdog unconditionally right after
Scheduler::call() returned, regardless of whether the scheduler had any
actual work to do. On an idle device this meant every outer loop
iteration paid the inline rate-limit check (load + sub + branch) for no
benefit.

Move the feed into Scheduler::execute_item_() so it fires only after a
scheduled callback actually runs, and covers both the main heap path
and the defer queue path (both go through execute_item_). This also
bounds the max feed gap during a burst of back-to-back scheduled items
by max(item_runtime) instead of sum(item_runtime).

The top-of-loop feed in Application::before_loop_tasks_() is now
unnecessary — when Scheduler::call does no work, the only elapsed time
is the sleep wake plus a few instructions, and when it does have work,
it fed the wdt as it went.
2026-04-11 14:51:59 -10:00
J. Nick Koston ddbf6f2347 [core] Inline feed_wdt hot path with out-of-line slow path
Split Application::feed_wdt() into an ALWAYS_INLINE wrapper that checks
the 3ms rate limit against last_wdt_feed_ and a feed_wdt_slow_() callee
that performs the actual arch_feed_wdt() + status LED re-dispatch.

Callers on the hot path (loop_task before/after each component) that
already have a millis() timestamp in hand now pay only a load + sub +
branch on the no-op path instead of a full call8 / entry / retw.

Moves the rate-limit state from a function-local static to a class
member (last_wdt_feed_) so the inline can access it.
2026-04-11 14:32:49 -10:00
Jonathan Swoboda bef4c8a86c [cc1101] Extract chip configuration into configure() method (#15635) 2026-04-11 17:36:27 -04:00
J. Nick Koston ea0ba99c28 [core] Drop stale register_socket defs after ota merge
PR #15639 removed Application::register_socket / monitored_sockets_
on the fast-select path before the ota-disable-loop-when-idle merge.
The merge re-introduced the function definitions without the matching
declarations. Drop them.
2026-04-11 08:40:17 -10:00
J. Nick Koston a6523fde47 Merge remote-tracking branch 'upstream/ota-disable-loop-when-idle' into integration
# Conflicts:
#	esphome/core/application.cpp
2026-04-11 08:36:24 -10:00
J. Nick Koston bfcfd7f110 Merge branch 'dev' into ota-disable-loop-when-idle 2026-04-11 08:28:06 -10:00
J. Nick Koston 30de0c17ab [esphome.ota] Trim verbose comments from simplification 2026-04-11 08:21:11 -10:00
J. Nick Koston e67fac704c [esphome.ota] Move OTA wake pointer out of Application into ota_esphome.cpp
Following the zephyr_mcumgr precedent, keep the single-instance pointer
as a file-scope static inside ota_esphome.cpp instead of plumbing an
ota_wake_component_ slot and setter through Application. The extern "C"
wake trampoline also lives in the same TU now, so nothing in core/
touches OTA-specific state.

Guard the C and C++ call sites on USE_OTA_PLATFORM_ESPHOME (added as a
-D flag from components/esphome/ota/__init__.py) instead of the broader
ESPHOME_USE_OTA that base ota/ used to emit — the trampoline symbol only
exists when the esphome OTA platform is actually compiled in.

Also drop the dead host yield_with_select_ wake hook (host has no OTA
platform today).
2026-04-11 08:17:49 -10:00
Farmer-shin 6e67864510 [epaper_spi] Add Waveshare 3.97inch E-Paper Display (#15466) 2026-04-11 21:27:25 +10:00
J. Nick Koston a977f99bb1 Merge remote-tracking branch 'upstream-ssh/status-led-disable-loop-idle' into integration 2026-04-11 00:16:56 -10:00
J. Nick Koston 7eb56e1cf6 [status_led] Clarify feed_wdt LOOP_DONE comment 2026-04-11 00:14:40 -10:00