Replace heap-allocated SensorWithDedup wrapper with inline
LazySensorWithDedup that stores the sensor pointer and deduplicator
directly in the component, eliminating heap allocations and reducing
RAM usage for configured sensors.
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.
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.
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.
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.
- 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.
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.
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.
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).
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).
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.
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.
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)
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).