Compare commits

...
Author SHA1 Message Date
J. Nick Koston 87a2b623f3 [sensor] Drop incorrect O(1) complexity claim from timeout filter comment
set_timer_common_'s self-key cancel path scans items_ and to_add_ linearly,
so the cancel-and-replace is O(N) in the global scheduler item count, not
O(1). Reword to describe the behavior without the complexity claim.

Address Copilot review feedback.
2026-04-30 11:01:24 -05:00
J. Nick Koston 6ccc2b23b5 [sensor] Drop Component from timeout filters, use self-keyed scheduler
Migrates TimeoutFilterBase / TimeoutFilterLast / TimeoutFilterConfigured off
Component, mirroring the migration #16132 did for the other Component-based
sensor filters. They now arm/re-arm via App.scheduler.set_timeout(this, ...)
keyed on the filter pointer; the scheduler cancels and replaces any pending
arm in O(1) on each new_value(). Filters live for the program's lifetime, so
the self-key never dangles.

Net effect: this reverts the design from #11922, which moved timeout filters
off the scheduler specifically because the bounded SchedulerItem pool churned
on devices with many timers (LD2450 etc.). With #16172 replacing that pool
with an unbounded intrusive freelist, the original churn problem is gone and
the scheduler is once again the right primitive for this workload.

Per-instance footprint shrinks: lose Component (second vptr, packed bookkeeping
bytes, ComponentRuntimeStats block when runtime_stats: is enabled) and drop
the now-unused timeout_start_time_ field. get_setup_priority() and the loop()
poll go with it.

Sensor timeout-filter syntax is unchanged. No user-facing change.
2026-04-30 10:49:18 -05:00
Kevin AhrendtandGitHub 2758aa5517 [audio] bump microOpus to v0.4.0 to use fixed-point by default on ESP32 (#16168) 2026-04-30 09:12:39 -04:00
Kevin AhrendtandGitHub a8b0133ec1 [audio] Enable specific codecs and configure advanced features (#16166) 2026-04-30 08:49:28 -04:00
Clyde StubbsandGitHub 1398dcebb4 [st7789v] Add deprecation warnings (#16162) 2026-04-30 00:53:37 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
096d0c4279 Bump aioesphomeapi from 44.22.0 to 44.23.0 (#16161)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 04:45:19 +00:00
Jesse HillsandGitHub e127268dac [core] Strip \\?\ prefix from sys.executable for PlatformIO subprocess (#16158) 2026-04-30 16:04:52 +12:00
J. Nick KostonandGitHub f0bffed3c0 [esp8266] Move HAL bodies into components/esp8266/hal.cpp + inline arch_init (#16112) 2026-04-30 15:42:17 +12:00
Jesse HillsandGitHub 1a871e231d [ci] Use client-id for GitHub App token generation (#16155) 2026-04-30 13:09:37 +12:00
Jesse HillsandGitHub 47765bd2d0 [ci] Correct version comment on create-github-app-token pin (#16156) 2026-04-30 13:08:56 +12:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8066325e0b 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>
2026-04-30 12:52:25 +12:00
24 changed files with 520 additions and 167 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+193 -5
View File
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
import esphome.codegen as cg
from esphome.components.esp32 import (
@@ -7,7 +7,12 @@ from esphome.components.esp32 import (
include_builtin_idf_component,
)
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
from esphome.const import (
CONF_BITS_PER_SAMPLE,
CONF_NUM_CHANNELS,
CONF_SAMPLE_RATE,
CONF_SIZE,
)
from esphome.core import CORE
import esphome.final_validate as fv
@@ -25,13 +30,46 @@ AUDIO_FILE_TYPE_ENUM = {
"OPUS": AudioFileType.OPUS,
}
MEMORY_PSRAM = "psram"
MEMORY_INTERNAL = "internal"
MEMORY_LOCATIONS = [MEMORY_PSRAM, MEMORY_INTERNAL]
@dataclass
class FlacOptions:
buffer_memory: str | None = None
@dataclass
class Mp3Options:
buffer_memory: str | None = None
@dataclass
class OpusPseudostackOptions:
threadsafe: bool | None = None
buffer_memory: str | None = None
size: int | None = None
@dataclass
class OpusOptions:
floating_point: bool | None = None
state_memory: str | None = None
pseudostack: OpusPseudostackOptions = field(default_factory=OpusPseudostackOptions)
@dataclass
class AudioData:
flac_support: bool = False
mp3_support: bool = False
opus_support: bool = False
# WAV defaults to True for backward compatibility; will become opt-in in a future release
wav_support: bool = True
micro_decoder_support: bool = False
flac: FlacOptions = field(default_factory=FlacOptions)
mp3: Mp3Options = field(default_factory=Mp3Options)
opus: OpusOptions = field(default_factory=OpusOptions)
def _get_data() -> AudioData:
@@ -55,6 +93,11 @@ def request_opus_support() -> None:
_get_data().opus_support = True
def request_wav_support() -> None:
"""Request WAV codec support for audio decoding."""
_get_data().wav_support = True
def request_micro_decoder_support() -> None:
"""Request micro-decoder library support for audio decoding."""
_get_data().micro_decoder_support = True
@@ -67,9 +110,78 @@ CONF_MAX_CHANNELS = "max_channels"
CONF_MIN_SAMPLE_RATE = "min_sample_rate"
CONF_MAX_SAMPLE_RATE = "max_sample_rate"
CONF_CODECS = "codecs"
CONF_WAV = "wav"
CONF_FLAC = "flac"
CONF_MP3 = "mp3"
CONF_OPUS = "opus"
CONF_BUFFER_MEMORY = "buffer_memory"
CONF_FLOATING_POINT = "floating_point"
CONF_STATE_MEMORY = "state_memory"
CONF_PSEUDOSTACK = "pseudostack"
CONF_THREADSAFE = "threadsafe"
_MEMORY_LOCATION_VALIDATOR = cv.one_of(*MEMORY_LOCATIONS, lower=True)
def _maybe_empty_codec(schema):
"""Wrap a codec dict schema so that a bare key (None value) is treated as an empty dict."""
def validator(value):
if value is None:
value = {}
return schema(value)
return validator
CODEC_FLAC_SCHEMA = cv.Schema(
{
cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
}
)
CODEC_MP3_SCHEMA = cv.Schema(
{
cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
}
)
OPUS_PSEUDOSTACK_SCHEMA = cv.Schema(
{
cv.Optional(CONF_THREADSAFE): cv.boolean,
cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
cv.Optional(CONF_SIZE): cv.int_range(60000, 240000),
}
)
CODEC_OPUS_SCHEMA = cv.Schema(
{
cv.Optional(CONF_FLOATING_POINT): cv.boolean,
cv.Optional(CONF_STATE_MEMORY): _MEMORY_LOCATION_VALIDATOR,
cv.Optional(CONF_PSEUDOSTACK): _maybe_empty_codec(OPUS_PSEUDOSTACK_SCHEMA),
}
)
CODEC_WAV_SCHEMA = cv.Schema({})
CODECS_SCHEMA = cv.Schema(
{
cv.Optional(CONF_FLAC): _maybe_empty_codec(CODEC_FLAC_SCHEMA),
cv.Optional(CONF_MP3): _maybe_empty_codec(CODEC_MP3_SCHEMA),
cv.Optional(CONF_OPUS): _maybe_empty_codec(CODEC_OPUS_SCHEMA),
cv.Optional(CONF_WAV): _maybe_empty_codec(CODEC_WAV_SCHEMA),
}
)
CONFIG_SCHEMA = cv.All(
cv.Schema({}),
cv.Schema(
{
cv.Optional(CONF_CODECS): _maybe_empty_codec(CODECS_SCHEMA),
}
),
cv.only_on_esp32,
)
AUDIO_COMPONENT_SCHEMA = cv.Schema(
@@ -208,6 +320,15 @@ def final_validate_audio_schema(
)
def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> None:
if value == MEMORY_PSRAM:
add_idf_sdkconfig_option(psram_key, True)
add_idf_sdkconfig_option(internal_key, False)
elif value == MEMORY_INTERNAL:
add_idf_sdkconfig_option(psram_key, False)
add_idf_sdkconfig_option(internal_key, True)
async def to_code(config):
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
include_builtin_idf_component("esp_http_client")
@@ -219,6 +340,36 @@ async def to_code(config):
data = _get_data()
# Merge user-supplied codec configuration (additive: presence enables the codec)
if codecs_config := config.get(CONF_CODECS):
if (flac_config := codecs_config.get(CONF_FLAC)) is not None:
data.flac_support = True
if (buffer_memory := flac_config.get(CONF_BUFFER_MEMORY)) is not None:
data.flac.buffer_memory = buffer_memory
if (mp3_config := codecs_config.get(CONF_MP3)) is not None:
data.mp3_support = True
if (buffer_memory := mp3_config.get(CONF_BUFFER_MEMORY)) is not None:
data.mp3.buffer_memory = buffer_memory
if (opus_config := codecs_config.get(CONF_OPUS)) is not None:
data.opus_support = True
floating_point = opus_config.get(CONF_FLOATING_POINT)
if floating_point is not None:
data.opus.floating_point = floating_point
if (state_memory := opus_config.get(CONF_STATE_MEMORY)) is not None:
data.opus.state_memory = state_memory
if (pseudostack_config := opus_config.get(CONF_PSEUDOSTACK)) is not None:
threadsafe = pseudostack_config.get(CONF_THREADSAFE)
if threadsafe is not None:
data.opus.pseudostack.threadsafe = threadsafe
if (
buffer_memory := pseudostack_config.get(CONF_BUFFER_MEMORY)
) is not None:
data.opus.pseudostack.buffer_memory = buffer_memory
if (size := pseudostack_config.get(CONF_SIZE)) is not None:
data.opus.pseudostack.size = size
if CONF_WAV in codecs_config:
data.wav_support = True
if data.micro_decoder_support:
add_idf_component(name="esphome/micro-decoder", ref="0.2.0")
@@ -229,13 +380,50 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
if not data.opus_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
if not data.wav_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False)
# Legacy audio_decoder.cpp support defines and components
# Configure each codec library.
# Adds a define and IDF component for legacy `audio_decoder.cpp`.
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.1.1")
_emit_memory_pair(
data.flac.buffer_memory,
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
"CONFIG_MICRO_FLAC_PREFER_INTERNAL",
)
if data.mp3_support:
cg.add_define("USE_AUDIO_MP3_SUPPORT")
_emit_memory_pair(
data.mp3.buffer_memory,
"CONFIG_MP3_DECODER_PREFER_PSRAM",
"CONFIG_MP3_DECODER_PREFER_INTERNAL",
)
if data.opus_support:
cg.add_define("USE_AUDIO_OPUS_SUPPORT")
add_idf_component(name="esphome/micro-opus", ref="0.3.6")
add_idf_component(name="esphome/micro-opus", ref="0.4.0")
if data.opus.floating_point is not None:
add_idf_sdkconfig_option(
"CONFIG_OPUS_FLOATING_POINT", data.opus.floating_point
)
_emit_memory_pair(
data.opus.state_memory,
"CONFIG_OPUS_STATE_PREFER_PSRAM",
"CONFIG_OPUS_STATE_PREFER_INTERNAL",
)
if data.opus.pseudostack.threadsafe is True:
add_idf_sdkconfig_option("CONFIG_OPUS_THREADSAFE_PSEUDOSTACK", True)
add_idf_sdkconfig_option("CONFIG_OPUS_NONTHREADSAFE_PSEUDOSTACK", False)
elif data.opus.pseudostack.threadsafe is False:
add_idf_sdkconfig_option("CONFIG_OPUS_THREADSAFE_PSEUDOSTACK", False)
add_idf_sdkconfig_option("CONFIG_OPUS_NONTHREADSAFE_PSEUDOSTACK", True)
_emit_memory_pair(
data.opus.pseudostack.buffer_memory,
"CONFIG_OPUS_PSEUDOSTACK_PREFER_PSRAM",
"CONFIG_OPUS_PSEUDOSTACK_PREFER_INTERNAL",
)
if data.opus.pseudostack.size is not None:
add_idf_sdkconfig_option(
"CONFIG_OPUS_PSEUDOSTACK_SIZE", data.opus.pseudostack.size
)
+2 -96
View File
@@ -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 <Arduino.h>
#include <core_esp8266_features.h>
extern "C" {
#include <user_interface.h>
}
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 1100 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
+111
View File
@@ -0,0 +1,111 @@
#ifdef USE_ESP8266
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <Arduino.h>
#include <core_esp8266_features.h>
extern "C" {
#include <user_interface.h>
}
// 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 1100 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
+1 -2
View File
@@ -280,7 +280,7 @@ ThrottleWithPriorityFilter = sensor_ns.class_(
ThrottleWithPriorityNanFilter = sensor_ns.class_(
"ThrottleWithPriorityNanFilter", Filter
)
TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component)
TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter)
TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase)
TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase)
DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component)
@@ -730,7 +730,6 @@ async def timeout_filter_to_code(config, filter_id):
filter_id.type = TimeoutFilterConfigured
template_ = await cg.templatable(config[CONF_VALUE], [], cg.float_)
var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_)
await cg.register_component(var, {})
return var
+7 -29
View File
@@ -322,41 +322,19 @@ optional<float> or_filter_new_value(Filter **filters, size_t count, float value,
return {};
}
// TimeoutFilterBase - shared loop logic
void TimeoutFilterBase::loop() {
// Check if timeout period has elapsed
// Use cached loop start time to avoid repeated millis() calls
const uint32_t now = App.get_loop_component_start_time();
if (now - this->timeout_start_time_ >= this->time_period_) {
// Timeout fired - get output value from derived class and output it
this->output(this->get_output_value());
// Disable loop until next value arrives
this->disable_loop();
}
}
float TimeoutFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; }
// TimeoutFilterLast - "last" mode implementation
// TimeoutFilterLast - "last" mode: re-arm on every input; output the latest value if no further
// input arrives within time_period_. Self-keyed scheduler.set_timeout(this, ...) cancels any
// pending arm with the same self-key and installs a new one.
optional<float> TimeoutFilterLast::new_value(float value) {
// Store the value to output when timeout fires
this->pending_value_ = value;
// Record when timeout started and enable loop
this->timeout_start_time_ = millis();
this->enable_loop();
App.scheduler.set_timeout(this, this->time_period_, [this]() { this->output(this->pending_value_); });
return value;
}
// TimeoutFilterConfigured - configured value mode implementation
// TimeoutFilterConfigured - configured-value mode: re-arm on every input; output the configured
// value (static or lambda) if no further input arrives within time_period_.
optional<float> TimeoutFilterConfigured::new_value(float value) {
// Record when timeout started and enable loop
// Note: we don't store the incoming value since we have a configured value
this->timeout_start_time_ = millis();
this->enable_loop();
App.scheduler.set_timeout(this, this->time_period_, [this]() { this->output(this->value_.value()); });
return value;
}
+9 -20
View File
@@ -412,22 +412,15 @@ class ThrottleWithPriorityNanFilter : public Filter {
uint32_t min_time_between_inputs_;
};
// Base class for timeout filters - contains common loop logic
class TimeoutFilterBase : public Filter, public Component {
public:
void loop() override;
float get_setup_priority() const override;
// Base class for timeout filters. Self-keyed scheduler timeout (`this` as key) re-arms on each
// new_value(). Filter instances live for the program's lifetime, so the scheduler key never dangles.
class TimeoutFilterBase : public Filter {
protected:
explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) { this->disable_loop(); }
virtual float get_output_value() = 0;
uint32_t time_period_; // 4 bytes (timeout duration in ms)
uint32_t timeout_start_time_{0}; // 4 bytes (when the timeout was started)
// Total base: 8 bytes
explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) {}
uint32_t time_period_;
};
// Timeout filter for "last" mode - outputs the last received value after timeout
// "last" mode outputs the most recent input after time_period_ ms of silence.
class TimeoutFilterLast : public TimeoutFilterBase {
public:
explicit TimeoutFilterLast(uint32_t time_period) : TimeoutFilterBase(time_period) {}
@@ -435,12 +428,10 @@ class TimeoutFilterLast : public TimeoutFilterBase {
optional<float> new_value(float value) override;
protected:
float get_output_value() override { return this->pending_value_; }
float pending_value_{0}; // 4 bytes (value to output when timeout fires)
// Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead
float pending_value_{0};
};
// Timeout filter with configured value - evaluates TemplatableValue after timeout
// Configured-value mode — outputs a static or lambda value after time_period_ ms of silence.
class TimeoutFilterConfigured : public TimeoutFilterBase {
public:
explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableFn<float> &new_value)
@@ -449,9 +440,7 @@ class TimeoutFilterConfigured : public TimeoutFilterBase {
optional<float> new_value(float value) override;
protected:
float get_output_value() override { return this->value_.value(); }
TemplatableFn<float> value_; // 4 bytes (configured output value, can be lambda)
// Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead
TemplatableFn<float> value_;
};
class DebounceFilter : public Filter, public Component {
+5
View File
@@ -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.
"""
+7
View File
@@ -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)
+3 -4
View File
@@ -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_<platform>.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_<platform>.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.
+3
View File
@@ -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
+6 -2
View File
@@ -3,6 +3,7 @@
#ifdef USE_ESP8266
#include <c_types.h>
#include <core_esp8266_features.h>
#include <cstdint>
#include <pgmspace.h>
@@ -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 <core_esp8266_features.h>; 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
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+1 -1
View File
@@ -10,7 +10,7 @@ dependencies:
esphome/micro-flac:
version: 0.1.1
esphome/micro-opus:
version: 0.3.6
version: 0.4.0
espressif/esp-dsp:
version: "1.7.1"
espressif/esp-tflite-micro:
+43 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
audio:
codecs:
flac:
buffer_memory: internal
mp3:
buffer_memory: psram
opus:
floating_point: false
state_memory: psram
pseudostack:
threadsafe: false
buffer_memory: internal
size: 80000
wav:
@@ -0,0 +1 @@
<<: !include common.yaml
+99
View File
@@ -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: