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.