diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 0e5ceb9346c..ea22f75ef04 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -27,9 +27,9 @@ 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 }} + 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/lock.yml b/.github/workflows/lock.yml index 20f9a74ea97..8d1dfe857de 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35b9e065e1e..a16af92b6fb 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 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index eb023ce32c3..bd299d71f05 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", diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 9161ca6aaf5..b9ad4082e98 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 00000000000..56910e5b399 --- /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/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index cd952099c05..76a3aabe3fa 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) diff --git a/esphome/components/st7789v/__init__.py b/esphome/components/st7789v/__init__.py index 3e64d09c57f..7915cf119c9 100644 --- a/esphome/components/st7789v/__init__.py +++ b/esphome/components/st7789v/__init__.py @@ -1,3 +1,8 @@ import esphome.codegen as cg st7789v_ns = cg.esphome_ns.namespace("st7789v") + +DEPRECATED_COMPONENT = """ +The 'st7789v' component is deprecated and no new functionality will be added to it. +PRs should target the newer and more performant 'mipi_spi' component. +""" diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index 745c37f47dd..3b4d6d99ea5 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import display, power_supply, spi @@ -26,6 +28,8 @@ CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["spi"] +LOGGER = logging.getLogger(__name__) + ST7789V = st7789v_ns.class_( "ST7789V", cg.PollingComponent, spi.SPIDevice, display.DisplayBuffer ) @@ -175,6 +179,9 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): + LOGGER.warning( + "The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead." + ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index b8696158fe5..a4ba5c34f31 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 2282c583cbf..ed5986e0974 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 6ea09e3a9e8..02f7d972a99 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 edc00e3727e..87bbf18c793 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; } diff --git a/esphome/core/component.h b/esphome/core/component.h index 6afcfda41db..185d51ab37d 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 diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 312effa7b0e..a53296979c6 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 2bff4244419..d5d7752bf6a 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 484118f1f50..b6e3b1ee3cb 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 682a1a422b4..a8896fdf638 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 e9d33b7753b..ecfe830fe35 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 2a1d67b4a32..46f6e421cd4 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 6707c85b2c2..d4b37b5eb6c 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 diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index dec541985f6..c0cd048890a 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -14,6 +14,37 @@ from esphome.util import run_external_process _LOGGER = logging.getLogger(__name__) +def _strip_win_long_path_prefix(path: str) -> str: + r"""Strip the Windows extended-length path prefix from ``path``. + + Handles both forms documented at + https://learn.microsoft.com/windows/win32/fileio/naming-a-file: + + * ``\\?\C:\path\to\file`` -> ``C:\path\to\file`` + * ``\\?\UNC\server\share\path`` -> ``\\server\share\path`` + + The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with + ``sys.executable`` already prefixed with ``\\?\``. That prefix propagates + into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from + the environment, falling back to ``os.path.normpath(sys.executable)``) + and ends up baked into SCons-emitted command lines for build steps such + as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand + the ``\\?\`` prefix, so the build fails with + "The system cannot find the path specified." Stripping the prefix early + keeps the path shell-quotable. + + No-op on non-Windows platforms. + """ + if sys.platform != "win32": + return path + if path.startswith("\\\\?\\UNC\\"): + # \\?\UNC\server\share\... -> \\server\share\... + return "\\\\" + path[len("\\\\?\\UNC\\") :] + if path.startswith("\\\\?\\"): + return path[len("\\\\?\\") :] + return path + + def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) @@ -24,7 +55,18 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") - cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args) + # Strip the Windows extended-length path prefix from sys.executable so it + # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted + # command lines run through cmd.exe. + python_exe = _strip_win_long_path_prefix(sys.executable) + if python_exe != sys.executable: + # Only override PYTHONEXEPATH when we actually stripped a prefix. + # PlatformIO's get_pythonexe_path() reads this and falls back to + # sys.executable otherwise; setting it unconditionally would clobber + # a user-provided value (or the unmodified path on platforms that + # don't need the strip). + os.environ["PYTHONEXEPATH"] = python_exe + cmd = [python_exe, "-m", "esphome.platformio_runner"] + list(args) return run_external_process(*cmd, **kwargs) diff --git a/requirements.txt b/requirements.txt index abc8ac5dbb6..789a3f79957 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260425.0 -aioesphomeapi==44.22.0 +aioesphomeapi==44.23.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 67e64e5f612..b241622f89d 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -311,6 +311,105 @@ def test_run_platformio_cli_sets_environment_variables( assert "arg" in args +@pytest.mark.parametrize( + ("platform", "input_path", "expected"), + [ + # win32: drive-letter extended-length prefix is stripped + ( + "win32", + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + ), + # win32: UNC extended-length prefix is translated to a regular UNC path + ( + "win32", + "\\\\?\\UNC\\server\\share\\python.exe", + "\\\\server\\share\\python.exe", + ), + # win32: paths without the prefix are returned unchanged + ( + "win32", + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe", + ), + # non-win32: prefix is left alone (no-op) + ("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"), + ("darwin", "/usr/bin/python3", "/usr/bin/python3"), + ], +) +def test_strip_win_long_path_prefix( + platform: str, input_path: str, expected: str +) -> None: + r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32.""" + with patch("esphome.platformio_api.sys.platform", platform): + assert platformio_api._strip_win_long_path_prefix(input_path) == expected + + +def test_run_platformio_cli_strips_win_long_path_prefix( + setup_core: Path, mock_run_external_process: Mock +) -> None: + r"""Windows ``\\?\`` prefix on sys.executable does not leak into the subprocess. + + The NSIS-installed esphome.exe launcher starts Python with + ``sys.executable`` already prefixed by the extended-length path marker. + That prefix would otherwise propagate into PlatformIO's ``PYTHONEXE`` and + break SCons-emitted command lines run through ``cmd.exe``. + """ + CORE.build_path = str(setup_core / "build" / "test") + prefixed_exe = ( + "\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe" + ) + stripped_exe = ( + "C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe" + ) + + with ( + patch.dict(os.environ, {}, clear=False), + patch("esphome.platformio_api.sys.platform", "win32"), + patch("esphome.platformio_api.sys.executable", prefixed_exe), + ): + # Pop any pre-existing PYTHONEXEPATH so the assertion below reflects + # what run_platformio_cli set, not whatever the test runner's + # environment happened to contain. + os.environ.pop("PYTHONEXEPATH", None) + mock_run_external_process.return_value = 0 + platformio_api.run_platformio_cli("test", "arg") + + # The subprocess is invoked with the stripped executable path. + mock_run_external_process.assert_called_once() + args = mock_run_external_process.call_args[0] + assert args[0] == stripped_exe + # PYTHONEXEPATH is exported with the stripped path so PlatformIO's + # get_pythonexe_path() picks it up in the subprocess. + assert os.environ["PYTHONEXEPATH"] == stripped_exe + + +def test_run_platformio_cli_does_not_set_pythonexepath_without_strip( + setup_core: Path, mock_run_external_process: Mock +) -> None: + r"""PYTHONEXEPATH is not touched when sys.executable has no ``\\?\`` prefix. + + Setting it unconditionally would clobber a user-provided value (or + interfere with non-Windows tooling that has no prefix to strip). + """ + CORE.build_path = str(setup_core / "build" / "test") + plain_exe = "/usr/bin/python3" + + with ( + patch.dict(os.environ, {}, clear=False), + patch("esphome.platformio_api.sys.platform", "linux"), + patch("esphome.platformio_api.sys.executable", plain_exe), + ): + os.environ.pop("PYTHONEXEPATH", None) + mock_run_external_process.return_value = 0 + platformio_api.run_platformio_cli("test", "arg") + + mock_run_external_process.assert_called_once() + args = mock_run_external_process.call_args[0] + assert args[0] == plain_exe + assert "PYTHONEXEPATH" not in os.environ + + def test_run_platformio_cli_run_builds_command( setup_core: Path, mock_run_platformio_cli: Mock ) -> None: