From 32ab3abd7c0cb4cbca7bb43cb8e20cf637395f02 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:55:21 +1200 Subject: [PATCH 01/10] [psram] Make schema extractable with per-variant options (#16949) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 29 +++++++++++ esphome/components/psram/__init__.py | 52 +++++++++++++------ script/build_language_schema.py | 9 ++++ tests/component_tests/psram/test_psram.py | 48 +++++++++++++++++ .../psram/validate-quad.esp32-s3-idf.yaml | 5 ++ .../components/psram/validate.esp32-idf.yaml | 4 ++ .../psram/validate.esp32-p4-idf.yaml | 4 ++ tests/script/test_build_language_schema.py | 22 ++++++++ 8 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 tests/components/psram/validate-quad.esp32-s3-idf.yaml create mode 100644 tests/components/psram/validate.esp32-idf.yaml create mode 100644 tests/components/psram/validate.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d703e22e46..5d4b3b8b47 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable, Iterable import contextlib from dataclasses import dataclass import itertools @@ -6,6 +7,7 @@ import os from pathlib import Path import re import subprocess +from typing import Any from esphome import yaml_util import esphome.codegen as cg @@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache @@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT] +def variant_filtered_enum( + by_variant: dict[str, Iterable[Any]], **kwargs: Any +) -> Callable[[Any], Any]: + """Build a ``one_of`` validator whose valid set depends on the active variant. + + ``by_variant`` maps each ESP32 variant constant to the iterable of values that + are valid on that variant. At validation time the value is checked against the + set allowed for the current target variant. For schema extraction the inverted + ``{value: [variants, ...]}`` map is returned instead, so the language-schema + dump can tag every option with the variants that accept it and frontends can + filter to the user's selected variant. + """ + by_value: dict[str, list[str]] = {} + for variant, values in by_variant.items(): + for value in values: + by_value.setdefault(str(value), []).append(variant) + + @schema_extractor("variant_enum") + def validator(value: Any) -> Any: + if value is SCHEMA_EXTRACT: + return by_value + return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value) + + return validator + + def get_board(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD] diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index d36d900997..296ea6c08c 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, get_esp32_variant, idf_version, + variant_filtered_enum, ) import esphome.config_validation as cv from esphome.const import ( @@ -29,6 +30,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "psram" @@ -70,6 +72,11 @@ SPIRAM_SPEEDS = { VARIANT_ESP32P4: (20, 100, 200), } +SPIRAM_SPEEDS_MHZ = { + variant: tuple(f"{speed}MHZ" for speed in speeds) + for variant, speeds in SPIRAM_SPEEDS.items() +} + def supported() -> bool: if not CORE.is_esp32: @@ -145,15 +152,23 @@ def validate_psram_mode(config): return config -def get_config_schema(config): +def _set_variant_defaults(config: ConfigType) -> ConfigType: + """Resolve variant-dependent defaults before the static schema validates. + + The set of valid ``mode``/``speed`` values is variant-specific (enforced by + ``variant_filtered_enum`` in the schema below); this only supplies the default + when the user omits the option. ``mode`` has no single default on chips that + support more than one mode, so selection is required there. + """ variant = get_esp32_variant() - speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] - if not speeds: + modes = SPIRAM_MODES.get(variant) + speeds = SPIRAM_SPEEDS.get(variant) + if not modes or not speeds: raise cv.Invalid("PSRAM is not supported on this chip") - modes = SPIRAM_MODES[variant] - if CONF_MODE not in config and len(modes) != 1: - raise ( - cv.Invalid( + config = config.copy() + if CONF_MODE not in config: + if len(modes) != 1: + raise cv.Invalid( textwrap.dedent( f""" {variant} requires PSRAM mode selection; one of {", ".join(modes)} @@ -161,20 +176,27 @@ def get_config_schema(config): """ ) ) - ) - return cv.Schema( + config[CONF_MODE] = modes[0] + if CONF_SPEED not in config: + config[CONF_SPEED] = f"{speeds[0]}MHZ" + return config + + +CONFIG_SCHEMA = cv.All( + _set_variant_defaults, + cv.Schema( { cv.GenerateID(): cv.declare_id(PsramComponent), - cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True), + cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True), cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean, - cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True), + cv.Optional(CONF_SPEED): variant_filtered_enum( + SPIRAM_SPEEDS_MHZ, upper=True + ), cv.Optional(CONF_DISABLED, default=False): cv.boolean, cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean, } - )(config) - - -CONFIG_SCHEMA = get_config_schema + ), +) def _store_psram_guaranteed(config): diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 61845c4b25..974957245a 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -951,6 +951,15 @@ def convert(schema, config_var, path): elif schema_type == "enum": config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) + elif schema_type == "variant_enum": + # Per-variant enum (e.g. psram mode/speed): each value carries the + # list of variants that accept it so clients can filter to the + # user's selected variant. Additive to the plain enum format — + # consumers that ignore the metadata still see every option. + config_var[S_TYPE] = "enum" + config_var["values"] = { + value: {"variants": variants} for value, variants in data.items() + } elif schema_type == "maybe": # maybe_simple_value: either a scalar shorthand (mapped to the key in # data[1]) or the full wrapped schema. The wrapped schema is usually a diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 0924e66adc..ea4adc69a9 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants( FINAL_VALIDATE_SCHEMA(config) +def test_psram_applies_single_mode_default( + set_core_config: SetCoreConfigCallable, +) -> None: + """On a single-mode variant the omitted mode/speed fall back to defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + config = CONFIG_SCHEMA({}) + assert config["mode"] == "quad" + assert config["speed"] == "40MHZ" + assert config["disabled"] is False + assert config["ignore_not_found"] is True + + +def test_psram_requires_mode_on_multi_mode_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A variant with multiple modes requires an explicit mode selection.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"): + CONFIG_SCHEMA({}) + + +def test_psram_rejects_mode_invalid_for_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A mode not supported by the active variant is rejected by the schema.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"): + CONFIG_SCHEMA({"mode": "octal"}) + + def _setup_psram_final_validation_test( esp32_config: dict, set_core_config: SetCoreConfigCallable, diff --git a/tests/components/psram/validate-quad.esp32-s3-idf.yaml b/tests/components/psram/validate-quad.esp32-s3-idf.yaml new file mode 100644 index 0000000000..3fa6360d14 --- /dev/null +++ b/tests/components/psram/validate-quad.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses +# octal; this exercises the other branch of the per-variant mode enum (quad) and +# lets speed fall back to its 40MHz default. +psram: + mode: quad diff --git a/tests/components/psram/validate.esp32-idf.yaml b/tests/components/psram/validate.esp32-idf.yaml new file mode 100644 index 0000000000..9c04284163 --- /dev/null +++ b/tests/components/psram/validate.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: with no options the single-mode ESP32 resolves mode -> quad and +# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here, +# so this only runs through `esphome config`. +psram: diff --git a/tests/components/psram/validate.esp32-p4-idf.yaml b/tests/components/psram/validate.esp32-p4-idf.yaml new file mode 100644 index 0000000000..3e5899061f --- /dev/null +++ b/tests/components/psram/validate.esp32-p4-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz). +# With no options it resolves mode -> hex and speed -> 20MHz, exercising the +# P4-specific default branch of the per-variant enums. +psram: diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index badd4686f6..8bbaa2773a 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -139,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None: assert "foo" in config_var["schema"]["config_vars"] +def test_convert_emits_variant_enum() -> None: + """A per-variant enum is dumped with each value tagged by its variants.""" + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32S3, + variant_filtered_enum, + ) + + validator = variant_filtered_enum( + {VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")}, + lower=True, + ) + config_var: dict = {} + _bls.convert(validator, config_var, "/test") + + assert config_var["type"] == "enum" + assert config_var["values"] == { + "quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]}, + "octal": {"variants": [VARIANT_ESP32S3]}, + } + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") From 33ace9d698a8ff8e6bed06a71b741b38bed2ecc7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:25:54 +1000 Subject: [PATCH 02/10] [mipi_dsi] Add SWRESET command to M5Stack Tab5-V2 init sequence (#16975) --- esphome/components/mipi_dsi/models/m5stack.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 2298f76cd4..53fac9b534 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -71,6 +71,7 @@ DriverChip( swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ + (0x01,), (0x60, 0x71, 0x23, 0xa2), (0x60, 0x71, 0x23, 0xa3), (0x60, 0x71, 0x23, 0xa4), From 9bf35ab8fbc69847a4f7b292784946cbc8e2e37b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Jun 2026 15:46:33 -0500 Subject: [PATCH 03/10] [core] Attribute "took a long time" blocking warning to the owning script (#16768) --- .../components/runtime_stats/runtime_stats.h | 2 +- esphome/components/script/script.h | 19 ++- esphome/core/application.h | 95 +++++++++++++- esphome/core/base_automation.h | 9 +- esphome/core/component.cpp | 30 +++-- esphome/core/component.h | 60 +-------- esphome/core/millis_internal.h | 4 +- esphome/core/scheduler.cpp | 33 +++-- esphome/core/scheduler.h | 39 ++++-- .../fixtures/scheduler_blocking_warning.yaml | 22 ++++ ...duler_blocking_warning_generic_source.yaml | 30 +++++ ...eduler_delay_runs_on_failed_component.yaml | 29 +++++ .../test_scheduler_blocking_warning.py | 120 ++++++++++++++++++ 13 files changed, 389 insertions(+), 103 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_blocking_warning.yaml create mode 100644 tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml create mode 100644 tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml create mode 100644 tests/integration/test_scheduler_blocking_warning.py diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 888d48e672..1e4910453a 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -47,7 +47,7 @@ class RuntimeStatsCollector { // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, - // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // LoopBlockingGuard construction/destruction, feed_wdt_with_time calls, // the for-loop itself). void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { this->period_active_count_++; diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 847fab02bd..6cd33e566c 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -3,6 +3,7 @@ #include #include #include +#include "esphome/core/application.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -57,6 +58,14 @@ template class Script : public ScriptLogger, public Triggerexecute(std::get(tuple)...); } + // Run the action chain with this script's name published as the current source (RAII save/restore, + // so nesting composes), so deferred work inside the script is attributed to it in blocking + // warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame). + inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE { + ScopedSourceGuard source_guard{this->name_}; + this->trigger(x...); + } + const LogString *name_{nullptr}; }; @@ -74,7 +83,7 @@ template class SingleScript : public Script { return; } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -91,7 +100,7 @@ template class RestartScript : public Script { this->stop_action(); } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -136,7 +145,7 @@ template class QueueingScript : public Script, public Com return; } - this->trigger(x...); + this->run_actions_(x...); // Check if the trigger was immediate and we can continue right away. this->loop(); } @@ -175,7 +184,7 @@ template class QueueingScript : public Script, public Com } template void trigger_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->trigger(std::get(tuple)...); + this->run_actions_(std::get(tuple)...); } int num_queued_ = 0; // Number of queued instances (not including currently running) @@ -197,7 +206,7 @@ template class ParallelScript : public Script { LOG_STR_ARG(this->name_)); return; } - this->trigger(x...); + this->run_actions_(x...); } void set_max_runs(int max_runs) { max_runs_ = max_runs; } diff --git a/esphome/core/application.h b/esphome/core/application.h index 369c970d46..7c12a66b2c 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -104,9 +104,13 @@ class Application { void register_area(Area *area) { this->areas_.push_back(area); } #endif - void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } + // Owning script of the action chain currently executing (nullptr when none); used to attribute + // blocking warnings for deferred work to the script that scheduled it. + void set_current_source(const LogString *source) { this->current_source_ = source; } + const LogString *get_current_source() { return this->current_source_; } + // Entity register methods (generated from entity_types.h). // Each entity type gets two overloads: // - register_(obj) — bare push_back @@ -393,6 +397,7 @@ class Application { protected: friend Component; friend class Scheduler; + friend class LoopBlockingGuard; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -402,6 +407,14 @@ class Application { /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + // Publish the running unit's identity (component + source) and dispatch time together, so a + // dispatch site can't set one without the others. Friend-only (Scheduler). + void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) { + this->current_component_ = component; + this->current_source_ = source; + this->set_loop_component_start_time_(now); + } + /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// (which is a friend) to decide whether to clear the corresponding bit on @@ -482,6 +495,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; + const LogString *current_source_{nullptr}; // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components @@ -554,6 +568,76 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the +/// previous value on exit, attributing deferred work scheduled inside to that source. +class ScopedSourceGuard { + public: + explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) { + App.set_current_source(source); + } + ~ScopedSourceGuard() { App.set_current_source(this->prev_); } + ScopedSourceGuard(const ScopedSourceGuard &) = delete; + ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete; + + private: + const LogString *prev_; +}; + +// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the +// main loop too long. The constructor publishes the unit's identity + dispatch time to App; +// finish()/the cold warning path read them back, so the guard stores no copy. +// +// Guards must not nest: the constructor publishes to App but never restores on destruction, so a +// nested guard would clobber the outer's context. Safe because the two dispatch sites (component +// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback. +class LoopBlockingGuard { + public: + // Publish the unit's identity + dispatch time, then start timing. The millis start lives in App, + // so only the runtime-stats micros stamp is kept here. + LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) { + App.set_current_execution_context_(component, source, now); +#ifdef USE_RUNTIME_STATS + this->started_us_ = micros(); +#endif + } + + // Finish the timing operation and return the current time (millis) + // Inlined: the fast path is just millis() + subtract + compare + inline uint32_t HOT finish() { +#ifdef USE_RUNTIME_STATS + uint32_t elapsed_us = micros() - this->started_us_; + // Delays have no component; accumulate into the global counter so loop() can subtract them. + Component *component = App.get_current_component(); + if (component != nullptr) { + component->runtime_stats_.record_time(elapsed_us); + } else { + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } +#endif + uint32_t curr_time = MillisInternal::get(); +#ifndef USE_BENCHMARK + // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) + static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; + uint32_t blocking_time = curr_time - App.get_loop_component_start_time(); + if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { + warn_blocking(blocking_time); + } +#endif + return curr_time; + } + + ~LoopBlockingGuard() = default; + +#ifdef USE_RUNTIME_STATS + protected: + uint32_t started_us_; +#endif + + private: + // Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit. + static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // before/tail splits recorded below. uint32_t loop_active_start_us = micros(); // Snapshot the cumulative component-recorded time so we can subtract the - // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // slice that the scheduler spends inside its own LoopBlockingGuard // (scheduler.cpp) — that time is already counted in per-component stats, // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; @@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->current_loop_index_++) { Component *component = this->looping_components_[this->current_loop_index_]; - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + // Guard publishes this component (no script source) + dispatch time, then times loop(). + LoopBlockingGuard guard{component, nullptr, last_op_end_time}; component->loop(); // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index dcad7c9d2e..cf8b05a300 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,10 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // Record the owning script (if any) so the blocking warning can name it; propagates across + // chained delays via the scheduler. + /* source= */ App.get_current_source()); } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay @@ -212,7 +215,9 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // See the no-argument branch above: record the owning script for log attribution. + /* source= */ App.get_current_source()); } } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2d80301897..7ef5ff50a5 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -258,9 +258,11 @@ void Component::call() { break; } } -bool Component::should_warn_of_blocking(uint32_t blocking_time) { +bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) { // Convert centisecond threshold to milliseconds for comparison uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; + // Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate. + threshold_ms_out = threshold_ms; if (blocking_time > threshold_ms) { // Set new threshold: blocking_time + increment, converted back to centiseconds uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; @@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif -void __attribute__((noinline, cold)) -WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { - bool should_warn; +void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) { + // Identity is published on App by the caller before the guard is built; read it back here. + Component *component = App.get_current_component(); + // Component-less path always warns (the caller already checked the constant threshold). + uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS; + if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) { + return; // Component's (possibly ratcheted) threshold not exceeded yet + } + // Component name if any, else the published source (owning script), else a generic label. + const LogString *name; if (component != nullptr) { - should_warn = component->should_warn_of_blocking(blocking_time); + name = component->get_component_log_str(); } else { - should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller - } - if (should_warn) { - ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms", - component == nullptr ? LOG_STR_LITERAL("") : LOG_STR_ARG(component->get_component_log_str()), - blocking_time); + name = App.get_current_source(); + if (name == nullptr) + name = LOG_STR("a scheduled task"); } + ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name), + blocking_time, threshold_ms); } #ifdef USE_SETUP_PRIORITY_OVERRIDE diff --git a/esphome/core/component.h b/esphome/core/component.h index ff10f1a8f1..299a5f72ea 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -118,7 +118,7 @@ struct ComponentRuntimeStats { // Cumulative sum of every record_time() duration since boot, across all // components. Used by Application::loop() to snapshot time spent inside - // WarnIfComponentBlockingGuard (including guards constructed by the + // LoopBlockingGuard (including guards constructed by the // scheduler at scheduler.cpp) so main-loop overhead accounting can // subtract scheduled-callback time from the before_loop_tasks_ wall time. static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -326,7 +326,7 @@ class Component { return component_source_lookup(this->component_source_index_); } - bool should_warn_of_blocking(uint32_t blocking_time); + bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out); protected: friend class Application; @@ -571,7 +571,7 @@ class Component { volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; ComponentRuntimeStats runtime_stats_; #endif }; @@ -619,59 +619,7 @@ class PollingComponent : public Component { uint32_t update_interval_; }; -// millis() and micros() are available via hal.h - -class WarnIfComponentBlockingGuard { - public: - WarnIfComponentBlockingGuard(Component *component, uint32_t start_time) - : started_(start_time), - component_(component) -#ifdef USE_RUNTIME_STATS - , - started_us_(micros()) -#endif - { - } - - // Finish the timing operation and return the current time (millis) - // Inlined: the fast path is just millis() + subtract + compare - inline uint32_t HOT finish() { -#ifdef USE_RUNTIME_STATS - uint32_t elapsed_us = micros() - this->started_us_; - // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) - if (this->component_ != nullptr) { - this->component_->runtime_stats_.record_time(elapsed_us); - } else { - // Still accumulate into the global counter so Application::loop() can subtract - // this time from before_loop_tasks_ wall time. - ComponentRuntimeStats::global_recorded_us += elapsed_us; - } -#endif - uint32_t curr_time = MillisInternal::get(); -#ifndef USE_BENCHMARK - // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) - static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; - uint32_t blocking_time = curr_time - this->started_; - if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { - warn_blocking(this->component_, blocking_time); - } -#endif - return curr_time; - } - - ~WarnIfComponentBlockingGuard() = default; - - protected: - uint32_t started_; - Component *component_; -#ifdef USE_RUNTIME_STATS - uint32_t started_us_; -#endif - - private: - // Cold path for blocking warning - defined in component.cpp - static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time); -}; +// LoopBlockingGuard lives in application.h because it reads its state from App. // Function to clear setup priority overrides after all components are set up // Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index bc1d55a1c4..7297d22357 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -16,7 +16,7 @@ namespace esphome { // Friend-gated accessor for a fast millis() variant intended only for // known task-context callers on the main loop hot path (Application::loop() -// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// and LoopBlockingGuard::finish()). It skips the ISR-context // dispatch that the public esphome::millis() pays on ESP32 and libretiny. // // MUST NOT be called from ISR context: on ESP32 and libretiny it calls the @@ -50,7 +50,7 @@ class MillisInternal { #endif } friend class Application; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; }; } // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a7c624486d..15bb9ea239 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel) { + std::function &&func, bool is_retry, bool skip_cancel, + const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { @@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); - item->component = component; + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } item->set_name(name_type, static_name, hash_or_id); item->type = type; // Use destroy + placement-new instead of move-assignment. @@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Not reached timeout yet, done for this call break; } - // Don't run on failed components - if (item->component != nullptr && item->component->is_failed()) { + // Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays). + if (this->is_item_failed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; @@ -790,10 +796,21 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { - App.set_current_component(item->component); - // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. - App.set_loop_component_start_time_(now); - WarnIfComponentBlockingGuard guard{item->component, now}; + // Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared + // union slot with a single name-type check. Self-keyed items have no owning component; their slot + // holds the source name (e.g. the owning script), published so deferred work chained inside the + // callback re-captures it and the blocking warning can name the script instead of "". + Component *component; + const LogString *source; + if (item->get_name_type() == NameType::SELF_POINTER) { + component = nullptr; + source = item->source_name; + } else { + component = item->component; + source = nullptr; + } + // Guard publishes the item's identity + dispatch time, then times the callback. + LoopBlockingGuard guard{component, source, now}; item->callback(); uint32_t end = guard.finish(); // Feed the watchdog after each scheduled item (both main heap and defer diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b640aa86fe..378c0fb94b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -183,11 +183,12 @@ class Scheduler { protected: struct SchedulerItem { - // Ordered by size to minimize padding. - // `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive). + // Ordered by size to minimize padding. Mutually exclusive by state; read the component via + // get_component() so SELF_POINTER items read as component-less. union { - Component *component; - SchedulerItem *next_free; + Component *component; // live, non-SELF_POINTER: owning component + const LogString *source_name; // live SELF_POINTER: owning script name (log attribution) + SchedulerItem *next_free; // while pooled }; // Optimized name storage using tagged union - zero heap allocation union { @@ -302,14 +303,23 @@ class Scheduler { next_execution_high_ = static_cast(value >> 32); } constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } - const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); } + // The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead). + // All component access goes through this so SELF_POINTER items read as component-less. + Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; } + const LogString *get_source() const { + // Same no-source label as warn_blocking, for consistent log vocabulary. + if (name_type_ == NameType::SELF_POINTER) + return source_name != nullptr ? source_name : LOG_STR("a scheduled task"); + return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown"); + } }; // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id + // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false); + bool skip_cancel = false, const LogString *source = nullptr); // Common implementation for retry - Remove before 2026.8.0 // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id @@ -402,8 +412,10 @@ class Scheduler { // Fixes: https://github.com/esphome/esphome/issues/11940 if (item == nullptr) return false; - if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) || - (match_retry && !item->is_retry)) { + // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they + // match by the `this` key alone. + if (item->get_component() != component || item->type != type || + (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -423,11 +435,16 @@ class Scheduler { // Helper to execute a scheduler item uint32_t execute_item_(SchedulerItem *item, uint32_t now); - // Helper to check if item should be skipped - bool should_skip_item_(SchedulerItem *item) const { - return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); + // True if the item's component is failed (so it must not run). SELF_POINTER delays have no + // component (get_component() == nullptr) and always fire. + bool is_item_failed_(SchedulerItem *item) const { + Component *component = item->get_component(); + return component != nullptr && component->is_failed(); } + // Helper to check if item should be skipped + bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); } + // Helper to recycle a SchedulerItem back to the pool. // Takes a raw pointer — caller transfers ownership. The item is either added to the // pool or deleted if the pool is full. diff --git a/tests/integration/fixtures/scheduler_blocking_warning.yaml b/tests/integration/fixtures/scheduler_blocking_warning.yaml new file mode 100644 index 0000000000..594ec46afb --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning.yaml @@ -0,0 +1,22 @@ +esphome: + name: scheduler-blocking-warning + on_boot: + then: + - script.execute: blocking_script + +host: +api: +logger: + level: DEBUG + +# The busy-block runs in the second delay's continuation; the warning must name the script. Two +# delays verify the source survives chained delays (the scheduler republishes it each continuation). +script: + - id: blocking_script + then: + - delay: 10ms + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml new file mode 100644 index 0000000000..2d8a62f25b --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml @@ -0,0 +1,30 @@ +esphome: + name: scheduler-blocking-generic + +host: +api: +logger: + level: DEBUG + +globals: + - id: done + type: bool + restore_value: false + initial_value: "false" + +# A delay in a plain (non-script) automation has no owning script, so the block must log the +# generic "a scheduled task" label, not a script name. +interval: + - interval: 100ms + id: gen_interval + then: + - if: + condition: + lambda: "return !id(done);" + then: + - lambda: "id(done) = true;" + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml new file mode 100644 index 0000000000..860fa00c37 --- /dev/null +++ b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml @@ -0,0 +1,29 @@ +esphome: + name: scheduler-delay-failed + +host: +api: +logger: + level: DEBUG + +globals: + - id: started + type: bool + restore_value: false + initial_value: "false" + +# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed +# component must not drop it, since the SELF_POINTER scheduler item has no owning component. +interval: + - interval: 100ms + id: host_interval + then: + - if: + condition: + lambda: "return !id(started);" + then: + - lambda: |- + id(started) = true; + id(host_interval)->mark_failed(); + - delay: 200ms + - logger.log: "DELAY_FIRED_AFTER_FAIL" diff --git a/tests/integration/test_scheduler_blocking_warning.py b/tests/integration/test_scheduler_blocking_warning.py new file mode 100644 index 0000000000..699a5bc746 --- /dev/null +++ b/tests/integration/test_scheduler_blocking_warning.py @@ -0,0 +1,120 @@ +"""Integration tests for blocking-warning source attribution. + +A blocking operation that runs inside a deferred scheduler continuation (e.g. after a ``delay`` +in a script) used to be reported as `` took a long time for an operation (NN ms), +max is 30 ms`` because the continuation carries no component. The warning should instead name +the owning script and report the real threshold (50 ms). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Matches: " took a long time for an operation (NN ms), max is NN ms" +WARN_PATTERN = re.compile( + r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" +) + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Deferred blocking work inside a script is attributed to the script, not "".""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + + # on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the + # continuation, tripping the blocking warning. + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + # Must name the owning script, not "" and not the generic fallback. + assert "" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + assert "a scheduled task" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + match = WARN_PATTERN.search(warning_line) + assert match is not None + assert match.group(1) == "blocking_script", ( + f"Warning should name 'blocking_script', got: {warning_line}" + ) + # The reported threshold must be the real default (50 ms), not the stale "30 ms". + assert match.group(3) == "50", f"Expected 'max is 50 ms', got: {warning_line}" + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning_generic_source( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay in a plain (non-script) automation logs the generic label, not a script name.""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + assert "a scheduled task took a long time" in warning_line, ( + f"Non-script deferred work should log the generic label, got: {warning_line}" + ) + assert "" not in warning_line + match = WARN_PATTERN.search(warning_line) + assert match is not None and match.group(3) == "50", ( + f"Expected 'max is 50 ms', got: {warning_line}" + ) + + +@pytest.mark.asyncio +async def test_scheduler_delay_runs_on_failed_component( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay must still fire even when its context component is marked failed. + + Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's + failed-component skip must not drop them. + """ + loop = asyncio.get_running_loop() + fired: asyncio.Future[bool] = loop.create_future() + + def check_output(line: str) -> None: + if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done(): + fired.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + # If the failed host component wrongly dropped the delay, this times out. + await asyncio.wait_for(fired, timeout=10.0) From aef9b5b72f731ff6d8d307e71cfbdcf37dcb52c5 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 15 Jun 2026 16:48:07 -0400 Subject: [PATCH 04/10] [audio] Bump microMP3 to v0.2.3 (#16977) --- .clang-tidy.hash | 2 +- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7497cc3679..7a3cfc7a03 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c +34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686 diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2ddce577ef..2aceff0c97 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,7 +395,7 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.1") + add_idf_component(name="esphome/micro-mp3", ref="0.2.3") _emit_memory_pair( data.mp3.buffer_memory, "CONFIG_MP3_DECODER_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c97e8906a8..04220488cc 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.1 + version: 0.2.3 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: From 1d38498ca7c27609dd166610397909cdcad8d174 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:05:50 -0400 Subject: [PATCH 05/10] [openthread] Fix InstanceLock releasing the lock twice on try_acquire (#16980) --- esphome/components/openthread/openthread.cpp | 2 +- esphome/components/openthread/openthread.h | 23 +++++++++++++++---- .../components/openthread/openthread_esp.cpp | 17 +++++++------- .../openthread_info_text_sensor.h | 2 +- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index bf14514636..c8ffc02131 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,7 +227,7 @@ bool OpenThreadComponent::teardown() { ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory"); return true; } - otInstance *instance = lock->get_instance(); + otInstance *instance = lock.get_instance(); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); global_openthread_component = nullptr; diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 5898492a50..96f1abdb92 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -86,19 +86,32 @@ class OpenThreadSrpComponent : public Component { void *pool_alloc_(size_t size); }; +// RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the +// guard may or may not own the lock (try_acquire can fail), so check it with +// operator bool before use. Non-copyable and non-movable: the factories return +// by value via guaranteed copy elision, so a guard is never duplicated and the +// lock is released exactly once, when the owning guard goes out of scope. class InstanceLock { public: - static std::optional try_acquire(int delay); + // May fail to acquire within delay ms; check the returned guard with operator bool. + static InstanceLock try_acquire(int delay); + // Blocks until the lock is held. static InstanceLock acquire(); + InstanceLock(const InstanceLock &) = delete; + InstanceLock(InstanceLock &&) = delete; + InstanceLock &operator=(const InstanceLock &) = delete; + InstanceLock &operator=(InstanceLock &&) = delete; ~InstanceLock(); - // Returns the global openthread instance guarded by this lock + explicit operator bool() const { return this->owns_; } + + // Returns the global openthread instance. Only valid on an owning guard + // (operator bool is true); the instance must not be used without the lock held. otInstance *get_instance(); private: - // Use a private constructor in order to force the handling - // of acquisition failure - InstanceLock() {} + explicit InstanceLock(bool owns) : owns_(owns) {} + bool owns_; }; } // namespace esphome::openthread diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index cf1288d90c..4d88cbd226 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -216,14 +216,11 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } -std::optional InstanceLock::try_acquire(int delay) { +InstanceLock InstanceLock::try_acquire(int delay) { if (!global_openthread_component->is_lock_initialized()) { - return {}; + return InstanceLock(false); } - if (esp_openthread_lock_acquire(delay)) { - return InstanceLock(); - } - return {}; + return InstanceLock(esp_openthread_lock_acquire(delay)); } InstanceLock InstanceLock::acquire() { @@ -242,12 +239,16 @@ InstanceLock InstanceLock::acquire() { while (!esp_openthread_lock_acquire(100)) { esp_task_wdt_reset(); } - return InstanceLock(); + return InstanceLock(true); } otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); } -InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } +InstanceLock::~InstanceLock() { + if (this->owns_) { + esp_openthread_lock_release(); + } +} } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index 10e83281f0..ef7c5cc8e9 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -17,7 +17,7 @@ class OpenThreadInstancePollingComponent : public PollingComponent { return; } - this->update_instance(lock->get_instance()); + this->update_instance(lock.get_instance()); } float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } From 66be793cd8a57af57c032e5ab207a34b5f83caa7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:12:53 +1200 Subject: [PATCH 06/10] [docker] Remove alpine base, build only on debian (#16991) --- .github/actions/build-image/action.yaml | 7 ------- docker/Dockerfile | 15 +++++---------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 2081264b91..494c0cebe8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -15,11 +15,6 @@ inputs: description: "Version to build" required: true example: "2023.12.0" - base_os: - description: "Base OS to use" - required: false - default: "debian" - example: "debian" runs: using: "composite" steps: @@ -60,7 +55,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true @@ -86,7 +80,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true diff --git a/docker/Dockerfile b/docker/Dockerfile index 25de9472b6..c360ae1a4a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,9 @@ ARG BUILD_VERSION=dev -ARG BUILD_OS=alpine ARG BUILD_BASE_VERSION=2025.04.0 ARG BUILD_TYPE=docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon +FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker +FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon ARG BUILD_TYPE FROM base-source-${BUILD_TYPE} AS base @@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \ # validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without # it idf_tools.py rejects the openocd install with exit 127 and aborts # the whole framework setup. -RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base libusb; \ - else \ - apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ - && rm -rf /var/lib/apt/lists/*; \ - fi +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 From 0ce89c17ab7233216c78a7e66875477f08d0acf3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:37:31 +1200 Subject: [PATCH 07/10] [ci] Push branch-tagged docker images to ghcr.io for local testing (#16992) --- .github/workflows/ci-docker.yml | 84 ++++++++++++++- docker/build.py | 55 +++++++--- tests/script/test_docker_build.py | 169 ++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 tests/script/test_docker_build.py diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 2a40675f3b..7d4b850356 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -22,7 +22,7 @@ on: - "script/platformio_install_deps.py" permissions: - contents: read # actions/checkout only; the build does not push images + contents: read # actions/checkout only concurrency: # yamllint disable-line rule:line-length @@ -33,6 +33,9 @@ jobs: check-docker: name: Build docker containers runs-on: ${{ matrix.os }} + permissions: + contents: read # actions/checkout to load Dockerfile and build context + packages: write # push branch-tagged images to ghcr.io for local testing strategy: fail-fast: false matrix: @@ -41,6 +44,9 @@ jobs: - "ha-addon" - "docker" # - "lint" + outputs: + tag: ${{ steps.tag.outputs.tag }} + push: ${{ steps.tag.outputs.push }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Python @@ -50,14 +56,82 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - name: Set TAG + - name: Determine tag and whether to push + id: tag run: | - echo "TAG=check" >> $GITHUB_ENV + # Sanitize the branch name into a valid docker tag: replace invalid + # characters, ensure the first character is valid (tags must start + # with [A-Za-z0-9_]), and cap the length at 128 characters. + branch="${{ github.head_ref || github.ref_name }}" + tag="${branch//[^a-zA-Z0-9_.-]/-}" + case "$tag" in + [a-zA-Z0-9_]*) ;; + *) tag="pr-${tag}" ;; + esac + tag="${tag:0:128}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + # Only push branch images for same-repo pull requests. Push events + # only fire for dev/beta/release, whose images are owned by the + # release pipeline -- never overwrite those from here. + if [ "${{ github.event_name }}" = "pull_request" ] \ + && [ "${{ github.repository }}" = "esphome/esphome" ] \ + && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then + echo "push=true" >> "$GITHUB_OUTPUT" + else + echo "push=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the GitHub container registry + if: steps.tag.outputs.push == 'true' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Run build run: | docker/build.py \ - --tag "${TAG}" \ + --tag "${{ steps.tag.outputs.tag }}" \ --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ - build + --registry ghcr \ + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + + manifest: + name: Push ${{ matrix.build_type }} manifest to ghcr.io + needs: [check-docker] + if: needs.check-docker.outputs.push == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to run docker/build.py + packages: write # buildx imagetools writes the multi-arch tag to ghcr.io + strategy: + fail-fast: false + matrix: + build_type: + - "ha-addon" + - "docker" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to the GitHub container registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + docker/build.py \ + --tag "${{ needs.check-docker.outputs.tag }}" \ + --build-type "${{ matrix.build_type }}" \ + --registry ghcr \ + manifest diff --git a/docker/build.py b/docker/build.py index 4d093cf88d..475986e905 100755 --- a/docker/build.py +++ b/docker/build.py @@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon" TYPE_LINT = "lint" TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] +REGISTRY_GHCR = "ghcr" +REGISTRY_DOCKERHUB = "dockerhub" +REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB] + parser = argparse.ArgumentParser() parser.add_argument( @@ -34,6 +38,12 @@ parser.add_argument( parser.add_argument( "--build-type", choices=TYPES, required=True, help="The type of build to run" ) +parser.add_argument( + "--registry", + choices=REGISTRIES, + action="append", + help="Restrict to specific registries (default: all). May be passed multiple times.", +) parser.add_argument( "--dry-run", action="store_true", help="Don't run any commands, just print them" ) @@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t build_parser.add_argument( "--load", help="Load the docker image locally", action="store_true" ) +build_parser.add_argument( + "--no-cache-to", + help="Don't write the build cache (avoids polluting the shared cache)", + action="store_true", +) manifest_parser = subparsers.add_parser( "manifest", help="Create a manifest from already pushed images" ) @@ -95,11 +110,14 @@ def main(): print("Command failed") sys.exit(1) + registries = args.registry or REGISTRIES + # detect channel from tag match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) major_minor_version = None if match is None: - channel = CHANNEL_DEV + # Custom tag (e.g. a branch name) -- push only the tag itself + channel = None elif match.group(2) is None: major_minor_version = match.group(1) channel = CHANNEL_RELEASE @@ -128,11 +146,18 @@ def main(): CHANNEL_DEV: "cache-dev", CHANNEL_BETA: "cache-beta", CHANNEL_RELEASE: "cache-latest", - }[channel] - cache_img = f"ghcr.io/{params.build_to}:{cache_tag}" + }.get(channel, "cache-dev") + # Cache images live alongside the pushed images; prefer GHCR when it is + # one of the selected registries, otherwise fall back to Docker Hub so a + # registry-restricted build doesn't need GHCR auth. + cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else "" + cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}" - imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push] - imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] + imgs = [] + if REGISTRY_DOCKERHUB in registries: + imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] # 3. build cmd = [ @@ -155,7 +180,9 @@ def main(): for img in imgs: cmd += ["--tag", img] if args.push: - cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"] + cmd += ["--push"] + if not args.no_cache_to: + cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"] if args.load: cmd += ["--load"] @@ -163,20 +190,22 @@ def main(): elif args.command == "manifest": manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to - targets = [f"{manifest}:{tag}" for tag in tags_to_push] - targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] - # 1. Create manifests + targets = [] + if REGISTRY_DOCKERHUB in registries: + targets += [f"{manifest}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] + # Use buildx imagetools (not `docker manifest`) so the per-arch sources, + # which buildx pushes as single-platform manifest lists, are combined + # and pushed correctly in one step. for target in targets: - cmd = ["docker", "manifest", "create", target] + cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] for arch in ARCHS: src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" if target.startswith("ghcr.io"): src = f"ghcr.io/{src}" cmd.append(src) run_command(*cmd) - # 2. Push manifests - for target in targets: - run_command("docker", "manifest", "push", target) if __name__ == "__main__": diff --git a/tests/script/test_docker_build.py b/tests/script/test_docker_build.py new file mode 100644 index 0000000000..34bcc4e714 --- /dev/null +++ b/tests/script/test_docker_build.py @@ -0,0 +1,169 @@ +"""Unit tests for docker/build.py command generation.""" + +import importlib.util +from pathlib import Path +import sys + +import pytest + +_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py" +_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY) +docker_build = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(docker_build) + + +def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]: + """Run build.py main() in dry-run mode and return the emitted commands.""" + full_argv = ["build.py", "--dry-run", *argv] + with pytest.MonkeyPatch.context() as mp: + mp.setattr(sys, "argv", full_argv) + docker_build.main() + out = capsys.readouterr().out + return [line[2:] for line in out.splitlines() if line.startswith("$ ")] + + +def test_branch_build_pushes_single_ghcr_tag_without_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + "--push", + "--no-cache-to", + ) + + assert len(commands) == 1 + cmd = commands[0] + # Custom tag -> only the tag itself, no companion "dev"/"latest" tags + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert ":dev" not in cmd + # ghcr only -> no Docker Hub image name + assert "--tag esphome/esphome-amd64:my-branch" not in cmd + # custom tag falls back to the dev cache for reads + assert ( + "--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd + ) + assert "--push" in cmd + # --no-cache-to must suppress the cache write + assert "--cache-to" not in cmd + + +def test_branch_manifest_targets_ghcr_only( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "ha-addon", + "--registry", + "ghcr", + "manifest", + ) + + assert commands == [ + "docker buildx imagetools create " + "--tag ghcr.io/esphome/esphome-hassio:my-branch " + "ghcr.io/esphome/esphome-hassio-amd64:my-branch " + "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ] + + +def test_release_build_keeps_both_registries_and_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "2025.6.0", + "--arch", + "amd64", + "--build-type", + "docker", + "build", + "--push", + ) + + cmd = commands[0] + # Default (no --registry) keeps both Docker Hub and ghcr image names + assert "--tag esphome/esphome-amd64:2025.6.0" in cmd + assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd + # Release channel still gets its companion tags + assert "--tag esphome/esphome-amd64:latest" in cmd + # Without --no-cache-to the cache write is preserved + assert ( + "--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max" + in cmd + ) + + +def test_build_no_push_omits_push_and_cache( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + ) + + cmd = commands[0] + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert "--push" not in cmd + assert "--cache-to" not in cmd + + +def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "dockerhub", + "build", + "--push", + ) + + cmd = commands[0] + assert "--tag esphome/esphome-amd64:my-branch" in cmd + assert "ghcr.io" not in cmd + # Cache reference falls back to Docker Hub when GHCR isn't selected + assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd + + +def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "docker", + "--registry", + "dockerhub", + "manifest", + ) + + create = commands[0] + assert create.startswith( + "docker buildx imagetools create --tag esphome/esphome:my-branch " + ) + assert "ghcr.io" not in create From 0422b581cb1537b6ceebb1b12546911a51efc43b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:24:26 +1200 Subject: [PATCH 08/10] [core] Stop parent git repos from breaking ESP-IDF/PlatformIO builds (#16994) --- esphome/espidf/toolchain.py | 6 +++++ esphome/helpers.py | 21 +++++++++++++++ esphome/platformio/toolchain.py | 5 ++++ tests/unit_tests/test_espidf_toolchain.py | 14 ++++++++++ tests/unit_tests/test_helpers.py | 27 +++++++++++++++++++ tests/unit_tests/test_platformio_toolchain.py | 5 ++++ 6 files changed, 78 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 2fef3faf8d..c622a2dd36 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -14,6 +14,7 @@ from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary +from esphome.helpers import add_git_ceiling_directory _LOGGER = logging.getLogger(__name__) @@ -82,6 +83,11 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] |= get_framework_env( *_get_esphome_esp_idf_paths(version) ) + + # Cap git's repo search at the config directory so ESP-IDF's + # `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(env_cache[version], CORE.config_dir) return env_cache[version] diff --git a/esphome/helpers.py b/esphome/helpers.py index 733474c9c9..ef7e2d0b93 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import MutableMapping from contextlib import suppress import ipaddress import logging @@ -374,6 +375,26 @@ def is_ha_addon(): return get_bool_env("ESPHOME_IS_HA_ADDON") +def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) -> None: + """Add ``directory`` to ``env``'s ``GIT_CEILING_DIRECTORIES`` list. + + Git stops walking up the directory tree to find a repository once it reaches + a ceiling directory, so this caps the search at ``directory`` (the ESPHome + project root). Without it, an uninitialized or corrupt git repo in a parent + directory makes the ``git describe`` that build toolchains run for the app + version error out and fail the whole build. + + ``GIT_CEILING_DIRECTORIES`` is an ``os.pathsep``-joined list of absolute + paths; any existing entries are preserved and duplicates are skipped. + """ + ceiling = str(directory) + existing = env.get("GIT_CEILING_DIRECTORIES", "") + parts = existing.split(os.pathsep) if existing else [] + if ceiling not in parts: + parts.append(ceiling) + env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts) + + def rmtree(path: Path | str) -> None: """Remove a directory tree, handling read-only files on Windows. diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index c81420e6ca..c97df812e3 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -7,6 +7,7 @@ import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError +from esphome.helpers import add_git_ceiling_directory from esphome.util import FlashImage, run_external_process _LOGGER = logging.getLogger(__name__) @@ -53,6 +54,10 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") + # Cap git's repo search at the config directory so the framework's build + # scripts running `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(os.environ, CORE.config_dir) # Strip the Windows extended-length path prefix from sys.executable so it # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted # command lines run through cmd.exe. diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 8849ea8bc8..b2309439f9 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -150,6 +150,20 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: assert result == {"cxx_path": "regen"} +def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: + """The IDF env caps git's upward search at the config directory. + + This stops ESP-IDF's `git describe` from walking into an uninitialized or + corrupt git repo in a parent directory and failing the build. + """ + toolchain._cache().env.clear() + # Set IDF_PATH so the framework-install branch is skipped. + with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}): + env = toolchain._get_idf_env(version="5.5.4") + assert CORE.config_dir == setup_core + assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) + + def test_get_core_framework_version_from_core_data(): """The version is read from CORE.data when validation populated it.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index efc2d8e42a..70c4b90082 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -196,6 +196,33 @@ def test_is_ha_addon(monkeypatch, value, expected): assert actual == expected +def test_add_git_ceiling_directory_sets_when_unset(): + """An empty env gets GIT_CEILING_DIRECTORIES set to the directory.""" + env: dict[str, str] = {} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + +def test_add_git_ceiling_directory_appends_to_existing(): + """An existing value is preserved and the new directory is appended.""" + env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [ + str(Path("/some/ceiling")), + str(directory), + ] + + +def test_add_git_ceiling_directory_skips_duplicate(): + """A directory already in the list is not appended again.""" + directory = Path("/home/user/config") + env = {"GIT_CEILING_DIRECTORIES": str(directory)} + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + def test_walk_files(fixture_path): path = fixture_path / "helpers" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index a37b19f584..568b43a259 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -304,6 +304,11 @@ def test_run_platformio_cli_sets_environment_variables( ) assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ + # Caps git's upward search at the config dir so an uninitialized or + # corrupt parent git repo can't break the framework's `git describe`. + assert str(CORE.config_dir) in os.environ["GIT_CEILING_DIRECTORIES"].split( + os.pathsep + ) # Check command was called correctly — runs PlatformIO as a subprocess # via the esphome.platformio.runner entry point. From 310baab5248a4fd28cc4c7941f2b716be63e3482 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:47:14 +1200 Subject: [PATCH 09/10] [docker] Bundle device-builder 1.0.1, make HA add-on builder-only (#16989) Co-authored-by: J. Nick Koston --- docker/Dockerfile | 5 +- docker/docker_entrypoint.sh | 8 ++ .../etc/cont-init.d/40-device-builder.sh | 22 ----- .../etc/nginx/includes/mime.types | 96 ------------------- .../etc/nginx/includes/proxy_params.conf | 16 ---- .../etc/nginx/includes/server_params.conf | 8 -- .../etc/nginx/includes/ssl_params.conf | 8 -- .../etc/nginx/includes/upstream.conf | 3 - docker/ha-addon-rootfs/etc/nginx/nginx.conf | 30 ------ .../etc/nginx/servers/.gitkeep | 1 - .../etc/nginx/templates/direct.gtpl | 28 ------ .../etc/nginx/templates/ingress.gtpl | 18 ---- .../s6-rc.d/discovery/dependencies.d/nginx | 0 .../etc/s6-overlay/s6-rc.d/discovery/run | 2 +- .../etc/s6-overlay/s6-rc.d/esphome/finish | 4 +- .../etc/s6-overlay/s6-rc.d/esphome/run | 15 +-- .../s6-rc.d/init-nginx/dependencies.d/base | 0 .../etc/s6-overlay/s6-rc.d/init-nginx/run | 35 ------- .../etc/s6-overlay/s6-rc.d/init-nginx/type | 1 - .../etc/s6-overlay/s6-rc.d/init-nginx/up | 1 - .../s6-rc.d/nginx/dependencies.d/esphome | 0 .../s6-rc.d/nginx/dependencies.d/init-nginx | 0 .../etc/s6-overlay/s6-rc.d/nginx/finish | 25 ----- .../etc/s6-overlay/s6-rc.d/nginx/run | 27 ------ .../etc/s6-overlay/s6-rc.d/nginx/type | 1 - .../s6-rc.d/user/contents.d/init-nginx | 0 .../s6-overlay/s6-rc.d/user/contents.d/nginx | 0 27 files changed, 20 insertions(+), 334 deletions(-) delete mode 100755 docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/mime.types delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/nginx.conf delete mode 100644 docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/dependencies.d/base delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/up delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/esphome delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/init-nginx delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/finish delete mode 100755 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx delete mode 100644 docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx diff --git a/docker/Dockerfile b/docker/Dockerfile index c360ae1a4a..c7634cf1c8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.0 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -31,6 +31,9 @@ RUN \ uv pip install --no-cache-dir \ -r /requirements.txt +# Install the ESPHome Device Builder dashboard. +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.1 + RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 1b9224244c..18baf40c29 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -27,4 +27,12 @@ if [[ -d /build ]]; then export ESPHOME_BUILD_PATH=/build fi +# The default CMD is "dashboard /config". Route the dashboard to the new +# Device Builder, but pass every other subcommand (compile, run, config, +# logs, ...) straight through to the esphome CLI so direct CLI use keeps working. +if [[ "$1" == "dashboard" ]]; then + shift + exec esphome-device-builder "$@" +fi + exec esphome "$@" diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh deleted file mode 100755 index b990469762..0000000000 --- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/with-contenv bashio -# ============================================================================== -# Installs the latest prerelease of esphome-device-builder when the -# `use_new_device_builder` config option is enabled. -# This is a temporary install-on-boot step until esphome-device-builder -# becomes a direct dependency of esphome. -# ============================================================================== - -if ! bashio::config.true 'use_new_device_builder'; then - exit 0 -fi - -bashio::log.info "Installing latest prerelease of esphome-device-builder..." -if command -v uv > /dev/null; then - uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ - esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -else - pip install --no-cache-dir --pre --upgrade esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -fi -bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types deleted file mode 100644 index 7c7cdef2d1..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types +++ /dev/null @@ -1,96 +0,0 @@ -types { - text/html html htm shtml; - text/css css; - text/xml xml; - image/gif gif; - image/jpeg jpeg jpg; - application/javascript js; - application/atom+xml atom; - application/rss+xml rss; - - text/mathml mml; - text/plain txt; - text/vnd.sun.j2me.app-descriptor jad; - text/vnd.wap.wml wml; - text/x-component htc; - - image/png png; - image/svg+xml svg svgz; - image/tiff tif tiff; - image/vnd.wap.wbmp wbmp; - image/webp webp; - image/x-icon ico; - image/x-jng jng; - image/x-ms-bmp bmp; - - font/woff woff; - font/woff2 woff2; - - application/java-archive jar war ear; - application/json json; - application/mac-binhex40 hqx; - application/msword doc; - application/pdf pdf; - application/postscript ps eps ai; - application/rtf rtf; - application/vnd.apple.mpegurl m3u8; - application/vnd.google-earth.kml+xml kml; - application/vnd.google-earth.kmz kmz; - application/vnd.ms-excel xls; - application/vnd.ms-fontobject eot; - application/vnd.ms-powerpoint ppt; - application/vnd.oasis.opendocument.graphics odg; - application/vnd.oasis.opendocument.presentation odp; - application/vnd.oasis.opendocument.spreadsheet ods; - application/vnd.oasis.opendocument.text odt; - application/vnd.openxmlformats-officedocument.presentationml.presentation - pptx; - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - xlsx; - application/vnd.openxmlformats-officedocument.wordprocessingml.document - docx; - application/vnd.wap.wmlc wmlc; - application/x-7z-compressed 7z; - application/x-cocoa cco; - application/x-java-archive-diff jardiff; - application/x-java-jnlp-file jnlp; - application/x-makeself run; - application/x-perl pl pm; - application/x-pilot prc pdb; - application/x-rar-compressed rar; - application/x-redhat-package-manager rpm; - application/x-sea sea; - application/x-shockwave-flash swf; - application/x-stuffit sit; - application/x-tcl tcl tk; - application/x-x509-ca-cert der pem crt; - application/x-xpinstall xpi; - application/xhtml+xml xhtml; - application/xspf+xml xspf; - application/zip zip; - - application/octet-stream bin exe dll; - application/octet-stream deb; - application/octet-stream dmg; - application/octet-stream iso img; - application/octet-stream msi msp msm; - - audio/midi mid midi kar; - audio/mpeg mp3; - audio/ogg ogg; - audio/x-m4a m4a; - audio/x-realaudio ra; - - video/3gpp 3gpp 3gp; - video/mp2t ts; - video/mp4 mp4; - video/mpeg mpeg mpg; - video/quicktime mov; - video/webm webm; - video/x-flv flv; - video/x-m4v m4v; - video/x-mng mng; - video/x-ms-asf asx asf; - video/x-ms-wmv wmv; - video/x-msvideo avi; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf deleted file mode 100644 index a1ebb5079a..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf +++ /dev/null @@ -1,16 +0,0 @@ -proxy_http_version 1.1; -proxy_ignore_client_abort off; -proxy_read_timeout 86400s; -proxy_redirect off; -proxy_send_timeout 86400s; -proxy_max_temp_file_size 0; - -proxy_set_header Accept-Encoding ""; -proxy_set_header Connection $connection_upgrade; -proxy_set_header Host $http_host; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-NginX-Proxy true; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header Authorization ""; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf deleted file mode 100644 index debdf83a8c..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -root /dev/null; -server_name $hostname; - -client_max_body_size 512m; - -add_header X-Content-Type-Options nosniff; -add_header X-XSS-Protection "1; mode=block"; -add_header X-Robots-Tag none; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf deleted file mode 100644 index e6789cbb9b..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -ssl_protocols TLSv1.2 TLSv1.3; -ssl_prefer_server_ciphers off; -ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; -ssl_session_timeout 10m; -ssl_session_cache shared:SSL:10m; -ssl_session_tickets off; -ssl_stapling on; -ssl_stapling_verify on; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf deleted file mode 100644 index 8e782bdc88..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf +++ /dev/null @@ -1,3 +0,0 @@ -upstream esphome { - server unix:/var/run/esphome.sock; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf deleted file mode 100644 index 497427596d..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -daemon off; -user root; -pid /var/run/nginx.pid; -worker_processes 1; -error_log /proc/1/fd/1 error; -events { - worker_connections 1024; -} - -http { - include /etc/nginx/includes/mime.types; - - access_log off; - default_type application/octet-stream; - gzip on; - keepalive_timeout 65; - sendfile on; - server_tokens off; - - tcp_nodelay on; - tcp_nopush on; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - include /etc/nginx/includes/upstream.conf; - include /etc/nginx/servers/*.conf; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep deleted file mode 100644 index 85ad51be5f..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley) diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl deleted file mode 100644 index 4fb0ca3f90..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl +++ /dev/null @@ -1,28 +0,0 @@ -server { - {{ if not .ssl }} - listen 6052 default_server; - {{ else }} - listen 6052 default_server ssl http2; - {{ end }} - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - {{ if .ssl }} - include /etc/nginx/includes/ssl_params.conf; - - ssl_certificate /ssl/{{ .certfile }}; - ssl_certificate_key /ssl/{{ .keyfile }}; - - # Redirect http requests to https on the same port. - # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/ - error_page 497 https://$http_host$request_uri; - {{ end }} - - # Clear Home Assistant Ingress header - proxy_set_header X-HA-Ingress ""; - - location / { - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl deleted file mode 100644 index 105ddde710..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 127.0.0.1:{{ .port }} default_server; - listen {{ .interface }}:{{ .port }} default_server; - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - # Set Home Assistant Ingress header - proxy_set_header X-HA-Ingress "YES"; - - location / { - allow 172.30.32.2; - allow 127.0.0.1; - deny all; - - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index 111157d301..bb36cfcdb4 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -16,7 +16,7 @@ fi port=$(bashio::addon.ingress_port) -# Wait for NGINX to become available +# Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 config=$(\ diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish index 6e0f8fe23a..da450c25f9 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish @@ -2,7 +2,7 @@ # shellcheck shell=bash # ============================================================================== # Home Assistant Community Add-on: ESPHome -# Take down the S6 supervision tree when ESPHome dashboard fails +# Take down the S6 supervision tree when ESPHome Device Builder fails # ============================================================================== declare exit_code readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode - fi - [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt -elif [[ "${exit_code_service}" -ne 0 ]]; then - if [[ "${exit_code_container}" -eq 0 ]]; then - echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode - fi - exec /run/s6/basedir/bin/halt -fi diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run deleted file mode 100755 index b8251e8e01..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ /dev/null @@ -1,27 +0,0 @@ -#!/command/with-contenv bashio -# shellcheck shell=bash -# ============================================================================== -# Community Hass.io Add-ons: ESPHome -# Runs the NGINX proxy -# ============================================================================== - -# The new device builder handles HA ingress itself, so nginx is bypassed. -# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on -# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish -# stamp the container exit 143, which trips the Supervisor's SIGTERM check. -if bashio::config.true 'use_new_device_builder'; then - bashio::log.info "NGINX bypassed: new device builder serves ingress directly." - trap 'exit 0' TERM - sleep infinity & - wait - exit 0 -fi - -bashio::log.info "Waiting for ESPHome dashboard to come up..." - -while [[ ! -S /var/run/esphome.sock ]]; do - sleep 0.5 -done - -bashio::log.info "Starting NGINX..." -exec nginx diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type deleted file mode 100644 index 5883cff0cd..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 From 53fd99578ae69088a4a93bc7fc8ad9b3f4932969 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:02:55 +1200 Subject: [PATCH 10/10] Bump version to 2026.6.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 809f934797..c94ea34387 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0b2 +PROJECT_NUMBER = 2026.6.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 27abfa2dd2..bf770ae5b5 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0b2" +__version__ = "2026.6.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (