From f1d3be4bdaaa1d6fcf7119d075b41cdc57ea31eb Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 30 Apr 2026 12:03:40 -0400 Subject: [PATCH 01/19] [core] Simplify RAMAllocator and add internal fallback to external mode (#16171) --- esphome/core/helpers.h | 61 ++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 355db6c7f4..07bcb7a74f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -2045,7 +2045,8 @@ void delay_microseconds_safe(uint32_t us); * Returns `nullptr` in case no memory is available. * * By setting flags, it can be configured to: - * - perform external allocation falling back to main memory if SPI RAM is full or unavailable + * - perform external allocation falling back to internal memory if SPI RAM is full or unavailable (default) + * - perform internal allocation falling back to external memory (with PREFER_INTERNAL) * - perform external allocation only * - perform internal allocation only */ @@ -2054,16 +2055,26 @@ template class RAMAllocator { using value_type = T; enum Flags { - NONE = 0, // Perform external allocation and fall back to internal memory - ALLOC_EXTERNAL = 1 << 0, // Perform external allocation only. - ALLOC_INTERNAL = 1 << 1, // Perform internal allocation only. - ALLOW_FAILURE = 1 << 2, // Does nothing. Kept for compatibility. + NONE = 0, // Perform external allocation and fall back to internal memory + ALLOC_EXTERNAL = 1 << 0, // Perform external allocation only. + ALLOC_INTERNAL = 1 << 1, // Perform internal allocation only. + ALLOW_FAILURE = 1 << 2, // Does nothing. Kept for compatibility. + PREFER_INTERNAL = 1 << 3, // Perform internal allocation and fall back to external memory }; constexpr RAMAllocator() = default; - constexpr RAMAllocator(uint8_t flags) - : flags_((flags & (ALLOC_INTERNAL | ALLOC_EXTERNAL)) != 0 ? (flags & (ALLOC_INTERNAL | ALLOC_EXTERNAL)) - : (ALLOC_INTERNAL | ALLOC_EXTERNAL)) {} + constexpr RAMAllocator(uint8_t flags) { + if (flags & PREFER_INTERNAL) { + this->flags_ = ALLOC_INTERNAL | ALLOC_EXTERNAL | PREFER_INTERNAL; + return; + } + const uint8_t alloc_bits = flags & (ALLOC_INTERNAL | ALLOC_EXTERNAL); + if (alloc_bits != 0) { + this->flags_ = alloc_bits; + return; + } + this->flags_ = ALLOC_INTERNAL | ALLOC_EXTERNAL; + } template constexpr RAMAllocator(const RAMAllocator &other) : flags_{other.flags_} {} T *allocate(size_t n) { return this->allocate(n, sizeof(T)); } @@ -2072,12 +2083,8 @@ template class RAMAllocator { size_t size = n * manual_size; T *ptr = nullptr; #ifdef USE_ESP32 - if (this->flags_ & Flags::ALLOC_EXTERNAL) { - ptr = static_cast(heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); - } - if (ptr == nullptr && this->flags_ & Flags::ALLOC_INTERNAL) { - ptr = static_cast(heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); - } + const auto caps = this->get_caps_(); + ptr = static_cast(heap_caps_malloc_prefer(size, 2, caps[0], caps[1])); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(malloc(size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2091,12 +2098,8 @@ template class RAMAllocator { size_t size = n * manual_size; T *ptr = nullptr; #ifdef USE_ESP32 - if (this->flags_ & Flags::ALLOC_EXTERNAL) { - ptr = static_cast(heap_caps_realloc(p, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); - } - if (ptr == nullptr && this->flags_ & Flags::ALLOC_INTERNAL) { - ptr = static_cast(heap_caps_realloc(p, size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); - } + const auto caps = this->get_caps_(); + ptr = static_cast(heap_caps_realloc_prefer(p, size, 2, caps[0], caps[1])); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(realloc(p, size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2147,6 +2150,24 @@ template class RAMAllocator { } private: +#ifdef USE_ESP32 + /// Returns {primary_caps, fallback_caps} for heap_caps_*_prefer based on the configured flags. + /// PREFER_INTERNAL implies both regions are enabled (enforced by the constructor), so when it is set + /// the primary is internal and the fallback is external. Otherwise the primary is whichever region + /// is enabled (external preferred when both are enabled), and the fallback is the other region (or + /// the same region when only one is enabled, making the second attempt a no-op). + std::array get_caps_() const { + constexpr uint32_t external_caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; + constexpr uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; + if (this->flags_ & PREFER_INTERNAL) { + return {internal_caps, external_caps}; + } + const uint32_t primary = (this->flags_ & ALLOC_EXTERNAL) ? external_caps : internal_caps; + const uint32_t fallback = (this->flags_ & ALLOC_INTERNAL) ? internal_caps : external_caps; + return {primary, fallback}; + } +#endif + uint8_t flags_{ALLOC_INTERNAL | ALLOC_EXTERNAL}; }; From d48aad8c4d42431beb4f7f89f35bf42628128a04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 12:27:54 -0500 Subject: [PATCH 02/19] [esp32] Replace 512B stack buffer in printf wraps with picolibc cookie FILE (#16170) --- esphome/components/esp32/printf_stubs.cpp | 77 +++++++++++++++++++---- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32/printf_stubs.cpp b/esphome/components/esp32/printf_stubs.cpp index 386fbbd79d..908b4023ea 100644 --- a/esphome/components/esp32/printf_stubs.cpp +++ b/esphome/components/esp32/printf_stubs.cpp @@ -13,14 +13,21 @@ * and printf() calls in SDK components are only in debug/assert paths * (gpio_dump_io_configuration, ringbuf diagnostics) that are either * GC'd or never called. Crash backtraces and panic output are - * unaffected — they use esp_rom_printf() which is a ROM function + * unaffected; they use esp_rom_printf() which is a ROM function * and does not go through libc. * - * These stubs redirect through vsnprintf() (which uses _svfprintf_r - * already in the binary) and fwrite(), allowing the linker to - * dead-code eliminate _vfprintf_r. + * On picolibc (default for IDF >= 5 on RISC-V, IDF >= 6 everywhere) we + * route output through a stack-allocated cookie FILE that forwards each + * byte to the real target stream via fputc(). Picolibc's tinystdio + * vfprintf walks the FILE::put callback one character at a time, so this + * costs ~32 bytes of stack for the cookie struct vs. a 512-byte format + * buffer. The buffered path overflows the loopTask stack on IDF 6. * - * Saves ~11 KB of flash. + * On newlib (IDF <= 5 on Xtensa) we keep the original snprintf-then-fwrite + * path because that loopTask stack budget has plenty of headroom for the + * 512-byte buffer; the picolibc-only crash above does not affect it. + * + * Saves ~11 KB of flash on newlib, ~2.8 KB on picolibc. * * To disable these wraps, set enable_full_printf: true in the esp32 * advanced config section. @@ -30,10 +37,55 @@ #include #include +#ifndef __PICOLIBC__ #include "esp_system.h" +#endif namespace esphome::esp32 {} +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +#ifdef __PICOLIBC__ + +#include +#include + +extern int __real_vfprintf(FILE *stream, const char *fmt, va_list ap); + +namespace { + +struct CookieFile { + FILE base; + FILE *target; +}; + +// cookie_put() recovers CookieFile* from FILE* via reinterpret_cast, which is +// only well-defined when FILE is the first member at offset 0 and CookieFile +// is standard-layout. +static_assert(offsetof(CookieFile, base) == 0, "FILE must be the first member of CookieFile"); +static_assert(std::is_standard_layout::value, "CookieFile must be standard-layout"); + +int cookie_put(char c, FILE *stream) { + auto *cookie = reinterpret_cast(stream); + return fputc(static_cast(c), cookie->target); +} + +const FILE COOKIE_FILE_TEMPLATE = FDEV_SETUP_STREAM(cookie_put, nullptr, nullptr, _FDEV_SETUP_WRITE); + +} // namespace + +int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { + CookieFile cookie; + cookie.base = COOKIE_FILE_TEMPLATE; + cookie.target = stream; + return __real_vfprintf(&cookie.base, fmt, ap); +} + +int __wrap_vprintf(const char *fmt, va_list ap) { return __wrap_vfprintf(stdout, fmt, ap); } + +#else // !__PICOLIBC__ + static constexpr size_t PRINTF_BUFFER_SIZE = 512; // These stubs are essentially dead code at runtime — ESPHome replaces the @@ -55,14 +107,18 @@ static int write_printf_buffer(FILE *stream, char *buf, int len) { return len; } -// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -extern "C" { - int __wrap_vprintf(const char *fmt, va_list ap) { char buf[PRINTF_BUFFER_SIZE]; return write_printf_buffer(stdout, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); } +int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { + char buf[PRINTF_BUFFER_SIZE]; + return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); +} + +#endif // __PICOLIBC__ + int __wrap_printf(const char *fmt, ...) { va_list ap; va_start(ap, fmt); @@ -71,11 +127,6 @@ int __wrap_printf(const char *fmt, ...) { return len; } -int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) { - char buf[PRINTF_BUFFER_SIZE]; - return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); -} - int __wrap_fprintf(FILE *stream, const char *fmt, ...) { va_list ap; va_start(ap, fmt); From 61261b4a592fa449a1e820a6392c4696fbc3d49a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 12:33:22 -0500 Subject: [PATCH 03/19] [libretiny] Move HAL bodies into components/libretiny/hal.cpp + inline trivial dispatches (#16113) --- esphome/components/libretiny/core.cpp | 53 +-------------------------- esphome/components/libretiny/hal.cpp | 53 +++++++++++++++++++++++++++ esphome/core/hal/hal_libretiny.h | 18 +++++++-- 3 files changed, 69 insertions(+), 55 deletions(-) create mode 100644 esphome/components/libretiny/hal.cpp diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index f46abe3b81..8686a41e64 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -1,55 +1,6 @@ #ifdef USE_LIBRETINY -#include "core.h" -#include "esphome/core/defines.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" -#include "preferences.h" - -#include -#include - -void setup(); -void loop(); - -namespace esphome { - -// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); } - -void arch_init() { - libretiny::setup_preferences(); - lt_wdt_enable(10000L); -#ifdef USE_BK72XX - // BK72xx SDK creates the main Arduino task at priority 3, which is lower than - // all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop - // stalls whenever WiFi background processing runs, because the main task - // cannot resume until every higher-priority task finishes. - // - // By contrast, RTL87xx creates the main task at osPriorityRealtime (highest). - // - // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the - // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. - // This is safe because ESPHome yields voluntarily via wakeable_delay() and - // the Arduino mainTask yield() after each loop() iteration. - static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; - static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); - vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY); -#endif -#if LT_GPIO_RECOVER - lt_gpio_recover(); -#endif -} - -void arch_restart() { - lt_reboot(); - while (1) { - } -} -void HOT arch_feed_wdt() { lt_wdt_feed(); } -uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); } -uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); } - -} // namespace esphome +// HAL functions live in hal.cpp. core.cpp is intentionally empty for +// libretiny — there is no extra component bootstrap to keep here. #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp new file mode 100644 index 0000000000..e6dbb7296c --- /dev/null +++ b/esphome/components/libretiny/hal.cpp @@ -0,0 +1,53 @@ +#ifdef USE_LIBRETINY + +#include "core.h" +#include "esphome/core/hal.h" +#include "preferences.h" + +#include +#include + +// Empty libretiny namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// libretiny component's API. +namespace esphome::libretiny {} // namespace esphome::libretiny + +namespace esphome { + +// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), +// arch_feed_wdt(), arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() +// inlined in core/hal/hal_libretiny.h. + +void arch_init() { + libretiny::setup_preferences(); + lt_wdt_enable(10000L); +#ifdef USE_BK72XX + // BK72xx SDK creates the main Arduino task at priority 3, which is lower than + // all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop + // stalls whenever WiFi background processing runs, because the main task + // cannot resume until every higher-priority task finishes. + // + // By contrast, RTL87xx creates the main task at osPriorityRealtime (highest). + // + // Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the + // main loop, but below the TCP/IP thread (7) so packet processing keeps priority. + // This is safe because ESPHome yields voluntarily via wakeable_delay() and + // the Arduino mainTask yield() after each loop() iteration. + static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6; + static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES"); + vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY); +#endif +#if LT_GPIO_RECOVER + lt_gpio_recover(); +#endif +} + +void arch_restart() { + lt_reboot(); + while (1) { + } +} + +} // namespace esphome + +#endif // USE_LIBRETINY diff --git a/esphome/core/hal/hal_libretiny.h b/esphome/core/hal/hal_libretiny.h index ecfe830fe3..db0fc11bfb 100644 --- a/esphome/core/hal/hal_libretiny.h +++ b/esphome/core/hal/hal_libretiny.h @@ -51,8 +51,16 @@ extern "C" void yield(void); extern "C" void delay(unsigned long ms); extern "C" unsigned long micros(void); extern "C" unsigned long millis(void); +extern "C" void delayMicroseconds(unsigned int us); // NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration) +// Forward decls from libretiny's family for the inline arch_* +// wrappers below. Pulling the full header would drag in the rest of the +// LibreTiny C API. +extern "C" void lt_wdt_feed(void); +extern "C" uint32_t lt_cpu_get_cycle_count(void); +extern "C" uint32_t lt_cpu_get_freq(void); + namespace esphome { /// Returns true when executing inside an interrupt handler. @@ -88,11 +96,13 @@ __attribute__((always_inline)) inline uint32_t millis() { return static_cast Date: Thu, 30 Apr 2026 19:10:53 -0500 Subject: [PATCH 04/19] [ci] Split integration tests into 3 buckets when count is more than 10 (#16152) --- .github/workflows/ci.yml | 29 +++++----- script/determine-jobs.py | 66 ++++++++++++++++++++-- tests/script/test_determine_jobs.py | 88 +++++++++++++++++++++++++++-- 3 files changed, 156 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57053c3645..3af1709774 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,8 +199,7 @@ jobs: - common outputs: integration-tests: ${{ steps.determine.outputs.integration-tests }} - integration-tests-run-all: ${{ steps.determine.outputs.integration-tests-run-all }} - integration-test-files: ${{ steps.determine.outputs.integration-test-files }} + integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} python-linters: ${{ steps.determine.outputs.python-linters }} @@ -243,8 +242,7 @@ jobs: # Extract individual fields echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT - echo "integration-tests-run-all=$(echo "$output" | jq -r '.integration_tests_run_all')" >> $GITHUB_OUTPUT - echo "integration-test-files=$(echo "$output" | jq -c '.integration_test_files')" >> $GITHUB_OUTPUT + echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT @@ -267,12 +265,16 @@ jobs: key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} integration-tests: - name: Run integration tests + name: Run integration tests (${{ matrix.bucket.name }}) runs-on: ubuntu-latest needs: - common - determine-jobs if: needs.determine-jobs.outputs.integration-tests == 'true' + strategy: + fail-fast: false + matrix: + bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -299,19 +301,14 @@ jobs: run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - name: Run integration tests env: - INTEGRATION_TEST_FILES: ${{ needs.determine-jobs.outputs.integration-test-files }} - INTEGRATION_TESTS_RUN_ALL: ${{ needs.determine-jobs.outputs.integration-tests-run-all }} + # JSON array of test paths; parsed into a bash array below to avoid + # shell word-splitting / glob hazards. + BUCKET_TESTS: ${{ toJson(matrix.bucket.tests) }} run: | . venv/bin/activate - if [[ "$INTEGRATION_TESTS_RUN_ALL" == "true" ]]; then - echo "Running all integration tests" - pytest -vv --no-cov --tb=native -n auto tests/integration/ - else - # Parse JSON array into bash array to avoid shell expansion issues - mapfile -t test_files < <(echo "$INTEGRATION_TEST_FILES" | jq -r '.[]') - echo "Running ${#test_files[@]} specific integration tests" - pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" - fi + mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') + echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" + pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" cpp-unit-tests: name: Run C++ unit tests diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 6fd7ab297c..c0cf8ecbdc 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -6,8 +6,7 @@ what files have changed. It outputs JSON with the following structure: { "integration_tests": true/false, - "integration_tests_run_all": true/false, - "integration_test_files": ["tests/integration/test_foo.py", ...], + "integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...], "clang_tidy": true/false, "clang_format": true/false, "python_linters": true/false, @@ -81,6 +80,62 @@ CLANG_TIDY_SPLIT_THRESHOLD = 65 # Isolated components count as 10x, groupable components count as 1x COMPONENT_TEST_BATCH_SIZE = 40 +# Integration test bucketing: when more than the threshold tests are scheduled, +# fan out across this many parallel jobs. Below the threshold, a single job runs. +INTEGRATION_TESTS_SPLIT_THRESHOLD = 10 +INTEGRATION_TESTS_SPLIT_BUCKETS = 3 + + +def _split_list(items: list[str], n: int) -> list[list[str]]: + """Split a list into n roughly-equal contiguous parts (matches script/clang-tidy).""" + k, m = divmod(len(items), n) + return [items[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)] + + +def _all_integration_test_files() -> list[str]: + """Return all integration test file paths, sorted, relative to repo root.""" + return sorted( + str(p.relative_to(root_path)) + for p in (Path(root_path) / "tests" / "integration").glob("test_*.py") + ) + + +def _compute_integration_test_buckets( + integration_run_all: bool, + integration_test_files: list[str], +) -> tuple[bool, list[dict[str, Any]]]: + """Compute (run_integration, buckets) from the determine_integration_tests result. + + Pure function for unit testing — no I/O beyond `_all_integration_test_files` + when `integration_run_all` is set. + + `buckets` is a list of `{name, tests}` dicts where `tests` is a JSON-friendly + list of file paths so the workflow can build a bash array via jq, avoiding + shell word-splitting / glob hazards. + """ + if integration_run_all: + files = _all_integration_test_files() + else: + files = sorted(integration_test_files) + + # Empty list (e.g. run_all expansion with no files on disk) would otherwise + # cause the workflow to invoke pytest with no path argument and collect + # tests outside tests/integration/. Suppress the run instead. + if not files: + return False, [] + + if len(files) > INTEGRATION_TESTS_SPLIT_THRESHOLD: + parts = [ + part for part in _split_list(files, INTEGRATION_TESTS_SPLIT_BUCKETS) if part + ] + buckets = [ + {"name": f"{i + 1}/{len(parts)}", "tests": part} + for i, part in enumerate(parts) + ] + else: + buckets = [{"name": "1/1", "tests": files}] + return True, buckets + class Platform(StrEnum): """Platform identifiers for memory impact analysis.""" @@ -812,7 +867,9 @@ def main() -> None: integration_run_all, integration_test_files = determine_integration_tests( args.branch ) - run_integration = integration_run_all or bool(integration_test_files) + run_integration, integration_test_buckets = _compute_integration_test_buckets( + integration_run_all, integration_test_files + ) run_clang_tidy = should_run_clang_tidy(args.branch) run_clang_format = should_run_clang_format(args.branch) run_python_linters = should_run_python_linters(args.branch) @@ -944,8 +1001,7 @@ def main() -> None: output: dict[str, Any] = { "integration_tests": run_integration, - "integration_tests_run_all": integration_run_all, - "integration_test_files": integration_test_files, + "integration_test_buckets": integration_test_buckets, "clang_tidy": run_clang_tidy, "clang_tidy_mode": clang_tidy_mode, "clang_format": run_clang_format, diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 44c110b689..e85f1757b0 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -122,10 +122,19 @@ def test_main_all_tests_should_run( "esphome/helpers.py", ] + # Stable, deterministic stand-in for the tests/integration/ glob so the + # bucket assertions don't drift with the real test count. + fake_test_files = [f"tests/integration/test_{i:03d}.py" for i in range(15)] + # Run main function with mocked argv with ( patch("sys.argv", ["determine-jobs.py"]), patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), + patch.object( + determine_jobs, + "_all_integration_test_files", + return_value=fake_test_files, + ), patch.object( determine_jobs, "get_changed_components", @@ -161,8 +170,24 @@ def test_main_all_tests_should_run( output = json.loads(captured.out) assert output["integration_tests"] is True - assert output["integration_tests_run_all"] is True - assert output["integration_test_files"] == [] + # run_all=True expands to the full glob and pre-buckets into 3 parts. + # Each bucket's `tests` is a JSON list of file paths. + assert isinstance(output["integration_test_buckets"], list) + assert len(output["integration_test_buckets"]) == 3 + assert [b["name"] for b in output["integration_test_buckets"]] == [ + "1/3", + "2/3", + "3/3", + ] + for bucket in output["integration_test_buckets"]: + assert isinstance(bucket["tests"], list) + for path in bucket["tests"]: + assert isinstance(path, str) + bucket_files = [f for b in output["integration_test_buckets"] for f in b["tests"]] + assert bucket_files == fake_test_files + # Bucket sizes are balanced (max-min difference at most 1). + sizes = [len(b["tests"]) for b in output["integration_test_buckets"]] + assert max(sizes) - min(sizes) <= 1 assert output["clang_tidy"] is True assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is True @@ -247,8 +272,7 @@ def test_main_no_tests_should_run( output = json.loads(captured.out) assert output["integration_tests"] is False - assert output["integration_tests_run_all"] is False - assert output["integration_test_files"] == [] + assert output["integration_test_buckets"] == [] assert output["clang_tidy"] is False assert output["clang_tidy_mode"] == "disabled" assert output["clang_format"] is False @@ -332,8 +356,7 @@ def test_main_with_branch_argument( output = json.loads(captured.out) assert output["integration_tests"] is False - assert output["integration_tests_run_all"] is False - assert output["integration_test_files"] == [] + assert output["integration_test_buckets"] == [] assert output["clang_tidy"] is True assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is False @@ -357,6 +380,59 @@ def test_main_with_branch_argument( assert output["cpp_unit_tests_components"] == ["mqtt"] +def test_compute_integration_test_buckets_empty() -> None: + """No integration tests scheduled => (False, []).""" + run, buckets = determine_jobs._compute_integration_test_buckets(False, []) + assert run is False + assert buckets == [] + + +def test_compute_integration_test_buckets_below_threshold() -> None: + """A small explicit list (<= threshold) => single 1/1 bucket with that list.""" + files = [f"tests/integration/test_{name}.py" for name in ("c", "a", "b")] + run, buckets = determine_jobs._compute_integration_test_buckets(False, files) + assert run is True + assert buckets == [{"name": "1/1", "tests": sorted(files)}] + + +def test_compute_integration_test_buckets_at_threshold_stays_single() -> None: + """Exactly INTEGRATION_TESTS_SPLIT_THRESHOLD files => still one bucket + (the split kicks in only when count is strictly greater than threshold).""" + files = [ + f"tests/integration/test_{i:02d}.py" + for i in range(determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD) + ] + run, buckets = determine_jobs._compute_integration_test_buckets(False, files) + assert run is True + assert len(buckets) == 1 + assert buckets[0]["name"] == "1/1" + assert buckets[0]["tests"] == sorted(files) + + +def test_compute_integration_test_buckets_just_over_threshold_splits() -> None: + """One file over the threshold triggers the 3-bucket fan-out, balanced.""" + n = determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD + 1 + files = [f"tests/integration/test_{i:02d}.py" for i in range(n)] + run, buckets = determine_jobs._compute_integration_test_buckets(False, files) + assert run is True + assert [b["name"] for b in buckets] == ["1/3", "2/3", "3/3"] + union = [path for b in buckets for path in b["tests"]] + assert union == sorted(files) + sizes = [len(b["tests"]) for b in buckets] + assert max(sizes) - min(sizes) <= 1 + + +def test_compute_integration_test_buckets_run_all_with_empty_glob_disables_run() -> ( + None +): + """run_all=True but glob returns no files => run suppressed (otherwise + pytest would collect tests outside tests/integration/).""" + with patch.object(determine_jobs, "_all_integration_test_files", return_value=[]): + run, buckets = determine_jobs._compute_integration_test_buckets(True, []) + assert run is False + assert buckets == [] + + def test_determine_integration_tests( monkeypatch: pytest.MonkeyPatch, ) -> None: From e085cb50d98a7a784050d5e2948754247d07fb9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:11:30 -0500 Subject: [PATCH 05/19] [sensor] Drop Component from filter classes, use self-keyed scheduler (#16132) --- esphome/components/sensor/__init__.py | 19 ++++++------------- esphome/components/sensor/filter.cpp | 21 +++++++-------------- esphome/components/sensor/filter.h | 15 +++++---------- 3 files changed, 18 insertions(+), 37 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 48b7d25d4d..c18aa32f37 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -266,7 +266,7 @@ StreamingMovingAverageFilter = sensor_ns.class_("StreamingMovingAverageFilter", ExponentialMovingAverageFilter = sensor_ns.class_( "ExponentialMovingAverageFilter", Filter ) -ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter, cg.Component) +ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter) LambdaFilter = sensor_ns.class_("LambdaFilter", Filter) StatelessLambdaFilter = sensor_ns.class_("StatelessLambdaFilter", Filter) OffsetFilter = sensor_ns.class_("OffsetFilter", Filter) @@ -283,8 +283,8 @@ ThrottleWithPriorityNanFilter = sensor_ns.class_( TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) -DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component) -HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component) +DebounceFilter = sensor_ns.class_("DebounceFilter", Filter) +HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter) DeltaFilter = sensor_ns.class_("DeltaFilter", Filter) OrFilter = sensor_ns.class_("OrFilter", Filter) CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter) @@ -567,9 +567,7 @@ async def exponential_moving_average_filter_to_code(config, filter_id): "throttle_average", ThrottleAverageFilter, cv.positive_time_period_milliseconds ) async def throttle_average_filter_to_code(config, filter_id): - var = cg.new_Pvariable(filter_id, config) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, config) @FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda) @@ -698,13 +696,10 @@ HEARTBEAT_SCHEMA = cv.Schema( async def heartbeat_filter_to_code(config, filter_id): if isinstance(config, dict): var = cg.new_Pvariable(filter_id, config[CONF_PERIOD]) - await cg.register_component(var, {}) cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) return var - var = cg.new_Pvariable(filter_id, config) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, config) TIMEOUT_SCHEMA = cv.maybe_simple_value( @@ -738,9 +733,7 @@ async def timeout_filter_to_code(config, filter_id): "debounce", DebounceFilter, cv.positive_time_period_milliseconds ) async def debounce_filter_to_code(config, filter_id): - var = cg.new_Pvariable(filter_id, config) - await cg.register_component(var, {}) - return var + return cg.new_Pvariable(filter_id, config) CONF_DATAPOINTS = "datapoints" diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 4896757d3f..5f7f19769a 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -13,11 +13,6 @@ namespace esphome::sensor { static const char *const TAG = "sensor.filter"; -// Filter scheduler IDs. -// Each filter is its own Component instance, so the scheduler scopes -// IDs by component pointer — no risk of collisions between instances. -constexpr uint32_t FILTER_ID = 0; - // Filter void Filter::input(float value) { ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value); @@ -185,8 +180,9 @@ optional ThrottleAverageFilter::new_value(float value) { } return {}; } -void ThrottleAverageFilter::setup() { - this->set_interval(FILTER_ID, this->time_period_, [this]() { +void ThrottleAverageFilter::initialize(Sensor *parent, Filter *next) { + Filter::initialize(parent, next); + App.scheduler.set_interval(this, this->time_period_, [this]() { ESP_LOGVV(TAG, "ThrottleAverageFilter(%p)::interval(sum=%f, n=%i)", this, this->sum_, this->n_); if (this->n_ == 0) { if (this->have_nan_) @@ -199,7 +195,6 @@ void ThrottleAverageFilter::setup() { this->have_nan_ = false; }); } -float ThrottleAverageFilter::get_setup_priority() const { return setup_priority::HARDWARE; } // LambdaFilter LambdaFilter::LambdaFilter(lambda_filter_t lambda_filter) : lambda_filter_(std::move(lambda_filter)) {} @@ -362,13 +357,12 @@ optional TimeoutFilterConfigured::new_value(float value) { // DebounceFilter optional DebounceFilter::new_value(float value) { - this->set_timeout(FILTER_ID, this->time_period_, [this, value]() { this->output(value); }); + App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); }); return {}; } DebounceFilter::DebounceFilter(uint32_t time_period) : time_period_(time_period) {} -float DebounceFilter::get_setup_priority() const { return setup_priority::HARDWARE; } // HeartbeatFilter HeartbeatFilter::HeartbeatFilter(uint32_t time_period) : time_period_(time_period), last_input_(NAN) {} @@ -384,8 +378,9 @@ optional HeartbeatFilter::new_value(float value) { return {}; } -void HeartbeatFilter::setup() { - this->set_interval(FILTER_ID, this->time_period_, [this]() { +void HeartbeatFilter::initialize(Sensor *parent, Filter *next) { + Filter::initialize(parent, next); + App.scheduler.set_interval(this, this->time_period_, [this]() { ESP_LOGVV(TAG, "HeartbeatFilter(%p)::interval(has_value=%s, last_input=%f)", this, YESNO(this->has_value_), this->last_input_); if (!this->has_value_) @@ -395,8 +390,6 @@ void HeartbeatFilter::setup() { }); } -float HeartbeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { for (size_t i = 0; i < count; i++) { if (!std::isfinite(functions[i][2]) || value < functions[i][2]) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 917a1ce7d5..d61df11d9b 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -254,16 +254,14 @@ class ExponentialMovingAverageFilter : public Filter { * * It takes the average of all the values received in a period of time. */ -class ThrottleAverageFilter : public Filter, public Component { +class ThrottleAverageFilter : public Filter { public: explicit ThrottleAverageFilter(uint32_t time_period); - void setup() override; + void initialize(Sensor *parent, Filter *next) override; optional new_value(float value) override; - float get_setup_priority() const override; - protected: float sum_{0.0f}; unsigned int n_{0}; @@ -454,25 +452,22 @@ class TimeoutFilterConfigured : public TimeoutFilterBase { // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead }; -class DebounceFilter : public Filter, public Component { +class DebounceFilter : public Filter { public: explicit DebounceFilter(uint32_t time_period); optional new_value(float value) override; - float get_setup_priority() const override; - protected: uint32_t time_period_; }; -class HeartbeatFilter : public Filter, public Component { +class HeartbeatFilter : public Filter { public: explicit HeartbeatFilter(uint32_t time_period); - void setup() override; + void initialize(Sensor *parent, Filter *next) override; optional new_value(float value) override; - float get_setup_priority() const override; void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } From 2f3e16b4823870cbf701c9273529d185b37a34eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:12:06 -0500 Subject: [PATCH 06/19] [bk72xx] Apply CFG_SUPPORT_BLE=0 SDK option to BK7238 (#16181) --- .../components/beken_spi_led_strip/light.py | 1 + esphome/components/libretiny/__init__.py | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py index 31572cd800..9093b08b62 100644 --- a/esphome/components/beken_spi_led_strip/light.py +++ b/esphome/components/beken_spi_led_strip/light.py @@ -62,6 +62,7 @@ CONF_IS_WRGB = "is_wrgb" SUPPORTED_PINS = { libretiny.const.FAMILY_BK7231N: [16], libretiny.const.FAMILY_BK7231T: [16], + libretiny.const.FAMILY_BK7238: [16], libretiny.const.FAMILY_BK7251: [16], } diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 40b8c8dc6c..74ac51d200 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -37,6 +37,7 @@ from .const import ( CONF_UART_PORT, FAMILIES, FAMILY_BK7231N, + FAMILY_BK7238, FAMILY_COMPONENT, FAMILY_FRIENDLY, FAMILY_RTL8710B, @@ -56,19 +57,22 @@ CODEOWNERS = ["@kuba2k2"] AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True -# BK7231N SDK options to disable unused features. +# BLE 5.x BK SDK options to disable unused features. # Disabling BLE saves ~21KB RAM and ~200KB Flash because BLE init code is # called unconditionally by the SDK. ESPHome doesn't use BLE on LibreTiny. # -# This only works on BK7231N (BLE 5.x). Other BK72XX chips using BLE 4.2 -# (BK7231T, BK7231Q, BK7251; BK7252 boards use the BK7251 family) have a bug -# where the BLE library still links and references undefined symbols when -# CFG_SUPPORT_BLE=0. +# This only works on BLE 5.x BK chips (BK7231N, BK7238). Other BK72XX chips +# using BLE 4.2 (BK7231T, BK7231Q, BK7251; BK7252 boards use the BK7251 family) +# have a bug where the BLE library still links and references undefined symbols +# when CFG_SUPPORT_BLE=0. +# +# On BK7238 the SDK also hangs at WiFi STA enable when BLE init runs, so +# disabling it is required for reliable boot, not just an optimization. # # Other options like CFG_TX_EVM_TEST, CFG_RX_SENSITIVITY_TEST, CFG_SUPPORT_BKREG, # CFG_SUPPORT_OTA_HTTP, and CFG_USE_SPI_SLAVE were evaluated but provide no # NOLINT # measurable benefit - the linker already strips unreferenced code via -gc-sections. -_BK7231N_SYS_CONFIG_OPTIONS = [ +_BLE5_BK_SYS_CONFIG_OPTIONS = [ "CFG_SUPPORT_BLE=0", ] @@ -549,9 +553,9 @@ async def component_to_code(config): cg.add_platformio_option("custom_fw_version", __version__) # Apply chip-specific SDK options to save RAM/Flash - if config[CONF_FAMILY] == FAMILY_BK7231N: + if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238): cg.add_platformio_option( - "custom_options.sys_config#h", _BK7231N_SYS_CONFIG_OPTIONS + "custom_options.sys_config#h", _BLE5_BK_SYS_CONFIG_OPTIONS ) # Tune lwIP for ESPHome's actual needs. From 3b3e003aa3312635ce22454703d386d49563fdca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:13:10 -0500 Subject: [PATCH 07/19] [sensor] Pack ThrottleAverageFilter have_nan_ into n_ bitfield (-4 B/instance) (#16169) --- esphome/components/sensor/__init__.py | 7 ++++++- esphome/components/sensor/filter.h | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index c18aa32f37..ed02cc2543 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -564,7 +564,12 @@ async def exponential_moving_average_filter_to_code(config, filter_id): @FILTER_REGISTRY.register( - "throttle_average", ThrottleAverageFilter, cv.positive_time_period_milliseconds + "throttle_average", + ThrottleAverageFilter, + cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(hours=24)), + ), ) async def throttle_average_filter_to_code(config, filter_id): return cg.new_Pvariable(filter_id, config) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index d61df11d9b..57a2386a7f 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -264,9 +264,12 @@ class ThrottleAverageFilter : public Filter { protected: float sum_{0.0f}; - unsigned int n_{0}; uint32_t time_period_; - bool have_nan_{false}; + // Sample count packed with NaN-seen flag in a single 32-bit word. + // n_ is bounded by YAML cap on time_period_ (24 h) × max plausible source + // rate (1 kHz) = 86.4M ≪ 2^31, so 31 bits has 25x headroom. + uint32_t n_ : 31 {0}; + uint32_t have_nan_ : 1 {0}; }; using lambda_filter_t = std::function(float)>; From 45e78e4114a9dba7628fca434163c8e4795670b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:13:54 -0500 Subject: [PATCH 08/19] [core] Inline loop gate expression to avoid stale local reuse (#16167) --- esphome/core/application.h | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 4a18714d0d..5baf570e62 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -637,10 +637,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // flag preserves it. wake_request_take() exchange-clears the flag; wakes // that arrive during Phase B re-set it and run Phase B again on the next // iteration. - const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency(); - const uint32_t elapsed = now - this->last_loop_; - const bool woke = esphome::wake_request_take(); - const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_); + // + // wake_request_take() must always be called first since it does an + // atomic exchange to clear the flag, and we want to run the component phase + // if either the flag was set or the scheduler requested a high-frequency loop. + const bool do_component_phase = esphome::wake_request_take() || HighFrequencyLoopRequester::is_high_frequency() || + (now - this->last_loop_ >= this->loop_interval_); if (do_component_phase) { ComponentPhaseGuard phase_guard{*this}; From 148d478decdbc07c69442162799d4a28a5e41c8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:14:20 -0500 Subject: [PATCH 09/19] [api] Add encode/decode benchmarks for Z-Wave, IR/RF, and serial proxy messages (#16157) --- tests/benchmarks/components/api/__init__.py | 14 +- .../components/api/bench_proto_proxy.cpp | 280 ++++++++++++++++++ .../esphome/components/infrared/infrared.h | 45 +++ .../radio_frequency/radio_frequency.h | 51 ++++ .../components/serial_proxy/serial_proxy.h | 46 +++ .../components/zwave_proxy/zwave_proxy.h | 29 ++ 6 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 tests/benchmarks/components/api/bench_proto_proxy.cpp create mode 100644 tests/benchmarks/stubs/esphome/components/infrared/infrared.h create mode 100644 tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h create mode 100644 tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h create mode 100644 tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index eb86492964..0d02e0b054 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -11,11 +11,19 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code(config): await original_to_code(config) - # Enable BLE proto message types for benchmarks. The real - # bluetooth_proxy component is ESP32-only; a lightweight stub - # header in tests/benchmarks/stubs/ satisfies the include. + # Enable proxy proto message types for benchmarks. The real + # components have hardware dependencies (BLE/UART/RMT); lightweight + # stub headers in tests/benchmarks/stubs/ satisfy the includes. cg.add_define("USE_BLUETOOTH_PROXY") cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + cg.add_define("USE_ZWAVE_PROXY") + cg.add_define("USE_INFRARED") + cg.add_define("USE_IR_RF") + cg.add_define("USE_RADIO_FREQUENCY") + cg.add_define("USE_SERIAL_PROXY") + cg.add_define("SERIAL_PROXY_COUNT", 0) + cg.add_define("ESPHOME_ENTITY_INFRARED_COUNT", 0) + cg.add_define("ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT", 0) manifest.to_code = to_code diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp new file mode 100644 index 0000000000..fa3191a969 --- /dev/null +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -0,0 +1,280 @@ +// Encode/decode microbenchmarks for proxy message families that carry +// high-volume traffic (Z-Wave, IR/RF, serial). Mirrors the existing +// BluetoothLERawAdvertisementsResponse benchmarks in bench_proto_encode.cpp. + +#include + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Encodes `src` into `out`. Caller owns `out` and must keep it alive across +// the decode loop (decoded messages may store pointers back into its bytes). +template static void encode_into(APIBuffer &out, const T &src) { + out.resize(src.calculate_size()); + ProtoWriteBuffer writer(&out, 0); + src.encode(writer); +} + +// --- ZWaveProxyFrame (Z-Wave frame, ~16 bytes payload) --- + +#ifdef USE_ZWAVE_PROXY + +static const uint8_t kZWaveFrameData[] = {0x01, 0x09, 0x00, 0x13, 0x01, 0x02, 0x00, 0x00, + 0x25, 0x00, 0x05, 0xC4, 0x00, 0x00, 0x00, 0x00}; + +static void Encode_ZWaveProxyFrame(benchmark::State &state) { + ZWaveProxyFrame msg; + msg.data = kZWaveFrameData; + msg.data_len = sizeof(kZWaveFrameData); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_ZWaveProxyFrame); + +static void Decode_ZWaveProxyFrame(benchmark::State &state) { + ZWaveProxyFrame source; + source.data = kZWaveFrameData; + source.data_len = sizeof(kZWaveFrameData); + APIBuffer encoded; + encode_into(encoded, source); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ZWaveProxyFrame msg; + msg.decode(data, size); + benchmark::DoNotOptimize(msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_ZWaveProxyFrame); + +static const uint8_t kZWaveRequestData[] = {0xDE, 0xAD, 0xBE, 0xEF}; + +static void Decode_ZWaveProxyRequest(benchmark::State &state) { + ZWaveProxyRequest source; + source.type = enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE; + source.data = kZWaveRequestData; + source.data_len = sizeof(kZWaveRequestData); + APIBuffer encoded; + encode_into(encoded, source); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ZWaveProxyRequest msg; + msg.decode(data, size); + benchmark::DoNotOptimize(msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_ZWaveProxyRequest); + +#endif // USE_ZWAVE_PROXY + +// --- SerialProxyDataReceived encode + SerialProxyWriteRequest decode --- +// +// SerialProxyWriteRequest is decode-only (SOURCE_CLIENT) but has the same +// wire layout as SerialProxyDataReceived, so we encode via the latter and +// decode as the former. + +#ifdef USE_SERIAL_PROXY + +static constexpr size_t kSerialPayloadSize = 64; +static const uint8_t kSerialPayload[kSerialPayloadSize] = { + 0x55, 0xAA, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, + 0xCD, 0xEF, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, + 0xFF, 0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0, + 0xF0, 0x0F, 0x1F, 0x2F, 0x3F, 0x4F, 0x5F, 0x6F, 0x7F, 0x8F, 0x9F, 0xAF, 0xBF, 0xCF, 0xDF, 0xEF}; + +static void Encode_SerialProxyDataReceived(benchmark::State &state) { + SerialProxyDataReceived msg; + msg.instance = 0; + msg.set_data(kSerialPayload, kSerialPayloadSize); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_SerialProxyDataReceived); + +static void Decode_SerialProxyWriteRequest(benchmark::State &state) { + SerialProxyDataReceived source; + source.instance = 0; + source.set_data(kSerialPayload, kSerialPayloadSize); + APIBuffer encoded; + encode_into(encoded, source); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + SerialProxyWriteRequest msg; + msg.decode(data, size); + benchmark::DoNotOptimize(msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_SerialProxyWriteRequest); + +#endif // USE_SERIAL_PROXY + +// --- InfraredRFReceiveEvent encode (100 sint32 timings) + +// InfraredRFTransmitRawTimingsRequest decode (hand-built wire bytes) --- + +#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) + +// Mark/space pairs simulating a typical RC-5 / NEC capture (100 timings). +static std::vector make_ir_timings_100() { + std::vector v; + v.reserve(100); + for (int i = 0; i < 100; i++) { + v.push_back((i % 2 == 0) ? 560 : -560); + } + return v; +} + +static const std::vector &get_ir_timings_100() { + static const std::vector timings = make_ir_timings_100(); + return timings; +} + +static void Encode_InfraredRFReceiveEvent(benchmark::State &state) { + InfraredRFReceiveEvent msg; + msg.key = 0xDEADBEEF; + msg.timings = &get_ir_timings_100(); + APIBuffer buffer; + buffer.resize(msg.calculate_size()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_InfraredRFReceiveEvent); + +static void CalculateSize_InfraredRFReceiveEvent(benchmark::State &state) { + InfraredRFReceiveEvent msg; + msg.key = 0xDEADBEEF; + msg.timings = &get_ir_timings_100(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_InfraredRFReceiveEvent); + +// Hand-built wire bytes for InfraredRFTransmitRawTimingsRequest (decode-only, +// no sister message with identical wire layout). +// field 2 (key, fixed32): tag=0x15, 4 LE bytes +// field 3 (carrier_frequency): tag=0x18, varint +// field 4 (repeat_count): tag=0x20, varint +// field 5 (timings, packed sint32): tag=0x2A, length varint, packed payload +// field 6 (modulation): tag=0x30, varint +static APIBuffer build_infrared_rf_transmit_wire() { + uint8_t bytes[256]; + size_t len = 0; + + auto put_byte = [&](uint8_t b) { bytes[len++] = b; }; + auto put_varint = [&](uint32_t v) { + while (v >= 0x80) { + bytes[len++] = static_cast((v & 0x7F) | 0x80); + v >>= 7; + } + bytes[len++] = static_cast(v); + }; + auto encode_zigzag = [](int32_t v) -> uint32_t { + return (static_cast(v) << 1) ^ static_cast(v >> 31); + }; + + put_byte(0x15); + put_byte(0xEF); + put_byte(0xBE); + put_byte(0xAD); + put_byte(0xDE); + put_byte(0x18); + put_varint(38000); + put_byte(0x20); + put_varint(2); + + uint8_t packed[200]; + size_t packed_len = 0; + for (int i = 0; i < 100; i++) { + int32_t value = (i % 2 == 0) ? 560 : -560; + uint32_t zz = encode_zigzag(value); + while (zz >= 0x80) { + packed[packed_len++] = static_cast((zz & 0x7F) | 0x80); + zz >>= 7; + } + packed[packed_len++] = static_cast(zz); + } + put_byte(0x2A); + put_varint(static_cast(packed_len)); + std::memcpy(bytes + len, packed, packed_len); + len += packed_len; + // field 6: modulation = 1 (non-zero so it's actually emitted and exercises + // decode_varint for this field, matching the documented layout above). + put_byte(0x30); + put_varint(1); + + APIBuffer buf; + buf.resize(len); + std::memcpy(buf.data(), bytes, len); + return buf; +} + +static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state) { + auto encoded = build_infrared_rf_transmit_wire(); + const uint8_t *data = encoded.data(); + size_t size = encoded.size(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + InfraredRFTransmitRawTimingsRequest msg; + msg.decode(data, size); + benchmark::DoNotOptimize(msg); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Decode_InfraredRFTransmitRawTimingsRequest); + +#endif // USE_IR_RF || USE_RADIO_FREQUENCY + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/stubs/esphome/components/infrared/infrared.h b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h new file mode 100644 index 0000000000..874e7a270b --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/infrared/infrared.h @@ -0,0 +1,45 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp and Application need when USE_INFRARED is defined, +// without pulling in the real remote_base/RMT dependencies. +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" + +namespace esphome::infrared { + +class Infrared; + +class InfraredCall { + public: + explicit InfraredCall(Infrared *parent) : parent_(parent) {} + InfraredCall &set_carrier_frequency(uint32_t /*frequency*/) { return *this; } + InfraredCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) { + return *this; + } + InfraredCall &set_repeat_count(uint32_t /*count*/) { return *this; } + void perform() {} + + protected: + Infrared *parent_; +}; + +class InfraredTraits { + public: + uint32_t get_receiver_frequency_hz() const { return 0; } +}; + +class Infrared : public Component, public EntityBase { + public: + Infrared() = default; + InfraredTraits &get_traits() { return this->traits_; } + const InfraredTraits &get_traits() const { return this->traits_; } + InfraredCall make_call() { return InfraredCall(this); } + uint32_t get_capability_flags() const { return 0; } + + protected: + InfraredTraits traits_; +}; + +} // namespace esphome::infrared diff --git a/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h b/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h new file mode 100644 index 0000000000..72fc08034b --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/radio_frequency/radio_frequency.h @@ -0,0 +1,51 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp and Application need when USE_RADIO_FREQUENCY is defined. +#pragma once + +#include +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" + +namespace esphome::radio_frequency { + +enum RadioFrequencyModulation : uint32_t { + RADIO_FREQUENCY_MODULATION_OOK = 0, +}; + +class RadioFrequency; + +class RadioFrequencyCall { + public: + explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {} + RadioFrequencyCall &set_frequency(uint32_t /*frequency*/) { return *this; } + RadioFrequencyCall &set_modulation(RadioFrequencyModulation /*mod*/) { return *this; } + RadioFrequencyCall &set_repeat_count(uint32_t /*count*/) { return *this; } + RadioFrequencyCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) { + return *this; + } + void perform() {} + + protected: + RadioFrequency *parent_; +}; + +class RadioFrequencyTraits { + public: + uint32_t get_frequency_min_hz() const { return 0; } + uint32_t get_frequency_max_hz() const { return 0; } + uint32_t get_supported_modulations() const { return 0; } +}; + +class RadioFrequency : public Component, public EntityBase { + public: + RadioFrequency() = default; + RadioFrequencyTraits &get_traits() { return this->traits_; } + const RadioFrequencyTraits &get_traits() const { return this->traits_; } + RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } + uint32_t get_capability_flags() const { return 0; } + + protected: + RadioFrequencyTraits traits_; +}; + +} // namespace esphome::radio_frequency diff --git a/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h new file mode 100644 index 0000000000..bab27549e7 --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/serial_proxy/serial_proxy.h @@ -0,0 +1,46 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp and Application need when USE_SERIAL_PROXY is defined, +// without pulling in the real UART implementation. +#pragma once + +#include +#include +#include "esphome/components/api/api_pb2.h" + +namespace esphome { + +namespace api { +class APIConnection; +} // namespace api + +namespace uart { +enum class UARTFlushResult : uint8_t { + UART_FLUSH_RESULT_SUCCESS, + UART_FLUSH_RESULT_ASSUMED_SUCCESS, + UART_FLUSH_RESULT_TIMEOUT, + UART_FLUSH_RESULT_FAILED, +}; +} // namespace uart + +namespace serial_proxy { + +class SerialProxy { + public: + void set_instance_index(uint32_t index) { this->instance_index_ = index; } + uint32_t get_instance_index() const { return this->instance_index_; } + const char *get_name() const { return ""; } + api::enums::SerialProxyPortType get_port_type() const { return {}; } + api::APIConnection *get_api_connection() { return nullptr; } + void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {} + void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint32_t stop_bits, uint32_t data_size) {} + void write_from_client(const uint8_t *data, size_t len) {} + void set_modem_pins(uint32_t line_states) {} + uint32_t get_modem_pins() const { return 0; } + uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + + protected: + uint32_t instance_index_{0}; +}; + +} // namespace serial_proxy +} // namespace esphome diff --git a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h new file mode 100644 index 0000000000..ba97e81236 --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h @@ -0,0 +1,29 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp needs when USE_ZWAVE_PROXY is defined, +// without pulling in the real UART-based ZWaveProxy implementation. +#pragma once + +#include "esphome/components/api/api_pb2.h" + +namespace esphome { +namespace api { +class APIConnection; +} // namespace api + +namespace zwave_proxy { + +class ZWaveProxy { + public: + api::APIConnection *get_api_connection() { return nullptr; } + void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {} + void send_frame(const uint8_t *data, size_t length) {} + void api_connection_authenticated(api::APIConnection *conn) {} + uint32_t get_feature_flags() const { return 0; } + uint32_t get_home_id() { return 0; } +}; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern ZWaveProxy *global_zwave_proxy; + +} // namespace zwave_proxy +} // namespace esphome From b708d1a8260b55acd9c0c7c66af3fbadc6b4c564 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:14:34 -0500 Subject: [PATCH 10/19] [core] Drop unused DELAY_ACTION from InternalSchedulerID enum (#16151) --- esphome/core/component.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 185d51ab37..5baf795ca6 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -65,7 +65,6 @@ inline constexpr uint32_t SCHEDULER_DONT_RUN = 4294967295UL; /// with component-level NUMERIC_ID values, even if the uint32_t values overlap. enum class InternalSchedulerID : uint32_t { POLLING_UPDATE = 0, // PollingComponent interval - DELAY_ACTION = 1, // DelayAction timeout }; // Forward declaration From ba7c06785a03f1f7c570b572e5aa677d0ed9d073 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:14:55 -0500 Subject: [PATCH 11/19] [mdns] Broadcast config_hash TXT record on _esphomelib._tcp (#16145) --- esphome/components/mdns/mdns_component.cpp | 40 ++++++++++++++++++++-- esphome/components/mdns/mdns_component.h | 33 +++++------------- esphome/components/mdns/mdns_host.cpp | 7 +++- 3 files changed, 52 insertions(+), 28 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index e05373ac5d..9bf27e71e4 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -39,7 +39,39 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); -void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf) { +void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { +#ifdef USE_MDNS_STORE_SERVICES + auto &services = this->services_; +#else + StaticVector services_storage; + auto &services = services_storage; +#endif + +#ifdef USE_API +#ifdef USE_MDNS_STORE_SERVICES + get_mac_address_into_buffer(this->mac_address_); + char *mac_ptr = this->mac_address_; + format_hex_to(this->config_hash_str_, App.get_config_hash()); + char *cfg_ptr = this->config_hash_str_; +#else + char mac_address[MAC_ADDRESS_BUFFER_SIZE]; + char config_hash_str[CONFIG_HASH_STR_SIZE]; + get_mac_address_into_buffer(mac_address); + format_hex_to(config_hash_str, App.get_config_hash()); + char *mac_ptr = mac_address; + char *cfg_ptr = config_hash_str; +#endif +#else + char *mac_ptr = nullptr; + char *cfg_ptr = nullptr; +#endif + + this->compile_records_(services, mac_ptr, cfg_ptr); + platform_register(this, services); +} + +void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf, + char *config_hash_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. @@ -47,6 +79,7 @@ void MDNSComponent::compile_records_(StaticVector &); - void setup_buffers_and_register_(PlatformRegisterFn platform_register) { -#ifdef USE_MDNS_STORE_SERVICES - auto &services = this->services_; -#else - StaticVector services_storage; - auto &services = services_storage; -#endif - -#ifdef USE_API -#ifdef USE_MDNS_STORE_SERVICES - get_mac_address_into_buffer(this->mac_address_); - char *mac_ptr = this->mac_address_; -#else - char mac_address[MAC_ADDRESS_BUFFER_SIZE]; - get_mac_address_into_buffer(mac_address); - char *mac_ptr = mac_address; -#endif -#else - char *mac_ptr = nullptr; -#endif - - this->compile_records_(services, mac_ptr); - platform_register(this, services); - } + void setup_buffers_and_register_(PlatformRegisterFn platform_register); #ifdef USE_MDNS_DYNAMIC_TXT /// Storage for runtime-generated TXT values from user lambdas @@ -159,6 +139,8 @@ class MDNSComponent final : public Component #if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) /// Fixed buffer for MAC address (only needed when services are stored) char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; + /// Fixed buffer for config hash hex string (only needed when services are stored) + char config_hash_str_[CONFIG_HASH_STR_SIZE]; #endif #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; @@ -167,7 +149,8 @@ class MDNSComponent final : public Component // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif - void compile_records_(StaticVector &services, char *mac_address_buf); + void compile_records_(StaticVector &services, char *mac_address_buf, + char *config_hash_buf); }; } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 4d902319b8..1e66a10df0 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -3,6 +3,8 @@ #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "mdns_component.h" @@ -13,10 +15,13 @@ void MDNSComponent::setup() { #ifdef USE_API get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; + format_hex_to(this->config_hash_str_, App.get_config_hash()); + char *cfg_ptr = this->config_hash_str_; #else char *mac_ptr = nullptr; + char *cfg_ptr = nullptr; #endif - this->compile_records_(this->services_, mac_ptr); + this->compile_records_(this->services_, mac_ptr, cfg_ptr); #endif // Host platform doesn't have actual mDNS implementation } From 550444dc34da939cd3c1da77042d7d07c88fe766 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:15:18 -0500 Subject: [PATCH 12/19] [binary_sensor] Drop Component from filter classes, use self-keyed scheduler (#16131) --- esphome/components/binary_sensor/__init__.py | 15 +++------ esphome/components/binary_sensor/filter.cpp | 34 +++++++------------- esphome/components/binary_sensor/filter.h | 18 +++-------- 3 files changed, 22 insertions(+), 45 deletions(-) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1456e5bc66..db82290750 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -143,15 +143,15 @@ BinarySensorCondition = binary_sensor_ns.class_("BinarySensorCondition", Conditi # Filters Filter = binary_sensor_ns.class_("Filter") -TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter, cg.Component) -DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter, cg.Component) -DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter, cg.Component) -DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter, cg.Component) +TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter) +DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter) +DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter) +DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter) InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter) AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter, cg.Component) LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter) StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter) -SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter, cg.Component) +SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter) _LOGGER = getLogger(__name__) @@ -175,7 +175,6 @@ async def invert_filter_to_code(config, filter_id): ) async def timeout_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_timeout_value(template_)) return var @@ -203,7 +202,6 @@ async def timeout_filter_to_code(config, filter_id): ) async def delayed_on_off_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) if isinstance(config, dict): template_ = await cg.templatable(config[CONF_TIME_ON], [], cg.uint32) cg.add(var.set_on_delay(template_)) @@ -221,7 +219,6 @@ async def delayed_on_off_filter_to_code(config, filter_id): ) async def delayed_on_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_delay(template_)) return var @@ -234,7 +231,6 @@ async def delayed_on_filter_to_code(config, filter_id): ) async def delayed_off_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_delay(template_)) return var @@ -306,7 +302,6 @@ async def lambda_filter_to_code(config, filter_id): ) async def settle_filter_to_code(config, filter_id): var = cg.new_Pvariable(filter_id) - await cg.register_component(var, {}) template_ = await cg.templatable(config, [], cg.uint32) cg.add(var.set_delay(template_)) return var diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 914060ce13..0a463ee9a9 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -4,16 +4,14 @@ #include "filter.h" #include "binary_sensor.h" +#include "esphome/core/application.h" namespace esphome::binary_sensor { static const char *const TAG = "sensor.filter"; -// Timeout IDs for filter classes. -// Each filter is its own Component instance, so the scheduler scopes -// IDs by component pointer — no risk of collisions between instances. -constexpr uint32_t FILTER_TIMEOUT_ID = 0; -// AutorepeatFilter needs two distinct IDs (both timeouts on the same component) +// AutorepeatFilter still inherits Component (it schedules two distinct timer +// purposes), so it keeps the (Component *, id) scheduler API. constexpr uint32_t AUTOREPEAT_TIMING_ID = 0; constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1; @@ -34,46 +32,40 @@ void Filter::input(bool value) { } void TimeoutFilter::input(bool value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); }); + App.scheduler.set_timeout(this, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); }); // we do not de-dup here otherwise changes from invalid to valid state will not be output this->output(value); } optional DelayedOnOffFilter::new_value(bool value) { if (value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->on_delay_.value(), [this]() { this->output(true); }); + App.scheduler.set_timeout(this, this->on_delay_.value(), [this]() { this->output(true); }); } else { - this->set_timeout(FILTER_TIMEOUT_ID, this->off_delay_.value(), [this]() { this->output(false); }); + App.scheduler.set_timeout(this, this->off_delay_.value(), [this]() { this->output(false); }); } return {}; } -float DelayedOnOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional DelayedOnFilter::new_value(bool value) { if (value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(true); }); + App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(true); }); return {}; } else { - this->cancel_timeout(FILTER_TIMEOUT_ID); + App.scheduler.cancel_timeout(this); return false; } } -float DelayedOnFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional DelayedOffFilter::new_value(bool value) { if (!value) { - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(false); }); + App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(false); }); return {}; } else { - this->cancel_timeout(FILTER_TIMEOUT_ID); + App.scheduler.cancel_timeout(this); return true; } } -float DelayedOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - optional InvertFilter::new_value(bool value) { return !value; } // AutorepeatFilterBase @@ -118,20 +110,18 @@ optional LambdaFilter::new_value(bool value) { return this->f_(value); } optional SettleFilter::new_value(bool value) { if (!this->steady_) { - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this, value]() { + App.scheduler.set_timeout(this, this->delay_.value(), [this, value]() { this->steady_ = true; this->output(value); }); return {}; } else { this->steady_ = false; - this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; }); + App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->steady_ = true; }); return value; } } -float SettleFilter::get_setup_priority() const { return setup_priority::HARDWARE; } - } // namespace esphome::binary_sensor #endif // USE_BINARY_SENSOR_FILTER diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 2e45554f81..8ff57cab0c 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -29,7 +29,7 @@ class Filter { Deduplicator dedup_; }; -class TimeoutFilter : public Filter, public Component { +class TimeoutFilter : public Filter { public: optional new_value(bool value) override { return value; } void input(bool value) override; @@ -39,12 +39,10 @@ class TimeoutFilter : public Filter, public Component { TemplatableFn timeout_delay_{}; }; -class DelayedOnOffFilter final : public Filter, public Component { +class DelayedOnOffFilter final : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_on_delay(T delay) { this->on_delay_ = delay; } template void set_off_delay(T delay) { this->off_delay_ = delay; } @@ -53,24 +51,20 @@ class DelayedOnOffFilter final : public Filter, public Component { TemplatableFn off_delay_{}; }; -class DelayedOnFilter : public Filter, public Component { +class DelayedOnFilter : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_delay(T delay) { this->delay_ = delay; } protected: TemplatableFn delay_{}; }; -class DelayedOffFilter : public Filter, public Component { +class DelayedOffFilter : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_delay(T delay) { this->delay_ = delay; } protected: @@ -146,12 +140,10 @@ class StatelessLambdaFilter : public Filter { optional (*f_)(bool); }; -class SettleFilter : public Filter, public Component { +class SettleFilter : public Filter { public: optional new_value(bool value) override; - float get_setup_priority() const override; - template void set_delay(T delay) { this->delay_ = delay; } protected: From 24fdfcf1a18be08c720a674384cd15712168d7d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:15:41 -0500 Subject: [PATCH 13/19] [rp2040] Move HAL bodies into components/rp2040/hal.cpp + inline trivial dispatches (#16114) --- esphome/components/rp2040/core.cpp | 39 ++-------------------------- esphome/components/rp2040/hal.cpp | 41 ++++++++++++++++++++++++++++++ esphome/core/hal/hal_rp2040.h | 19 +++++++++++--- 3 files changed, 59 insertions(+), 40 deletions(-) create mode 100644 esphome/components/rp2040/hal.cpp diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index d3dc1cf2bb..11f23ccfef 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -1,41 +1,6 @@ #ifdef USE_RP2040 -#include "core.h" -#include "esphome/core/defines.h" -#ifdef USE_RP2040_CRASH_HANDLER -#include "crash_handler.h" -#endif -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" - -#include "hardware/timer.h" -#include "hardware/watchdog.h" - -namespace esphome { - -// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h. -void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } -void arch_restart() { - watchdog_reboot(0, 0, 10); - while (1) { - continue; - } -} - -void arch_init() { -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_read_and_clear(); -#endif -#if USE_RP2040_WATCHDOG_TIMEOUT > 0 - watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); -#endif -} - -void HOT arch_feed_wdt() { watchdog_update(); } - -uint32_t HOT arch_get_cpu_cycle_count() { return ulMainGetRunTimeCounterValue(); } -uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } - -} // namespace esphome +// HAL functions live in hal.cpp. core.cpp is intentionally empty for +// rp2040 — there is no extra component bootstrap to keep here. #endif // USE_RP2040 diff --git a/esphome/components/rp2040/hal.cpp b/esphome/components/rp2040/hal.cpp new file mode 100644 index 0000000000..7475205d60 --- /dev/null +++ b/esphome/components/rp2040/hal.cpp @@ -0,0 +1,41 @@ +#ifdef USE_RP2040 + +#include "core.h" +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#ifdef USE_RP2040_CRASH_HANDLER +#include "crash_handler.h" +#endif + +#include "hardware/watchdog.h" + +// Empty rp2040 namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// rp2040 component's API. +namespace esphome::rp2040 {} // namespace esphome::rp2040 + +namespace esphome { + +// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), +// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in core/hal/hal_rp2040.h. +void arch_restart() { + watchdog_reboot(0, 0, 10); + while (1) { + continue; + } +} + +void arch_init() { +#ifdef USE_RP2040_CRASH_HANDLER + rp2040::crash_handler_read_and_clear(); +#endif +#if USE_RP2040_WATCHDOG_TIMEOUT > 0 + watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); +#endif +} + +uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/hal/hal_rp2040.h b/esphome/core/hal/hal_rp2040.h index 46f6e421cd..27a9b23c0b 100644 --- a/esphome/core/hal/hal_rp2040.h +++ b/esphome/core/hal/hal_rp2040.h @@ -20,8 +20,17 @@ extern "C" unsigned long millis(void); // Forward decl from . extern "C" uint64_t time_us_64(void); +// Forward decls from pico-sdk / FreeRTOS port for the inline arch_* +// wrappers below. +extern "C" void watchdog_update(void); +extern "C" unsigned long ulMainGetRunTimeCounterValue(void); + namespace esphome { +// Forward decl from helpers.h. +// NOLINTNEXTLINE(readability-redundant-declaration) +void delay_microseconds_safe(uint32_t us); + /// Returns true when executing inside an interrupt handler. __attribute__((always_inline)) inline bool in_isr_context() { uint32_t ipsr; @@ -35,9 +44,13 @@ __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::time_us_64()); } -void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) -void arch_feed_wdt(); -uint32_t arch_get_cpu_cycle_count(); +// 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() { watchdog_update(); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { + return static_cast(ulMainGetRunTimeCounterValue()); +} + void arch_init(); uint32_t arch_get_cpu_freq_hz(); From 3d69169141984487a28d5b1e323a3740748a1408 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:16:16 -0500 Subject: [PATCH 14/19] [climate] Fold ControlAction fields into a single stateless lambda (#16044) --- esphome/components/climate/__init__.py | 87 +++++++++++------- esphome/components/climate/automation.h | 35 ++----- .../fixtures/climate_control_action.yaml | 92 +++++++++++++++++++ .../test_climate_control_action.py | 84 +++++++++++++++++ 4 files changed, 238 insertions(+), 60 deletions(-) create mode 100644 tests/integration/fixtures/climate_control_action.yaml create mode 100644 tests/integration/test_climate_control_action.py diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 0fdb18a92c..7c9002d6dc 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -48,13 +48,13 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import LambdaExpression, MockObjClass IS_PLATFORM_COMPONENT = True @@ -487,38 +487,57 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( ) async def climate_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - if (mode := config.get(CONF_MODE)) is not None: - template_ = await cg.templatable(mode, args, ClimateMode) - cg.add(var.set_mode(template_)) - if (target_temp := config.get(CONF_TARGET_TEMPERATURE)) is not None: - template_ = await cg.templatable(target_temp, args, cg.float_) - cg.add(var.set_target_temperature(template_)) - if (target_temp_low := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None: - template_ = await cg.templatable(target_temp_low, args, cg.float_) - cg.add(var.set_target_temperature_low(template_)) - if (target_temp_high := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None: - template_ = await cg.templatable(target_temp_high, args, cg.float_) - cg.add(var.set_target_temperature_high(template_)) - if (target_humidity := config.get(CONF_TARGET_HUMIDITY)) is not None: - template_ = await cg.templatable(target_humidity, args, cg.float_) - cg.add(var.set_target_humidity(template_)) - if (fan_mode := config.get(CONF_FAN_MODE)) is not None: - template_ = await cg.templatable(fan_mode, args, ClimateFanMode) - cg.add(var.set_fan_mode(template_)) - if (custom_fan_mode := config.get(CONF_CUSTOM_FAN_MODE)) is not None: - template_ = await cg.templatable(custom_fan_mode, args, cg.std_string) - cg.add(var.set_custom_fan_mode(template_)) - if (preset := config.get(CONF_PRESET)) is not None: - template_ = await cg.templatable(preset, args, ClimatePreset) - cg.add(var.set_preset(template_)) - if (custom_preset := config.get(CONF_CUSTOM_PRESET)) is not None: - template_ = await cg.templatable(custom_preset, args, cg.std_string) - cg.add(var.set_custom_preset(template_)) - if (swing_mode := config.get(CONF_SWING_MODE)) is not None: - template_ = await cg.templatable(swing_mode, args, ClimateSwingMode) - cg.add(var.set_swing_mode(template_)) - return var + + # All configured fields are folded into a single stateless lambda whose + # constants live in flash; the action stores only a function pointer. + # For custom_fan_mode/custom_preset the static-string path emits the + # (const char *, size_t) overload of set_fan_mode/set_preset to avoid + # constructing a std::string and calling runtime strlen. + FIELDS = ( + (CONF_MODE, "set_mode", ClimateMode), + (CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_), + (CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_), + (CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_), + (CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_), + (CONF_FAN_MODE, "set_fan_mode", ClimateFanMode), + (CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string), + (CONF_PRESET, "set_preset", ClimatePreset), + (CONF_CUSTOM_PRESET, "set_preset", cg.std_string), + (CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode), + ) + + fwd_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + + for conf_key, setter, type_ in FIELDS: + if (value := config.get(conf_key)) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda(value, args, return_type=type_) + body_lines.append(f"call.{setter}(({inner})({fwd_args}));") + elif type_ is cg.std_string: + # Static custom strings: emit a flash literal and pass the + # UTF-8 byte length to skip the runtime strlen inside + # set_fan_mode/set_preset. + literal = cg.safe_exp(value) + body_lines.append( + f"call.{setter}({literal}, {len(value.encode('utf-8'))});" + ) + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + # Match ControlAction::ApplyFn signature: const Ts &... for trigger args. + apply_args = [ + (ClimateCall.operator("ref"), "call"), + *((t.operator("const").operator("ref"), n) for t, n in args), + ] + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + apply_args, + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index fac56d9d9e..71d23fd6b6 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -5,42 +5,25 @@ namespace esphome::climate { +// All configured fields are baked into a single stateless lambda whose +// constants live in flash. The action only stores one function pointer +// plus one parent pointer, regardless of how many fields the user set. +// Trigger args are forwarded to the apply function so user lambdas +// (e.g. `target_temperature: !lambda "return x;"`) keep working. template class ControlAction : public Action { public: - explicit ControlAction(Climate *climate) : climate_(climate) {} - - TEMPLATABLE_VALUE(ClimateMode, mode) - TEMPLATABLE_VALUE(float, target_temperature) - TEMPLATABLE_VALUE(float, target_temperature_low) - TEMPLATABLE_VALUE(float, target_temperature_high) - TEMPLATABLE_VALUE(float, target_humidity) - TEMPLATABLE_VALUE(bool, away) - TEMPLATABLE_VALUE(ClimateFanMode, fan_mode) - TEMPLATABLE_VALUE(std::string, custom_fan_mode) - TEMPLATABLE_VALUE(ClimatePreset, preset) - TEMPLATABLE_VALUE(std::string, custom_preset) - TEMPLATABLE_VALUE(ClimateSwingMode, swing_mode) + using ApplyFn = void (*)(ClimateCall &, const Ts &...); + ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} void play(const Ts &...x) override { auto call = this->climate_->make_call(); - call.set_mode(this->mode_.optional_value(x...)); - call.set_target_temperature(this->target_temperature_.optional_value(x...)); - call.set_target_temperature_low(this->target_temperature_low_.optional_value(x...)); - call.set_target_temperature_high(this->target_temperature_high_.optional_value(x...)); - call.set_target_humidity(this->target_humidity_.optional_value(x...)); - if (away_.has_value()) { - call.set_preset(away_.value(x...) ? CLIMATE_PRESET_AWAY : CLIMATE_PRESET_HOME); - } - call.set_fan_mode(this->fan_mode_.optional_value(x...)); - call.set_fan_mode(this->custom_fan_mode_.optional_value(x...)); - call.set_preset(this->preset_.optional_value(x...)); - call.set_preset(this->custom_preset_.optional_value(x...)); - call.set_swing_mode(this->swing_mode_.optional_value(x...)); + this->apply_(call, x...); call.perform(); } protected: Climate *climate_; + ApplyFn apply_; }; class ControlTrigger : public Trigger { diff --git a/tests/integration/fixtures/climate_control_action.yaml b/tests/integration/fixtures/climate_control_action.yaml new file mode 100644 index 0000000000..1dd300fcc2 --- /dev/null +++ b/tests/integration/fixtures/climate_control_action.yaml @@ -0,0 +1,92 @@ +esphome: + name: climate-control-action-test +host: +api: +logger: + level: DEBUG + +globals: + - id: test_target_temp + type: float + initial_value: "21.5" + +sensor: + - platform: template + id: temp_sensor + name: "Temp" + lambda: 'return 20.0;' + update_interval: 60s + +climate: + - platform: thermostat + id: test_climate + name: "Test Climate" + sensor: temp_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + min_cooling_run_time: 300s + heat_action: + - logger.log: heating + idle_action: + - logger.log: idle + cool_action: + - logger.log: cooling + heat_cool_mode: + - logger.log: heat_cool + preset: + - name: Default + default_target_temperature_low: 18 °C + default_target_temperature_high: 22 °C + visual: + min_temperature: 10 °C + max_temperature: 30 °C + +button: + # mode only + - platform: template + id: btn_mode + name: "Set Mode Heat" + on_press: + - climate.control: + id: test_climate + mode: HEAT + + # mode + target_temperature_low + target_temperature_high + - platform: template + id: btn_mode_temps + name: "Set Mode Temps" + on_press: + - climate.control: + id: test_climate + mode: HEAT_COOL + target_temperature_low: 19.0 °C + target_temperature_high: 23.0 °C + + # target_temperature_low only + - platform: template + id: btn_low_only + name: "Set Low Only" + on_press: + - climate.control: + id: test_climate + target_temperature_low: 17.5 °C + + # Lambda path: target_temperature_high computed at runtime + - platform: template + id: btn_lambda_high + name: "Lambda High" + on_press: + - climate.control: + id: test_climate + target_temperature_high: !lambda "return id(test_target_temp);" + + # mode only — turn off via mode + - platform: template + id: btn_off + name: "Set Off" + on_press: + - climate.control: + id: test_climate + mode: "OFF" diff --git a/tests/integration/test_climate_control_action.py b/tests/integration/test_climate_control_action.py new file mode 100644 index 0000000000..2b0293b209 --- /dev/null +++ b/tests/integration/test_climate_control_action.py @@ -0,0 +1,84 @@ +"""Integration test for climate ControlAction. + +Tests that climate.control automation actions work correctly with the +single stateless apply lambda/function pointer implementation. Exercises +multiple field combinations and the lambda path. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ( + ButtonInfo, + ClimateInfo, + ClimateMode, + ClimateState, + EntityState, +) +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_climate_control_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test climate ControlAction with constants and lambdas.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + climate_state_future: asyncio.Future[ClimateState] | None = None + + def on_state(state: EntityState) -> None: + if ( + isinstance(state, ClimateState) + and climate_state_future is not None + and not climate_state_future.done() + ): + climate_state_future.set_result(state) + + async def wait_for_climate_state(timeout: float = 5.0) -> ClimateState: + nonlocal climate_state_future + climate_state_future = loop.create_future() + try: + return await asyncio.wait_for(climate_state_future, timeout) + finally: + climate_state_future = None + + entities, _ = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + require_entity(entities, "test_climate", ClimateInfo) + + async def press_and_wait(name: str) -> ClimateState: + btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo) + client.button_command(btn.key) + return await wait_for_climate_state() + + # mode only — set HEAT + state = await press_and_wait("Set Mode Heat") + assert state.mode == ClimateMode.HEAT + + # mode + target_temperature_low + target_temperature_high + state = await press_and_wait("Set Mode Temps") + assert state.mode == ClimateMode.HEAT_COOL + assert state.target_temperature_low == pytest.approx(19.0, abs=0.5) + assert state.target_temperature_high == pytest.approx(23.0, abs=0.5) + + # target_temperature_low only + state = await press_and_wait("Set Low Only") + assert state.target_temperature_low == pytest.approx(17.5, abs=0.5) + + # lambda path: target_temperature_high computed at runtime + state = await press_and_wait("Lambda High") + assert state.target_temperature_high == pytest.approx(21.5, abs=0.5) + + # mode only — turn off via mode + state = await press_and_wait("Set Off") + assert state.mode == ClimateMode.OFF From 92aa98f680c8ea40ff424dfd6a529fd3f6313d7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 19:42:38 -0500 Subject: [PATCH 15/19] [host] Move HAL bodies into components/host/hal.cpp + inline trivial dispatches (#16115) --- esphome/components/host/core.cpp | 60 +---------------------------- esphome/components/host/hal.cpp | 65 ++++++++++++++++++++++++++++++++ esphome/core/hal/hal_host.h | 12 +++--- 3 files changed, 73 insertions(+), 64 deletions(-) create mode 100644 esphome/components/host/hal.cpp diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index b067ebbf6e..9123975884 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -1,74 +1,16 @@ #ifdef USE_HOST #include "esphome/core/application.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #include "preferences.h" #include -#include -#include -#include namespace { volatile sig_atomic_t s_signal_received = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void signal_handler(int signal) { s_signal_received = signal; } } // namespace -namespace esphome { - -void HOT yield() { ::sched_yield(); } -uint32_t IRAM_ATTR HOT millis() { - struct timespec spec; - clock_gettime(CLOCK_MONOTONIC, &spec); - return static_cast(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000); -} -uint64_t millis_64() { - struct timespec spec; - clock_gettime(CLOCK_MONOTONIC, &spec); - return static_cast(spec.tv_sec) * 1000ULL + static_cast(spec.tv_nsec) / 1000000ULL; -} -void HOT delay(uint32_t ms) { - struct timespec ts; - ts.tv_sec = ms / 1000; - ts.tv_nsec = (ms % 1000) * 1000000; - int res; - do { - res = nanosleep(&ts, &ts); - } while (res != 0 && errno == EINTR); -} -uint32_t IRAM_ATTR HOT micros() { - struct timespec spec; - clock_gettime(CLOCK_MONOTONIC, &spec); - return static_cast(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000); -} -void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { - struct timespec ts; - ts.tv_sec = us / 1000000U; - ts.tv_nsec = (us % 1000000U) * 1000U; - int res; - do { - res = nanosleep(&ts, &ts); - } while (res != 0 && errno == EINTR); -} -void arch_restart() { exit(0); } -void arch_init() { - // pass -} -void HOT arch_feed_wdt() { - // pass -} - -uint32_t arch_get_cpu_cycle_count() { - struct timespec spec; - clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t us = spec.tv_nsec; - return ((uint32_t) seconds) * 1000000000U + us; -} -uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } - -} // namespace esphome +// HAL functions live in hal.cpp. void setup(); void loop(); diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp new file mode 100644 index 0000000000..256a12ac62 --- /dev/null +++ b/esphome/components/host/hal.cpp @@ -0,0 +1,65 @@ +#ifdef USE_HOST + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +#include +#include +#include + +// Empty host namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// host component's API. +namespace esphome::host {} // namespace esphome::host + +namespace esphome { + +// yield(), arch_init(), arch_feed_wdt(), arch_get_cpu_freq_hz() inlined in +// core/hal/hal_host.h. + +uint32_t IRAM_ATTR HOT millis() { + struct timespec spec; + clock_gettime(CLOCK_MONOTONIC, &spec); + return static_cast(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000); +} +uint64_t millis_64() { + struct timespec spec; + clock_gettime(CLOCK_MONOTONIC, &spec); + return static_cast(spec.tv_sec) * 1000ULL + static_cast(spec.tv_nsec) / 1000000ULL; +} +void HOT delay(uint32_t ms) { + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (ms % 1000) * 1000000; + int res; + do { + res = nanosleep(&ts, &ts); + } while (res != 0 && errno == EINTR); +} +uint32_t IRAM_ATTR HOT micros() { + struct timespec spec; + clock_gettime(CLOCK_MONOTONIC, &spec); + return static_cast(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000); +} +void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { + struct timespec ts; + ts.tv_sec = us / 1000000U; + ts.tv_nsec = (us % 1000000U) * 1000U; + int res; + do { + res = nanosleep(&ts, &ts); + } while (res != 0 && errno == EINTR); +} +void arch_restart() { exit(0); } + +uint32_t arch_get_cpu_cycle_count() { + struct timespec spec; + clock_gettime(CLOCK_MONOTONIC, &spec); + time_t seconds = spec.tv_sec; + uint32_t ns = static_cast(spec.tv_nsec); + return static_cast(seconds) * 1000000000U + ns; +} + +} // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/hal/hal_host.h b/esphome/core/hal/hal_host.h index a8896fdf63..d7f317176e 100644 --- a/esphome/core/hal/hal_host.h +++ b/esphome/core/hal/hal_host.h @@ -3,6 +3,7 @@ #ifdef USE_HOST #include +#include #define IRAM_ATTR #define PROGMEM @@ -13,17 +14,18 @@ namespace esphome { /// Host has no ISR concept. __attribute__((always_inline)) inline bool in_isr_context() { return false; } -void yield(); +__attribute__((always_inline)) inline void yield() { ::sched_yield(); } + void delay(uint32_t ms); uint32_t micros(); uint32_t millis(); 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(); + +__attribute__((always_inline)) inline void arch_init() {} +__attribute__((always_inline)) inline void arch_feed_wdt() {} +__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } } // namespace esphome From 9999913d072b930bdf330ba421e403126d7b65a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 20:10:51 -0500 Subject: [PATCH 16/19] [zephyr] Move HAL bodies into components/zephyr/hal.cpp + inline trivial dispatches (#16116) --- esphome/components/zephyr/core.cpp | 52 +----------------------- esphome/components/zephyr/hal.cpp | 63 ++++++++++++++++++++++++++++++ esphome/core/hal/hal_zephyr.h | 20 ++++++---- 3 files changed, 76 insertions(+), 59 deletions(-) create mode 100644 esphome/components/zephyr/hal.cpp diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index 93a9a1ae8e..d1bdaee02d 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -1,8 +1,6 @@ #ifdef USE_ZEPHYR #include -#include -#include #include #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -10,55 +8,7 @@ namespace esphome { -#ifdef CONFIG_WATCHDOG -static int wdt_channel_id = -1; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); -#endif - -void yield() { ::k_yield(); } -uint32_t millis() { return static_cast(millis_64()); } -uint64_t millis_64() { return static_cast(k_uptime_get()); } -uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); } -void delayMicroseconds(uint32_t us) { ::k_usleep(us); } -void delay(uint32_t ms) { ::k_msleep(ms); } - -void arch_init() { -#ifdef CONFIG_WATCHDOG - if (device_is_ready(WDT)) { - static wdt_timeout_cfg wdt_config{}; - wdt_config.flags = WDT_FLAG_RESET_SOC; -#ifdef USE_ZIGBEE - // zboss thread use a lot of cpu cycles during start - wdt_config.window.max = 10000; -#else - wdt_config.window.max = 2000; -#endif - wdt_channel_id = wdt_install_timeout(WDT, &wdt_config); - if (wdt_channel_id >= 0) { - uint8_t options = 0; -#ifdef USE_DEBUG - options |= WDT_OPT_PAUSE_HALTED_BY_DBG; -#endif -#ifdef USE_DEEP_SLEEP - options |= WDT_OPT_PAUSE_IN_SLEEP; -#endif - wdt_setup(WDT, options); - } - } -#endif -} - -void arch_feed_wdt() { -#ifdef CONFIG_WATCHDOG - if (wdt_channel_id >= 0) { - wdt_feed(WDT, wdt_channel_id); - } -#endif -} - -void arch_restart() { sys_reboot(SYS_REBOOT_COLD); } -uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); } -uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); } +// HAL functions live in hal.cpp. Mutex::Mutex() { auto *mutex = new k_mutex(); diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp new file mode 100644 index 0000000000..5c08ed2519 --- /dev/null +++ b/esphome/components/zephyr/hal.cpp @@ -0,0 +1,63 @@ +#ifdef USE_ZEPHYR + +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" + +#include +#include + +// Empty zephyr namespace block to satisfy ci-custom's lint_namespace check. +// HAL functions live in namespace esphome (root) — they are not part of the +// zephyr component's API. +namespace esphome::zephyr {} // namespace esphome::zephyr + +namespace esphome { + +#ifdef CONFIG_WATCHDOG +static int wdt_channel_id = -1; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); +#endif + +// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), +// arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() inlined in +// core/hal/hal_zephyr.h. + +void arch_init() { +#ifdef CONFIG_WATCHDOG + if (device_is_ready(WDT)) { + static wdt_timeout_cfg wdt_config{}; + wdt_config.flags = WDT_FLAG_RESET_SOC; +#ifdef USE_ZIGBEE + // zboss thread uses a lot of CPU cycles during startup + wdt_config.window.max = 10000; +#else + wdt_config.window.max = 2000; +#endif + wdt_channel_id = wdt_install_timeout(WDT, &wdt_config); + if (wdt_channel_id >= 0) { + uint8_t options = 0; +#ifdef USE_DEBUG + options |= WDT_OPT_PAUSE_HALTED_BY_DBG; +#endif +#ifdef USE_DEEP_SLEEP + options |= WDT_OPT_PAUSE_IN_SLEEP; +#endif + wdt_setup(WDT, options); + } + } +#endif +} + +void arch_feed_wdt() { +#ifdef CONFIG_WATCHDOG + if (wdt_channel_id >= 0) { + wdt_feed(WDT, wdt_channel_id); + } +#endif +} + +void arch_restart() { sys_reboot(SYS_REBOOT_COLD); } + +} // namespace esphome + +#endif // USE_ZEPHYR diff --git a/esphome/core/hal/hal_zephyr.h b/esphome/core/hal/hal_zephyr.h index d4b37b5eb6..613b3911c1 100644 --- a/esphome/core/hal/hal_zephyr.h +++ b/esphome/core/hal/hal_zephyr.h @@ -4,6 +4,8 @@ #include +#include + #define IRAM_ATTR #define PROGMEM @@ -13,17 +15,19 @@ namespace esphome { /// Zephyr/nRF52: not currently consulted — wake path is platform-specific. __attribute__((always_inline)) inline bool in_isr_context() { return false; } -void yield(); -void delay(uint32_t ms); -uint32_t micros(); -uint32_t millis(); -uint64_t millis_64(); +__attribute__((always_inline)) inline void yield() { ::k_yield(); } +__attribute__((always_inline)) inline void delay(uint32_t ms) { ::k_msleep(ms); } +__attribute__((always_inline)) inline uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); } +__attribute__((always_inline)) inline uint64_t millis_64() { return static_cast(k_uptime_get()); } +__attribute__((always_inline)) inline uint32_t millis() { return static_cast(millis_64()); } + +// NOLINTNEXTLINE(readability-identifier-naming) +__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { ::k_usleep(us); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); } +__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); } -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 From faa61696e036cb26e44ef0e4a28400e0636c5ca3 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 30 Apr 2026 21:43:24 -0400 Subject: [PATCH 17/19] [sendspin] Use sendspin-cpp to v0.4.0 to reduce stuttering (#16178) --- esphome/components/sendspin/__init__.py | 30 ++++++++++++++++--- .../sendspin/media_source/__init__.py | 4 +++ esphome/idf_component.yml | 2 +- .../sendspin/common-media_source.yaml | 1 + 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 58687ae838..1348bf2bfc 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -24,6 +24,7 @@ CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" +CONF_DECODE_MEMORY = "decode_memory" # sendspin-cpp library lives in the global `sendspin` namespace. sendspin_library_ns = cg.global_ns.namespace("sendspin") @@ -39,6 +40,18 @@ CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject") PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig") +# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement +# preference for the player role's transfer buffers. +SendspinMemoryLocation = sendspin_library_ns.enum("MemoryLocation", is_class=True) + +MEMORY_PSRAM = "psram" +MEMORY_INTERNAL = "internal" +MEMORY_LOCATIONS = [MEMORY_PSRAM, MEMORY_INTERNAL] +MEMORY_LOCATION_ENUM = { + MEMORY_PSRAM: SendspinMemoryLocation.PREFER_EXTERNAL, + MEMORY_INTERNAL: SendspinMemoryLocation.PREFER_INTERNAL, +} + # Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace. # Analysis tools strip the trailing underscore (same pattern as `template_`). sendspin_ns = cg.esphome_ns.namespace("sendspin_") @@ -193,7 +206,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.1") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.4.0") cg.add_define("USE_SENDSPIN", True) # for MDNS @@ -249,14 +262,23 @@ async def to_code(config: ConfigType) -> None: "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True ) - player_config_struct = cg.StructInitializer( - PlayerRoleConfig, + # Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not + # starved by the HTTP server during the initial encoded-audio burst at stream start), + # interpolation/decode buffer locations PREFER_EXTERNAL. + player_struct_fields = [ ("audio_formats", audio_format_structs), ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), ("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]), ("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), ("psram_stack", psram_stack), - ("priority", 2), + ] + if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None: + player_struct_fields.append( + ("decode_buffer_location", MEMORY_LOCATION_ENUM[decode_memory]) + ) + player_config_struct = cg.StructInitializer( + PlayerRoleConfig, + *player_struct_fields, ) cg.add(var.set_player_config(player_config_struct)) else: diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6d61a8a636..f689ab01cb 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -13,9 +13,11 @@ from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( + CONF_DECODE_MEMORY, CONF_FIXED_DELAY, CONF_INITIAL_STATIC_DELAY, CONF_SENDSPIN_ID, + MEMORY_LOCATIONS, SendspinHub, _validate_task_stack_in_psram, register_player_config, @@ -57,6 +59,7 @@ def _register(config: ConfigType) -> ConfigType: CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], CONF_FIXED_DELAY: config[CONF_FIXED_DELAY], CONF_TASK_STACK_IN_PSRAM: config.get(CONF_TASK_STACK_IN_PSRAM, False), + CONF_DECODE_MEMORY: config.get(CONF_DECODE_MEMORY), } ) return config @@ -82,6 +85,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.int_range( min=16000, max=96000 ), + cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True), } ), cv.only_on_esp32, diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 757fcf9dd7..d02c3adb8f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -92,6 +92,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.3.1 + version: 0.4.0 lvgl/lvgl: version: 9.5.0 diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 4a7cd79c67..5b33a54647 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -7,3 +7,4 @@ media_source: initial_static_delay: 5ms static_delay_adjustable: true fixed_delay: 480us + decode_memory: internal From 08e5cb55764033d11e4feb35c2c6a7fc3fa2da50 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:47:22 -0400 Subject: [PATCH 18/19] [esp32_hosted] Bump esp_hosted to 2.12.6 and esp_wifi_remote to 1.5.1 (#16176) --- esphome/components/esp32_hosted/__init__.py | 7 ++++--- esphome/idf_component.yml | 10 +++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 1619a845d8..eca7c24b10 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -246,9 +246,10 @@ async def to_code(config): idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" if idf_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0") - esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") + esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") + esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index d02c3adb8f..f5a8dd8c60 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -20,15 +20,19 @@ dependencies: espressif/mdns: version: 1.11.0 espressif/esp_wifi_remote: - version: 1.4.0 + version: 1.5.1 + rules: + - if: "target in [esp32h2, esp32p4]" + espressif/wifi_remote_over_eppp: + version: 0.3.2 rules: - if: "target in [esp32h2, esp32p4]" espressif/eppp_link: - version: 1.1.4 + version: 1.1.5 rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.1 + version: 2.12.6 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: From f6e39d305d5410e7524ffb4d9d7fb0b388c861ae Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 1 May 2026 04:08:55 +0200 Subject: [PATCH 19/19] [zigbee] Add newlib compatibility for zigbee sdk in idf 6 (#16174) --- esphome/components/zigbee/zigbee_esp32.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 1b98df6c0a..9081582c7b 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -9,6 +9,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, add_partition, + idf_version, require_vfs_select, ) import esphome.config_validation as cv @@ -186,6 +187,10 @@ async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: # The pre-built Zigbee library uses esp_log_default_level which requires # dynamic log level control to be enabled add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) + # The pre-built Zigbee library is compiled against newlib which requires newlib + # reentrancy to be enabled with picolibc compatibility. + if idf_version() >= cv.Version(6, 0, 0): + add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", True) async def attributes_to_code(