From 77da64a367c62a123d01559d344255a2c338bebe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:05:51 -0400 Subject: [PATCH 1/8] [sx126x] Add cold sleep option and drop unused RTC wakeup bit (#16144) --- esphome/components/sx126x/__init__.py | 58 +++++++++++++++++++++----- esphome/components/sx126x/automation.h | 3 +- esphome/components/sx126x/sx126x.cpp | 5 ++- esphome/components/sx126x/sx126x.h | 2 +- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index b8696158fe..a4ba5c34f3 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi @@ -5,6 +7,8 @@ from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID, TimePeriod +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType, TemplateArgsType MULTI_CONF = True CODEOWNERS = ["@swoboda1337"] @@ -15,6 +19,7 @@ CONF_SX126X_ID = "sx126x_id" CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_CODING_RATE = "coding_rate" +CONF_COLD = "cold" CONF_CRC_INVERTED = "crc_inverted" CONF_CRC_SIZE = "crc_size" CONF_CRC_POLYNOMIAL = "crc_polynomial" @@ -144,7 +149,7 @@ SetModeStandbyAction = sx126x_ns.class_( ) -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list[int]: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, list): @@ -154,7 +159,7 @@ def validate_raw_data(value): ) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: lora_bws = [ "7_8kHz", "10_4kHz", @@ -235,7 +240,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) @@ -307,24 +312,50 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -@automation.register_action( - "sx126x.set_mode_sleep", - SetModeSleepAction, - NO_ARGS_ACTION_SCHEMA, - synchronous=True, -) @automation.register_action( "sx126x.set_mode_standby", SetModeStandbyAction, NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def no_args_action_to_code(config, action_id, template_arg, args): +async def no_args_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var +SET_MODE_SLEEP_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(SX126x), + cv.Optional(CONF_COLD, default=False): cv.templatable(cv.boolean), + } +) + + +@automation.register_action( + "sx126x.set_mode_sleep", + SetModeSleepAction, + SET_MODE_SLEEP_ACTION_SCHEMA, + synchronous=True, +) +async def set_mode_sleep_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_COLD], args, bool) + cg.add(var.set_cold(template_)) + return var + + SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SX126x), @@ -340,7 +371,12 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( SEND_PACKET_ACTION_SCHEMA, synchronous=True, ) -async def send_packet_action_to_code(config, action_id, template_arg, args): +async def send_packet_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) data = config[CONF_DATA] diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index 2282c583cb..ed5986e097 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -56,7 +56,8 @@ template class SetModeRxAction : public Action, public Pa template class SetModeSleepAction : public Action, public Parented { public: - void play(const Ts &...x) override { this->parent_->set_mode_sleep(); } + TEMPLATABLE_VALUE(bool, cold) + void play(const Ts &...x) override { this->parent_->set_mode_sleep(this->cold_.value(x...)); } }; template class SetModeStandbyAction : public Action, public Parented { diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6ea09e3a9e..02f7d972a9 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -459,9 +459,10 @@ void SX126x::set_mode_tx() { this->write_opcode_(RADIO_SET_TX, buf, 3); } -void SX126x::set_mode_sleep() { +void SX126x::set_mode_sleep(bool cold) { + // 0x04 = warm start (config retained), 0x00 = cold start (config lost, lowest power) uint8_t buf[1]; - buf[0] = 0x05; + buf[0] = cold ? 0x00 : 0x04; this->write_opcode_(RADIO_SET_SLEEP, buf, 1); } diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index edc00e3727..87bbf18c79 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -79,7 +79,7 @@ class SX126x : public Component, void set_mode_rx(); void set_mode_tx(); void set_mode_standby(SX126xStandbyMode mode); - void set_mode_sleep(); + void set_mode_sleep(bool cold = false); void set_modulation(uint8_t modulation) { this->modulation_ = modulation; } void set_pa_power(int8_t power) { this->pa_power_ = power; } void set_pa_ramp(uint8_t ramp) { this->pa_ramp_ = ramp; } From e4b33fddf5556aa3a7d806a7d6b1ee2caa522d8c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:43:15 -0400 Subject: [PATCH 2/8] [esp32] Add ESP-IDF 6.0.1 platform entry (#16146) --- esphome/components/esp32/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index eb023ce32c..bd299d71f0 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -729,6 +729,9 @@ ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { "dev": cv.Version(5, 5, 4), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { + cv.Version( + 6, 0, 1 + ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", From 9b1f5c59bb6ea7417174a8751d458c4f9bdb2722 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 18:05:38 -0500 Subject: [PATCH 3/8] [core] Fix null deref in WarnIfComponentBlockingGuard for self-keyed scheduler timers (#16150) --- esphome/core/component.h | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 6afcfda41d..185d51ab37 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -655,7 +655,15 @@ class WarnIfComponentBlockingGuard { // Inlined: the fast path is just millis() + subtract + compare inline uint32_t HOT finish() { #ifdef USE_RUNTIME_STATS - this->component_->runtime_stats_.record_time(micros() - this->started_us_); + uint32_t elapsed_us = micros() - this->started_us_; + // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) + if (this->component_ != nullptr) { + this->component_->runtime_stats_.record_time(elapsed_us); + } else { + // Still accumulate into the global counter so Application::loop() can subtract + // this time from before_loop_tasks_ wall time. + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } #endif uint32_t curr_time = MillisInternal::get(); #ifndef USE_BENCHMARK From b8d24c9e494f4abfd24e700ad52b1430c25334fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 18:14:07 -0500 Subject: [PATCH 4/8] [mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#16149) --- esphome/components/mcp23xxx_base/__init__.py | 22 +++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index cd952099c0..76a3aabe3f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -2,6 +2,7 @@ from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( + CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -30,10 +31,29 @@ MCP23XXX_INTERRUPT_MODES = { "FALLING": MCP23XXXInterruptMode.MCP23XXX_FALLING, } + +def _validate_interrupt_pin(value): + # The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR) + # and installs a single ISR per GPIO, so neither inversion nor sharing is supported. + value = pins.internal_gpio_input_pin_schema(value) + if value.get(CONF_INVERTED): + raise cv.Invalid( + f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "the MCP23xxx INT line is fixed active-low" + ) + if value.get(CONF_ALLOW_OTHER_USES): + raise cv.Invalid( + f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "sharing the interrupt pin between multiple MCP23xxx (or other components) " + "is not implemented. Remove the interrupt_pin to fall back to polling." + ) + return value + + MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) From 8066325e0b89ac4b0c08d54a95a5828f5235aefb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:52:25 +1200 Subject: [PATCH 5/8] Bump esphome/workflows/.github/workflows/lock.yml from 2026.4.0 to 2026.4.1 (#16143) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 20f9a74ea9..8d1dfe857d 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -8,4 +8,4 @@ on: jobs: lock: - uses: esphome/workflows/.github/workflows/lock.yml@3c4e8446aa1029f1c346a482034b3ee1489077ca # 2026.4.0 + uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1 From 47765bd2d07901e233ad56b1d7dfb272a932d16a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:08:56 +1200 Subject: [PATCH 6/8] [ci] Correct version comment on create-github-app-token pin (#16156) --- .github/workflows/auto-label-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 0e5ceb9346..5e685ce0aa 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -27,7 +27,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} From 1a871e231dd658e99e44c27d34ffe3f6358541cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:09:37 +1200 Subject: [PATCH 7/8] [ci] Use client-id for GitHub App token generation (#16155) --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/release.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 5e685ce0aa..ea22f75ef0 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -29,7 +29,7 @@ jobs: id: generate-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - name: Auto Label PR diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35b9e065e1..a16af92b6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,7 +223,7 @@ jobs: id: generate-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: home-assistant-addon @@ -258,7 +258,7 @@ jobs: id: generate-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: esphome-schema @@ -289,7 +289,7 @@ jobs: id: generate-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: - app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} owner: esphome repositories: version-notifier From f0bffed3c04c75f65513a4405914e093340d5782 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 22:42:17 -0500 Subject: [PATCH 8/8] [esp8266] Move HAL bodies into components/esp8266/hal.cpp + inline arch_init (#16112) --- esphome/components/esp8266/core.cpp | 98 +----------------------- esphome/components/esp8266/hal.cpp | 111 ++++++++++++++++++++++++++++ esphome/core/hal.h | 7 +- esphome/core/hal/hal_esp32.h | 3 + esphome/core/hal/hal_esp8266.h | 8 +- esphome/core/hal/hal_host.h | 2 + esphome/core/hal/hal_libretiny.h | 2 + esphome/core/hal/hal_rp2040.h | 2 + esphome/core/hal/hal_zephyr.h | 2 + 9 files changed, 133 insertions(+), 102 deletions(-) create mode 100644 esphome/components/esp8266/hal.cpp diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 9161ca6aaf..b9ad4082e9 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -3,98 +3,12 @@ #include "core.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#include "esphome/core/time_64.h" -#include "esphome/core/helpers.h" -#include "preferences.h" #include -#include - -extern "C" { -#include -} namespace esphome { -// yield(), micros(), millis_64() inlined in hal.h. -// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit -// multiplies on the LX106). Tracks a running ms counter from 32-bit -// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis -// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. -// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static -// state against ISR re-entry; the critical section is bounded (≤10 while-loop -// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on -// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level -// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). -// -// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles -// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a -// >71 min block would trip the watchdog long before it could matter here. -static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; -static constexpr uint32_t US_PER_MS = 1000; - -uint32_t IRAM_ATTR HOT millis() { - // Struct packs the three statics so the compiler loads one base address - // instead of three separate literal pool entries (saves ~8 bytes IRAM). - static struct { - uint32_t cache; - uint32_t remainder; - uint32_t last_us; - } state = {0, 0, 0}; - uint32_t ps = xt_rsil(15); - uint32_t now_us = system_get_time(); - uint32_t delta = now_us - state.last_us; - state.last_us = now_us; - state.remainder += delta; - if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { - // Rare path: large gap (WiFi scan, boot, long block). Constant-time - // conversion keeps the critical section bounded. - uint32_t ms = state.remainder / US_PER_MS; - state.cache += ms; - // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a - // second __umodsi3 call on the LX106 (no hardware divide). - state.remainder -= ms * US_PER_MS; - } else { - // Common path: small gap. At most ~10 iterations since remainder was - // < threshold (10 ms) on entry and delta adds at most one more threshold - // before exiting this branch. - while (state.remainder >= US_PER_MS) { - state.cache++; - state.remainder -= US_PER_MS; - } - } - uint32_t result = state.cache; - xt_wsr_ps(ps); - return result; -} -// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object -// call to the original millis() that --wrap can't intercept, so calling ::delay() -// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still -// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and -// WiFi run correctly. Theoretically less power-efficient than Arduino's -// os_timer-based delay() for long waits, but nearly all ESPHome delays are short -// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is -// negligible. -void HOT delay(uint32_t ms) { - if (ms == 0) { - optimistic_yield(1000); - return; - } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } -} -// delayMicroseconds(), arch_feed_wdt(), and progmem_read_*() are inlined in hal/hal_esp8266.h. -void arch_restart() { - system_restart(); - // restart() doesn't always end execution - while (true) { // NOLINT(clang-diagnostic-unreachable-code) - yield(); - } -} -void arch_init() {} -uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } -uint32_t arch_get_cpu_freq_hz() { return F_CPU; } +// HAL functions live in hal.cpp. This file keeps only the ESP8266-specific +// firmware bootstrap (Tasmota OTA magic bytes, optional GPIO pre-init). void force_link_symbols() { // Tasmota uses magic bytes in the binary to check if an OTA firmware is compatible @@ -131,12 +45,4 @@ extern "C" void resetPins() { // NOLINT } // namespace esphome -// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. -// Requires -Wl,--wrap=millis in build flags (added by __init__.py). -// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } -// Note: Arduino's init() registers a 60-second overflow timer for micros64(). -// We leave it running — wrapping init() as a no-op would break micros64()'s -// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). - #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp new file mode 100644 index 0000000000..56910e5b39 --- /dev/null +++ b/esphome/components/esp8266/hal.cpp @@ -0,0 +1,111 @@ +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +#include +#include + +extern "C" { +#include +} + +// Empty esp8266 namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// esp8266 component's API. +namespace esphome::esp8266 {} // namespace esphome::esp8266 + +namespace esphome { + +// yield(), micros(), millis_64(), delayMicroseconds(), arch_feed_wdt(), +// progmem_read_*() are inlined in core/hal/hal_esp8266.h. +// +// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit +// multiplies on the LX106). Tracks a running ms counter from 32-bit +// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis +// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. +// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static +// state against ISR re-entry; the critical section is bounded (≤10 while-loop +// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on +// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level +// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). +// +// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles +// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a +// >71 min block would trip the watchdog long before it could matter here. +static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; +static constexpr uint32_t US_PER_MS = 1000; + +uint32_t IRAM_ATTR HOT millis() { + // Struct packs the three statics so the compiler loads one base address + // instead of three separate literal pool entries (saves ~8 bytes IRAM). + static struct { + uint32_t cache; + uint32_t remainder; + uint32_t last_us; + } state = {0, 0, 0}; + uint32_t ps = xt_rsil(15); + uint32_t now_us = system_get_time(); + uint32_t delta = now_us - state.last_us; + state.last_us = now_us; + state.remainder += delta; + if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { + // Rare path: large gap (WiFi scan, boot, long block). Constant-time + // conversion keeps the critical section bounded. + uint32_t ms = state.remainder / US_PER_MS; + state.cache += ms; + // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a + // second __umodsi3 call on the LX106 (no hardware divide). + state.remainder -= ms * US_PER_MS; + } else { + // Common path: small gap. At most ~10 iterations since remainder was + // < threshold (10 ms) on entry and delta adds at most one more threshold + // before exiting this branch. + while (state.remainder >= US_PER_MS) { + state.cache++; + state.remainder -= US_PER_MS; + } + } + uint32_t result = state.cache; + xt_wsr_ps(ps); + return result; +} + +// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object +// call to the original millis() that --wrap can't intercept, so calling ::delay() +// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still +// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and +// WiFi run correctly. Theoretically less power-efficient than Arduino's +// os_timer-based delay() for long waits, but nearly all ESPHome delays are short +// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is +// negligible. +void HOT delay(uint32_t ms) { + if (ms == 0) { + optimistic_yield(1000); + return; + } + uint32_t start = millis(); + while (millis() - start < ms) { + optimistic_yield(1000); + } +} + +void arch_restart() { + system_restart(); + // restart() doesn't always end execution + while (true) { // NOLINT(clang-diagnostic-unreachable-code) + yield(); + } +} + +} // namespace esphome + +// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. +// Requires -Wl,--wrap=millis in build flags (added by __init__.py). +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } +// Note: Arduino's init() registers a 60-second overflow timer for micros64(). +// We leave it running — wrapping init() as a no-op would break micros64()'s +// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). + +#endif // USE_ESP8266 diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 312effa7b0..a53296979c 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -31,11 +31,10 @@ namespace esphome { // Cross-platform declarations. delayMicroseconds(), arch_feed_wdt(), -// arch_get_cpu_cycle_count() vary per platform (some inline, some -// out-of-line) so they live in hal/hal_.h. +// arch_get_cpu_cycle_count(), arch_init(), arch_get_cpu_freq_hz() vary +// per platform (some inline, some out-of-line) so they live in +// hal/hal_.h. void __attribute__((noreturn)) arch_restart(); -void arch_init(); -uint32_t arch_get_cpu_freq_hz(); #ifndef USE_ESP8266 // All non-ESP8266 platforms: PROGMEM is a no-op, so these are direct dereferences. diff --git a/esphome/core/hal/hal_esp32.h b/esphome/core/hal/hal_esp32.h index 2bff424441..d5d7752bf6 100644 --- a/esphome/core/hal/hal_esp32.h +++ b/esphome/core/hal/hal_esp32.h @@ -42,6 +42,9 @@ __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { dela __attribute__((always_inline)) inline void arch_feed_wdt() { esp_task_wdt_reset(); } __attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } +void arch_init(); +uint32_t arch_get_cpu_freq_hz(); + } // namespace esphome #endif // USE_ESP32 diff --git a/esphome/core/hal/hal_esp8266.h b/esphome/core/hal/hal_esp8266.h index 484118f1f5..b6e3b1ee3c 100644 --- a/esphome/core/hal/hal_esp8266.h +++ b/esphome/core/hal/hal_esp8266.h @@ -3,6 +3,7 @@ #ifdef USE_ESP8266 #include +#include #include #include @@ -59,8 +60,11 @@ __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_ // NOLINTNEXTLINE(readability-identifier-naming) __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } __attribute__((always_inline)) inline void arch_feed_wdt() { system_soft_wdt_feed(); } - -uint32_t arch_get_cpu_cycle_count(); +__attribute__((always_inline)) inline void arch_init() {} +// esp_get_cycle_count() declared in ; F_CPU is a +// compiler-driven macro from the ESP8266 Arduino board defs (-DF_CPU=...). +__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_get_cycle_count(); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return F_CPU; } } // namespace esphome diff --git a/esphome/core/hal/hal_host.h b/esphome/core/hal/hal_host.h index 682a1a422b..a8896fdf63 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/core/hal/hal_host.h @@ -22,6 +22,8 @@ uint64_t millis_64(); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index e9d33b7753..ecfe830fe3 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -91,6 +91,8 @@ __attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/core/hal/hal_rp2040.h index 2a1d67b4a3..46f6e421cd 100644 --- a/esphome/core/hal/hal_rp2040.h +++ b/esphome/core/hal/hal_rp2040.h @@ -38,6 +38,8 @@ __attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_mi void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/core/hal/hal_zephyr.h index 6707c85b2c..d4b37b5eb6 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/core/hal/hal_zephyr.h @@ -22,6 +22,8 @@ uint64_t millis_64(); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void arch_feed_wdt(); uint32_t arch_get_cpu_cycle_count(); +void arch_init(); +uint32_t arch_get_cpu_freq_hz(); } // namespace esphome